Compare commits

..
24 changed files with 9981 additions and 8626 deletions

No files matched your search

+1 -43
View File
@@ -1,43 +1 @@
# 七虎AI PaaS 平台 — 版本清单 (VERSION)
# 版本规范见 docs/VERSIONING.md
# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始)
# 环境标签: prod = 正式版 | test = 测试版
VERSION=v2026.07.19.3
ENV=prod
DATE=2026-07-19
BASE_IMAGE=qihu-aiflow:with-deps
BACKUP_IMAGE=qihu-aiflow:backup-20260719-003
## 本版本变更 (v2026.07.19.3 / prod)
- Phase2.2 成本归因:token_usage_logs 新增 trace_id 列(迁移 020_add_trace_id_token_usage_logs.sql,幂等 ALTER),打通「链路 -> 成本」归集
- _write_media_token_log(媒体图片/视频)注入 get_trace_id(),每条媒体 TokenUsageLog 现可回溯到请求链路
- LogService.log_token_usage 增加 trace_id 参数(为后续文本 LLM token 记账接通成本归因预留)
- chat_service / chat_media 在 LLM 补全、MCP 工具拉取/调用、技能执行、AGNES 媒体生成处加 span 子链路(async with span),一次请求可下钻耗时分布
- 注意:_generate_media_task 为后台 fire-and-forget,其 span 会因处于 asyncio.create_task 表达式内而非法,已移除;真实媒体耗时由 chat_media 内 media.agnes_call span 覆盖
- 纯增量埋点,不改接口返回结构与业务行为;pytest 9 passed + vitest 4 passed 门禁绿
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-003
## 本版本变更 (v2026.07.19.1 / prod)
- Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成)
- 路由处理器(/conversations、/messages、/media/relay、/models 等全部 10 个路由)保留在 chat.py,业务/服务函数迁至子模块并通过 import 再导出
- 关键修复:此前拆分误删了全部 @router 装饰器导致路由为 0(重启即全 404),本版本以 AST 比对原始提交恢复装饰器,并补回 chat_service._send_message_core 导入
- 测试安全网守住 R1/R2/R3 不回退:后端 9 passed(smoke/market/sms + R1/R2/R3),前端 4 passed(datetime 时区格式化);ci_gate 全绿
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-001(旧 chat.py 10070 行)
## 本版本变更 (v2026.07.19.2 / prod)
- Phase2.1 统一可观测基座:新增 app/core/observability.py(trace_id contextvar + TraceIdFilter + span 计时器 + 可选 JSON formatter),零侵入为全平台日志注入链路 ID
- main.py APITrackingMiddleware 复用既有 request_id 作为 trace_id,并为每个响应注入 X-Trace-Id 响应头(前端可关联排查)
- config.py setup_file_logging 接入 TraceIdFilter 并将日志格式升级为 [tid:%(trace_id)s],所有 getLogger(__name__) 日志自动带链路 ID
- chat.py send_message 路由用 span 包裹 _send_message_core(耗时+trace_id+conv_id/user_id),覆盖 LLM/MCP/媒体生成整体耗时,为后续成本归因建立回归基线
- 纯增量埋点,不改任何接口返回结构与业务行为;向后兼容既有 logging 用法
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-002
## 历史
- v2026.07.18.5 (prod):Phase0 收官 — 最小 CI 门禁(ci_gate.py + pre-push 钩子 + 休眠 Gitea workflow)。后端 9 passed,前端 4 passed。
- v2026.07.18.4 (prod):前端 vitest 基建落地(4 passed,datetime 时区格式化)。
- v2026.07.18.3 (prod):回归用例 R1/R2/R3(9 passed)— 微信Bot截断、技能/MCP覆盖规则、媒体生成超时回写。
- v2026.07.18.2 (prod):后端 pytest 基建落地(6 passed)— 冒烟/市场14类/短信宝MD5。
- v2026.07.18.1 (prod):Phase0 启动 — 部署前镜像备份 SOP + 版本号规范 + 引入测试基建。
- 旧方案:镜像曾用 AIFlow v2.0.1 / v2026.07.14 / v2026.07.14-nav;git 曾用 v2026.07.12-recover / v2026.07.12-ui-recover / v2026.07.13-recover。自 v2026.07.18.1 起统一为 vYYYY.MM.DD.N + prod/test,便于正式版推广后与测试版做环境切割。
- 历史功能:AI聊天(real API + tool-call function calling)、知识库文件存储 + AI 图谱、频道二维码、邮件/短信配置 + AI 生成、支付配置、平台驾驶舱(token 累计 + 审计/操作日志)、Agent 资源 API、evolution pipeline 自动恢复、全局默认模型、广告位图片上传 + AI 绘图、应用市场安装/卸载/搜索/去重、资产文件类型分类 + 在线预览。迁移 001-015。
v2026.07.19.8
@@ -88,6 +88,7 @@ def agent_to_dict(a: MarketApp) -> dict:
"agent_endpoint": a.agent_endpoint,
"skill_content": a.skill_content,
"config": config,
"orchestration": a.orchestration,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
@@ -555,3 +556,101 @@ async def get_available_models(
for m, p in models
]
}
# ============================================================
# 3.1 tool_policy 沙箱:Agent 工具/MCP 权限策略
# ============================================================
class ToolPolicyModel(BaseModel):
tool_policy: Optional[dict] = None # null = 清除策略(恢复默认允许全部)
@router.get("/{agent_id}/tool-policy", summary="获取 Agent 工具策略")
async def get_agent_tool_policy(
agent_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id, MarketApp.type == "agent"
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
return {"agent_id": agent_id, "tool_policy": agent.tool_policy}
@router.put("/{agent_id}/tool-policy", summary="设置 Agent 工具策略")
async def set_agent_tool_policy(
agent_id: str,
body: ToolPolicyModel,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id, MarketApp.type == "agent"
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
tp = body.tool_policy
if tp is not None:
if not isinstance(tp, dict):
raise HTTPException(status_code=400, detail="tool_policy 必须为对象")
# 轻量结构校验
if "allowed_tools" in tp and tp["allowed_tools"] is not None:
at = tp["allowed_tools"]
if not isinstance(at, dict):
raise HTTPException(status_code=400, detail="allowed_tools 必须为对象")
for k in ("skill_ids", "mcp_ids"):
if k in at and at[k] is not None and not isinstance(at[k], list):
raise HTTPException(status_code=400, detail=f"allowed_tools.{k} 必须为数组")
for k in ("max_calls_per_conversation", "per_round_cap"):
if k in tp and tp[k] is not None and not isinstance(tp[k], int):
raise HTTPException(status_code=400, detail=f"{k} 必须为整数")
agent.tool_policy = tp
db.commit()
return {"agent_id": agent_id, "tool_policy": agent.tool_policy, "updated": True}
# 3.3 多 Agent 编排图(图引擎)
# ============================================================
class OrchestrationModel(BaseModel):
orchestration: Optional[dict] = None # null = 清除编排图
@router.get("/{agent_id}/orchestration", summary="获取 Agent 编排图")
async def get_agent_orchestration(
agent_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id, MarketApp.type == "agent"
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
return {"agent_id": agent_id, "orchestration": agent.orchestration}
@router.put("/{agent_id}/orchestration", summary="设置 Agent 编排图")
async def set_agent_orchestration(
agent_id: str,
body: OrchestrationModel,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id, MarketApp.type == "agent"
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
og = body.orchestration
if og is not None:
if not isinstance(og, dict):
raise HTTPException(status_code=400, detail="orchestration 必须为对象")
try:
from app.core.orchestrator import validate_graph
validate_graph(og)
except Exception as e:
raise HTTPException(status_code=400, detail=f"编排图非法: {str(e)}")
agent.orchestration = og
db.commit()
return {"agent_id": agent_id, "orchestration": agent.orchestration, "updated": True}
@@ -0,0 +1,103 @@
"""调用可观测面板后端 API(媒体/LLM 调用归因)。
路由前缀: /app/api/admin/observability
数据来源: token_usage_logs(Phase 2.2 已加 trace_id 列,可经 X-Trace-Id 关联请求链路)。
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, Depends
from ...database import get_db
from ..auth import get_current_user
from sqlalchemy import text
from sqlalchemy.orm import Session
router = APIRouter()
@router.get("/summary")
async def observability_summary(
days: int = Query(30, description="统计天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""媒体/LLM 调用归因:总览 + 按模型 + 按用户 + 每日趋势 + 最近调用(trace_id 下钻)。"""
since = datetime.now() - timedelta(days=days)
totals = db.execute(text("""
SELECT COUNT(*),
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(cost), 0),
COUNT(DISTINCT user_id)
FROM token_usage_logs WHERE created_at >= :since
"""), {"since": since}).fetchone()
by_model = db.execute(text("""
SELECT model_name, COUNT(*), COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cost), 0)
FROM token_usage_logs WHERE created_at >= :since
GROUP BY model_name ORDER BY COALESCE(SUM(total_tokens), 0) DESC
"""), {"since": since}).fetchall()
by_user = db.execute(text("""
SELECT t.user_id, u.username, COUNT(*), COALESCE(SUM(t.total_tokens), 0), COALESCE(SUM(t.cost), 0)
FROM token_usage_logs t LEFT JOIN users u ON u.id = t.user_id
WHERE t.created_at >= :since
GROUP BY t.user_id, u.username ORDER BY COALESCE(SUM(t.total_tokens), 0) DESC LIMIT 20
"""), {"since": since}).fetchall()
daily = db.execute(text("""
SELECT DATE(created_at) AS d, COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cost), 0), COUNT(*)
FROM token_usage_logs WHERE created_at >= :since
GROUP BY DATE(created_at) ORDER BY d
"""), {"since": since}).fetchall()
recent = db.execute(text("""
SELECT trace_id, model_name, total_tokens, cost, created_at
FROM token_usage_logs WHERE created_at >= :since
ORDER BY created_at DESC LIMIT 50
"""), {"since": since}).fetchall()
return {
"totals": {
"calls": int(totals[0] or 0),
"tokens": int(totals[1] or 0),
"cost": float(totals[2] or 0),
"users": int(totals[3] or 0),
},
"by_model": [
{
"model_name": m[0] or "unknown",
"calls": int(m[1] or 0),
"tokens": int(m[2] or 0),
"cost": float(m[3] or 0),
}
for m in by_model
],
"by_user": [
{
"user_id": u[0],
"username": u[1] or u[0],
"calls": int(u[2] or 0),
"tokens": int(u[3] or 0),
"cost": float(u[4] or 0),
}
for u in by_user
],
"daily": [
{
"date": str(d[0]),
"tokens": int(d[1] or 0),
"cost": float(d[2] or 0),
"calls": int(d[3] or 0),
}
for d in daily
],
"recent": [
{
"trace_id": r[0],
"model_name": r[1] or "unknown",
"tokens": int(r[2] or 0),
"cost": float(r[3] or 0),
"created_at": r[4].strftime("%Y-%m-%d %H:%M:%S") if r[4] else None,
}
for r in recent
],
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+161 -1
View File
@@ -20,7 +20,7 @@ from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary,
@@ -1230,3 +1230,163 @@ def _get_conversation_context(db: Session, conv_id: str) -> dict:
return context
# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 =====
logger = logging.getLogger(__name__)
def _estimate_tokens(text):
"""粗略 token 估算:中英文混排约 2 字符/token。"""
if not text:
return 0
return max(1, len(text) // 2)
def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
exclude_latest_user=True, include_summary=True):
"""会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。
- 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
"""
summary_block = None
if include_summary:
try:
summ = db.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id
).first()
if summ and summ.summary_text:
summary_block = {
"role": "system",
"content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text,
}
except Exception:
pass
rows = db.query(Message).filter(
Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]),
).order_by(desc(Message.created_at)).limit(max_messages).all()
if exclude_latest_user:
for i, m in enumerate(rows):
if m.role == "user":
rows = rows[:i] + rows[i + 1:]
break
kept = []
used = 0
for m in rows: # 新->旧
t = _estimate_tokens(m.content or "")
if kept and used + t > max_tokens:
break
kept.append({"role": m.role, "content": m.content})
used += t
kept.reverse() # 时间正序
out = []
if summary_block:
out.append(summary_block)
out.extend(kept)
return out
async def _summarize_via_llm(prompt):
"""调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。"""
try:
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import _proxy_for_url
sdb = SessionLocal()
try:
ai_model = resolve_auto_model("text", sdb)
if not ai_model:
return None
provider = sdb.query(ModelProvider).filter(
ModelProvider.id == ai_model.provider_id
).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
if not provider or not provider.api_base_url or not api_key:
return None
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
body = {
"model": ai_model.name,
"messages": [
{"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"},
{"role": "user", "content": prompt},
],
"max_tokens": 512,
"temperature": 0.2,
}
async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client:
resp = await client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=body,
)
if resp.status_code != 200:
return None
data = resp.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
finally:
sdb.close()
except Exception as e:
logger.warning(f"[context] summarize llm failed: {e}")
return None
async def _maybe_compress_history(conv_id):
"""后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
"""
sdb = None
try:
sdb = SessionLocal()
all_msgs = sdb.query(Message).filter(
Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]),
).order_by(asc(Message.created_at)).all()
PROTECTED = 20
if len(all_msgs) <= PROTECTED:
return
compressible = all_msgs[: len(all_msgs) - PROTECTED]
if len(compressible) < 8:
return
chunk = compressible[:30]
text = "\n".join(f"{m.role}: {m.content}" for m in chunk)
existing = sdb.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id
).first()
prior = existing.summary_text if existing else ""
prompt = (
"请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。"
"若提供『已有摘要』,请合并而非重复。\n\n"
f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}"
)
summary = await _summarize_via_llm(prompt)
if not summary:
return
if existing:
existing.summary_text = summary
existing.token_count = _estimate_tokens(summary)
existing.updated_at = datetime.now()
else:
sdb.add(ConversationSummary(
conversation_id=conv_id,
summary_text=summary,
token_count=_estimate_tokens(summary),
updated_at=datetime.now(),
))
sdb.commit()
except Exception as e:
logger.warning(f"[context] compress history failed conv={conv_id}: {e}")
if sdb is not None:
try:
sdb.rollback()
except Exception:
pass
finally:
if sdb is not None:
try:
sdb.close()
except Exception:
pass
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,6 +1,7 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from app.core.credential import decrypt_secret, mask_secret
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
@@ -513,6 +514,7 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
"tool_policy": ag.tool_policy,
# 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段:
@@ -529,7 +531,8 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
"agent_api_key": ag.agent_api_key or None,
"agent_api_key": mask_secret(decrypt_secret(ag.agent_api_key)) or None,
"orchestration": ag.orchestration,
+234 -232
View File
@@ -1,232 +1,234 @@
"""
IM Bot 真实 LLM 调用。
原实现位于 channels.py(通用频道管理模块),该模块已移除。
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
行为(对齐 AI 聊天):
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
不再对"制作图片"这类请求只回文本。
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
"""
import asyncio
import json
import logging
import re as _re
from datetime import datetime, timezone, timedelta
from typing import Optional
from urllib.parse import urlparse as _urlparse
import httpx
from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
logger = logging.getLogger(__name__)
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
国内域名(尊重 NO_PROXY)直连,更稳定。
"""
import os
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
if "qihuyun.com" in host:
return None
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy:
if host == _np or host.endswith("." + _np) or _np in host:
return None
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
def _run_async(coro, timeout=None):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if timeout is not None:
async def _timed():
return await asyncio.wait_for(coro, timeout=timeout)
target = _timed()
else:
target = coro
if loop is not None and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(1) as ex:
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
return asyncio.run(target)
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里“查无此项”。
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
"""
if not user_id or not ai_model:
return
try:
base = 1200 if media_type == "video" else 400
est = base + (len(prompt or "") // 4)
db.add(TokenUsageLog(
id=gen_uuid(),
user_id=user_id,
model_id=ai_model.id,
model_name=ai_model.name,
prompt_tokens=est,
completion_tokens=0,
total_tokens=est,
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
))
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。"""
try:
from app.api.chat import (
_generate_media,
detect_media_intent,
_discover_installed_skills,
_build_system_prompt,
_execute_skill_tool,
)
# ===== 1) 媒体意图路由(Issue 7)=====
intent = detect_media_intent(prompt)
if intent:
ai_model = resolve_auto_model(intent, db)
if ai_model:
try:
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
except Exception:
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
if result:
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
s = result.strip()
if s.startswith("<img") or s.startswith("<video"):
m = _re.search(r'src="([^"]+)"', s)
url = m.group(1) if m else ""
media_type = "image" if s.startswith("<img") else "video"
if not url:
return "(生成完成,但未能提取媒体地址)"
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
_log_media_usage(db, user_id, ai_model, media_type, prompt)
return {"__media__": True, "media_type": media_type, "url": url}
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
if s.startswith("⚠️") or s.startswith("(媒体"):
return s
return "(媒体生成未能返回有效内容,请重试)"
# ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
ai_model = resolve_auto_model("text", db)
if not ai_model:
return "(未配置默认文本模型,无法生成回复)"
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url):
return "(模型未配置 API Key 或地址,无法生成回复)"
# 自动发现本账号已安装且平台已部署的技能
skills = []
if user_id:
try:
skills = _discover_installed_skills(db, user_id)[:15]
except Exception:
skills = []
system_prompt, function_definitions = _build_system_prompt(
skills=skills,
agents=[],
kb_context="",
user_content=prompt,
mcp=[],
auto_discovered=bool(skills),
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
max_rounds = 3
ai_content = ""
for _ in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
if function_definitions and skills:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
with httpx.Client(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
resp = client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=request_body,
)
if resp.status_code != 200:
return f"(AI 模型调用失败 HTTP {resp.status_code})"
data = resp.json()
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
if tool_calls and skills:
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
continue
ai_content = message.get("content", "")
break
return ai_content or "(AI 回复为空)"
except Exception as e:
return f"(AI 回复生成失败:{e})"
"""
IM Bot 真实 LLM 调用。
原实现位于 channels.py(通用频道管理模块),该模块已移除。
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
行为(对齐 AI 聊天):
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
不再对"制作图片"这类请求只回文本。
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
"""
import asyncio
import json
import logging
import re as _re
from datetime import datetime, timezone, timedelta
from typing import Optional
from urllib.parse import urlparse as _urlparse
import httpx
from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
from app.core.observability import get_trace_id
logger = logging.getLogger(__name__)
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
国内域名(尊重 NO_PROXY)直连,更稳定。
"""
import os
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
if "qihuyun.com" in host:
return None
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy:
if host == _np or host.endswith("." + _np) or _np in host:
return None
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
def _run_async(coro, timeout=None):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if timeout is not None:
async def _timed():
return await asyncio.wait_for(coro, timeout=timeout)
target = _timed()
else:
target = coro
if loop is not None and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(1) as ex:
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
return asyncio.run(target)
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里“查无此项”。
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
"""
if not user_id or not ai_model:
return
try:
base = 1200 if media_type == "video" else 400
est = base + (len(prompt or "") // 4)
db.add(TokenUsageLog(
id=gen_uuid(),
user_id=user_id,
model_id=ai_model.id,
model_name=ai_model.name,
trace_id=get_trace_id(),
prompt_tokens=est,
completion_tokens=0,
total_tokens=est,
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
))
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。"""
try:
from app.api.chat import (
_generate_media,
detect_media_intent,
_discover_installed_skills,
_build_system_prompt,
_execute_skill_tool,
)
# ===== 1) 媒体意图路由(Issue 7)=====
intent = detect_media_intent(prompt)
if intent:
ai_model = resolve_auto_model(intent, db)
if ai_model:
try:
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
except Exception:
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
if result:
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
s = result.strip()
if s.startswith("<img") or s.startswith("<video"):
m = _re.search(r'src="([^"]+)"', s)
url = m.group(1) if m else ""
media_type = "image" if s.startswith("<img") else "video"
if not url:
return "(生成完成,但未能提取媒体地址)"
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
_log_media_usage(db, user_id, ai_model, media_type, prompt)
return {"__media__": True, "media_type": media_type, "url": url}
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
if s.startswith("⚠️") or s.startswith("(媒体"):
return s
return "(媒体生成未能返回有效内容,请重试)"
# ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
ai_model = resolve_auto_model("text", db)
if not ai_model:
return "(未配置默认文本模型,无法生成回复)"
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url):
return "(模型未配置 API Key 或地址,无法生成回复)"
# 自动发现本账号已安装且平台已部署的技能
skills = []
if user_id:
try:
skills = _discover_installed_skills(db, user_id)[:15]
except Exception:
skills = []
system_prompt, function_definitions = _build_system_prompt(
skills=skills,
agents=[],
kb_context="",
user_content=prompt,
mcp=[],
auto_discovered=bool(skills),
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
max_rounds = 3
ai_content = ""
for _ in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
if function_definitions and skills:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
with httpx.Client(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
resp = client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=request_body,
)
if resp.status_code != 200:
return f"(AI 模型调用失败 HTTP {resp.status_code})"
data = resp.json()
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
if tool_calls and skills:
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
continue
ai_content = message.get("content", "")
break
return ai_content or "(AI 回复为空)"
except Exception as e:
return f"(AI 回复生成失败:{e})"
+2 -1
View File
@@ -1,3 +1,4 @@
from app.core.credential import encrypt_secret
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
@@ -557,7 +558,7 @@ def upload_app(
is_member_free=req.is_member_free if req.is_member_free is not None else False,
join_promotion=req.join_promotion if req.join_promotion is not None else False,
agent_endpoint=req.agent_endpoint,
agent_api_key=req.api_key,
agent_api_key=encrypt_secret(req.api_key),
status="published", # 用户创建的应用直接发布
created_at=datetime.now(),
updated_at=datetime.now(),
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""Phase 3.2 - Agent 凭据加解密 (Fernet 对称加密)。
主密钥获取策略 (防竞态):
- 优先环境变量 AGENT_MASTER_KEY。
- 否则读写 /app/secrets/agent_master_key:
* 部署阶段由 patch 显式生成并落盘 (原子写), 容器重启/提交后稳定存在。
* 运行时只读, 文件存在即读取, 绝不重新生成 —— 避免多进程/多请求并发
重建密钥导致新旧密文无法互解。
* 仅当文件确实不存在时, 才在文件锁保护下生成 (双重检查, 原子替换)。
- 存储格式: 密文以 "enc:" 前缀标识; 非 "enc:" 视为旧明文 (向后兼容, 解密原样返回)。
"""
import os
import logging
import fcntl
from cryptography.fernet import Fernet
logger = logging.getLogger(__name__)
_MASTER_KEY_FILE = "/app/secrets/agent_master_key"
_fernet_instance = None
def _generate_and_persist_key() -> bytes:
key = Fernet.generate_key()
os.makedirs(os.path.dirname(_MASTER_KEY_FILE), exist_ok=True)
tmp = _MASTER_KEY_FILE + ".tmp"
with open(tmp, "w") as f:
f.write(key.decode())
f.flush()
os.fsync(f.fileno())
try:
os.chmod(tmp, 0o600)
except Exception:
pass
os.replace(tmp, _MASTER_KEY_FILE) # 原子替换, 避免读到半截文件
return key
def get_master_key() -> bytes:
env_key = os.environ.get("AGENT_MASTER_KEY")
if env_key:
return env_key.encode() if isinstance(env_key, str) else env_key
# 运行时只读: 文件已存在则直接读取 (部署阶段已生成)
if os.path.exists(_MASTER_KEY_FILE):
try:
with open(_MASTER_KEY_FILE, "r") as f:
k = f.read().strip()
if k:
return k.encode()
except Exception as e:
logger.warning("[credential] 读取主密钥失败: %s" % e)
# 文件缺失: 加锁串行生成, 防多进程并发重建
lockf = open(_MASTER_KEY_FILE + ".lock", "w")
try:
fcntl.flock(lockf, fcntl.LOCK_EX)
if os.path.exists(_MASTER_KEY_FILE): # 双重检查
with open(_MASTER_KEY_FILE, "r") as f:
k = f.read().strip()
if k:
return k.encode()
return _generate_and_persist_key()
finally:
fcntl.flock(lockf, fcntl.LOCK_UN)
try:
lockf.close()
except Exception:
pass
def _fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(get_master_key())
return _fernet_instance
def encrypt_secret(plain):
if plain is None or plain == "":
return None
if isinstance(plain, str) and plain.startswith("enc:"):
return plain # 已是密文, 幂等
try:
return "enc:" + _fernet().encrypt(plain.encode()).decode()
except Exception as e:
logger.error("[credential] 加密失败: %s" % e)
return None
def decrypt_secret(cipher):
if cipher is None or cipher == "":
return None
if isinstance(cipher, str) and cipher.startswith("enc:"):
try:
return _fernet().decrypt(cipher[4:].encode()).decode()
except Exception as e:
logger.error("[credential] 解密失败(密钥不匹配或数据损坏): %s" % e)
return None
return cipher # 旧明文, 向后兼容
def mask_secret(plain):
if not plain:
return ""
return ("*" * 8) + plain[-4:]
+395
View File
@@ -0,0 +1,395 @@
"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
设计目标:
- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Optional
import httpx
logger = logging.getLogger("aiflow.orchestrator")
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
class OrchestrationError(ValueError):
"""图结构非法或执行被 halt。"""
# ---------------------------------------------------------------------------
# 校验 + 拓扑
# ---------------------------------------------------------------------------
def validate_graph(graph: Any) -> None:
if not isinstance(graph, dict):
raise OrchestrationError("orchestration 必须为对象")
nodes = graph.get("nodes")
edges = graph.get("edges")
if not isinstance(nodes, list) or not nodes:
raise OrchestrationError("nodes 必须为非空数组")
if not isinstance(edges, list):
raise OrchestrationError("edges 必须为数组")
ids = set()
for i, n in enumerate(nodes):
if not isinstance(n, dict):
raise OrchestrationError(f"节点 #{i} 必须为对象")
nid = n.get("id")
if not nid or not isinstance(nid, str):
raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
if nid in ids:
raise OrchestrationError(f"节点 id 重复: {nid}")
ids.add(nid)
ntype = n.get("type")
if ntype not in VALID_NODE_TYPES:
raise OrchestrationError(
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
)
if ntype in ("local", "external") and not n.get("agent_id"):
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
for i, e in enumerate(edges):
if not isinstance(e, dict):
raise OrchestrationError(f"边 #{i} 必须为对象")
f = e.get("from")
t = e.get("to")
if f not in ids:
raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
if t not in ids:
raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
if f == t:
raise OrchestrationError(f"边 #{i} 存在自环: {f}")
def topological_layers(graph: dict) -> list:
"""Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
nodes = graph["nodes"]
edges = graph.get("edges", [])
indeg = {n["id"]: 0 for n in nodes}
adj = {n["id"]: [] for n in nodes}
for e in edges:
f, t = e["from"], e["to"]
adj[f].append(t)
indeg[t] += 1
order = {n["id"]: i for i, n in enumerate(nodes)}
layers: list = []
remaining = set(indeg)
while remaining:
layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
if not layer:
cycle = sorted(remaining, key=lambda x: order[x])
raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
layers.append(layer)
for nid in layer:
remaining.discard(nid)
for nxt in adj[nid]:
indeg[nxt] -= 1
return layers
def _node_by_id(graph: dict, nid: str) -> dict:
for n in graph["nodes"]:
if n["id"] == nid:
return n
return {}
def _predecessors(graph: dict, nid: str) -> list:
return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
def _successors(graph: dict, nid: str) -> list:
return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
def _find_output_node(graph: dict) -> str:
if graph.get("output_node"):
return graph["output_node"]
sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
if len(sinks) == 1:
return sinks[0]
for s in sinks:
if _node_by_id(graph, s).get("type") == "reduce":
return s
if sinks:
return sinks[0]
return graph["nodes"][-1]["id"]
def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
"""入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
if is_entry:
return query or ""
if upstream:
return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
return query or ""
# ---------------------------------------------------------------------------
# 模型解析 + LLM 调用原语
# ---------------------------------------------------------------------------
async def _resolve_model_and_endpoint(model_id, db):
from app.models import AIModel, ModelProvider
from ..capability_registry import resolve_auto_model
ai_model = None
if model_id and model_id != "auto":
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
if not ai_model:
ai_model = resolve_auto_model("text", db)
if not ai_model:
raise OrchestrationError("无法解析到可用模型")
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not api_url or not api_key:
raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
return ai_model, api_url, api_key
async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
from app.api.chat_utils import _proxy_for_url
from app.core.observability import span
ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
]
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
async with span("orchestrator.llm", model=ai_model.id):
async with httpx.AsyncClient(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
resp = await client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=request_body,
)
if resp.status_code != 200:
raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
return data["choices"][0]["message"].get("content") or ""
# ---------------------------------------------------------------------------
# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
# ---------------------------------------------------------------------------
async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
from app.api.chat_utils import _proxy_for_url
skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id)
kb_context = _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
system_prompt, function_definitions = _build_system_prompt(
skills=skills,
agents=[agent],
kb_context=kb_context,
user_content=node_input,
auto_discovered=False,
)
ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
tool_policy = _resolve_agent_tool_policy([agent])
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": node_input},
]
called_skills: list = []
tool_call_count = 0
max_tool_rounds = 5
last_content = ""
async with httpx.AsyncClient(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
for round_num in range(max_tool_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
if function_definitions:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
resp = await client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=request_body,
)
if resp.status_code != 200:
raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
message = data["choices"][0]["message"]
tool_calls = message.get("tool_calls") or []
content = message.get("content") or ""
last_content = content
if not tool_calls or not function_definitions:
return {"text": content, "used_tools": called_skills}
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
round_tool_count = 0
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
check = _check_tool_policy(
func_name, None, skills, tool_policy, tool_call_count, round_tool_count
)
if not check["allowed"]:
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", f"call_{round_num}"),
"content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
})
tool_call_count += 1
round_tool_count += 1
continue
tool_call_count += 1
round_tool_count += 1
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
except Exception:
func_args = {}
res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
called_skills.append(func_name)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", f"call_{round_num}"),
"content": res,
})
return {"text": last_content, "used_tools": called_skills}
# ---------------------------------------------------------------------------
# 单节点执行(含独立 DB 会话,保证并发安全)
# ---------------------------------------------------------------------------
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
from app.api.chat_skill import _resolve_market_agents
from app.database import SessionLocal
nid = node["id"]
ntype = node["type"]
preds = _predecessors(graph, nid)
pred_results = [blackboard[p] for p in preds]
is_entry = not preds
if pred_results:
parts = []
for b in pred_results:
label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
if b.get("ok"):
parts.append(f"【{label}】\n{b.get('output', '')}")
else:
parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
upstream = "\n\n".join(parts)
else:
upstream = ""
with SessionLocal() as db:
if ntype in ("llm", "reduce"):
if ntype == "llm":
sys_prompt = node.get("system_prompt") or (
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
"只输出处理后的结果,不要多余解释。"
)
else:
sys_prompt = node.get("system_prompt") or (
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
)
uc = _assemble_node_input(query, upstream, is_entry)
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
if ntype == "local":
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
if not agents:
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
ni = _assemble_node_input(query, upstream, is_entry)
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
return {
"ok": True,
"node_id": nid,
"node_type": "local",
"output": res["text"],
"used_tools": res.get("used_tools"),
}
if ntype == "external":
from app.api.chat_agents import _call_external_agent
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
if not agents:
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
ni = _assemble_node_input(query, upstream, is_entry)
answer, _ = await _call_external_agent(
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
)
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
raise OrchestrationError(f"未知节点类型 {ntype}")
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
"""执行编排图,返回 (answer: str, trace: dict)。
- 拓扑分层后逐层 asyncio.gather 并行执行。
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
"""
validate_graph(graph)
layers = topological_layers(graph)
blackboard: dict = {}
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
for layer in layers:
tasks = [
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
for nid in layer
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for nid, res in zip(layer, results):
node = _node_by_id(graph, nid)
if isinstance(res, Exception):
on_error = node.get("on_error", "continue")
err_msg = str(res)
blackboard[nid] = {
"ok": False,
"node_id": nid,
"node_type": node["type"],
"error": err_msg,
}
trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
if on_error == "halt":
raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
else:
blackboard[nid] = res
trace["nodes"].append({
"id": nid,
"type": res.get("node_type"),
"ok": True,
"used_tools": res.get("used_tools"),
})
output_node = _find_output_node(graph)
final = blackboard.get(output_node, {})
if final.get("ok"):
answer = final.get("output", "")
else:
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
trace["output_node"] = output_node
return answer, trace
+1139 -1127
View File
File diff suppressed because it is too large. Load diff
+250 -249
View File
@@ -1,249 +1,250 @@
"""
Qihu-AIFlow FastAPI 主应用
完整后端API,监听18080端口,所有接口以 /app 为前缀
"""
import sys
import os
import time
import uuid
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.casdoor_auth import router as casdoor_router
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.core.observability import set_trace_id
from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转)
access_logger = setup_file_logging()
logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
set_trace_id(request_id)
start_time = time.time()
path = request.url.path
method = request.method
# 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"]
should_skip = any(path.startswith(p) for p in skip_paths)
try:
response = await call_next(request)
elapsed = time.time() - start_time
status_code = response.status_code
response.headers["X-Trace-Id"] = request_id
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
)
# 异步记录API调用统计
try:
db = SessionLocal()
try:
_record_api_call(db, method, path, status_code, elapsed)
finally:
db.close()
except Exception as e:
logger.warning(f"API统计记录失败: {e}")
return response
except Exception as e:
elapsed = time.time() - start_time
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
)
raise
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
"""记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID)
parts = path.split("/")
simplified_parts = []
for part in parts:
# 将UUID样式的路径段替换为{id}
if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}")
# 将纯数字路径段替换为{id}
elif part.isdigit():
simplified_parts.append("{id}")
else:
simplified_parts.append(part)
simplified_path = "/".join(simplified_parts)
# 统计记录通过数据库实现,简化版记录到日志
is_error = status_code >= 400
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
if is_slow:
logger.warning(
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try:
from app.services.evolution_service import EvolutionWatcherManager
from app.models import UserEvolutionConfig
db = SessionLocal()
try:
configs = db.query(UserEvolutionConfig).filter(
UserEvolutionConfig.watcher_status == "running"
).all()
if configs:
watcher = EvolutionWatcherManager()
for cfg in configs:
try:
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
except Exception as e:
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
else:
logger.info("[Evolution] 无待恢复的进化配置")
finally:
db.close()
except Exception as e:
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
# 启动Redis事件总线
try:
from app.event_bus import get_event_bus
event_bus = get_event_bus()
event_bus.start(num_consumers=2)
logger.info("[EventBus] Redis事件总线已启动")
except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield
# 停止Redis事件总线
try:
from app.event_bus import get_event_bus
get_event_bus().stop()
logger.info("[EventBus] Redis事件总线已停止")
except Exception:
pass
# 优雅关闭:停止所有 Evolution Pipeline
try:
from app.services.evolution_service import EvolutionWatcherManager
EvolutionWatcherManager().shutdown_all()
logger.info("[Evolution] 所有Pipeline已停止")
except Exception:
pass
logger.info("Qihu-AIFlow 正在关闭...")
app = FastAPI(
title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0",
lifespan=lifespan,
)
# API调用追踪中间件
app.add_middleware(APITrackingMiddleware)
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 前台 API
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
# 后台 API
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
@app.get("/app/api/health")
async def health_check():
return {"status": "ok", "service": "Qihu-AIFlow"}
"""
Qihu-AIFlow FastAPI 主应用
完整后端API,监听18080端口,所有接口以 /app 为前缀
"""
import sys
import os
import time
import uuid
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.casdoor_auth import router as casdoor_router
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.core.observability import set_trace_id
from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转)
access_logger = setup_file_logging()
logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
set_trace_id(request_id)
start_time = time.time()
path = request.url.path
method = request.method
# 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"]
should_skip = any(path.startswith(p) for p in skip_paths)
try:
response = await call_next(request)
elapsed = time.time() - start_time
status_code = response.status_code
response.headers["X-Trace-Id"] = request_id
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
)
# 异步记录API调用统计
try:
db = SessionLocal()
try:
_record_api_call(db, method, path, status_code, elapsed)
finally:
db.close()
except Exception as e:
logger.warning(f"API统计记录失败: {e}")
return response
except Exception as e:
elapsed = time.time() - start_time
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
)
raise
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
"""记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID)
parts = path.split("/")
simplified_parts = []
for part in parts:
# 将UUID样式的路径段替换为{id}
if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}")
# 将纯数字路径段替换为{id}
elif part.isdigit():
simplified_parts.append("{id}")
else:
simplified_parts.append(part)
simplified_path = "/".join(simplified_parts)
# 统计记录通过数据库实现,简化版记录到日志
is_error = status_code >= 400
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
if is_slow:
logger.warning(
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try:
from app.services.evolution_service import EvolutionWatcherManager
from app.models import UserEvolutionConfig
db = SessionLocal()
try:
configs = db.query(UserEvolutionConfig).filter(
UserEvolutionConfig.watcher_status == "running"
).all()
if configs:
watcher = EvolutionWatcherManager()
for cfg in configs:
try:
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
except Exception as e:
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
else:
logger.info("[Evolution] 无待恢复的进化配置")
finally:
db.close()
except Exception as e:
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
# 启动Redis事件总线
try:
from app.event_bus import get_event_bus
event_bus = get_event_bus()
event_bus.start(num_consumers=2)
logger.info("[EventBus] Redis事件总线已启动")
except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield
# 停止Redis事件总线
try:
from app.event_bus import get_event_bus
get_event_bus().stop()
logger.info("[EventBus] Redis事件总线已停止")
except Exception:
pass
# 优雅关闭:停止所有 Evolution Pipeline
try:
from app.services.evolution_service import EvolutionWatcherManager
EvolutionWatcherManager().shutdown_all()
logger.info("[Evolution] 所有Pipeline已停止")
except Exception:
pass
logger.info("Qihu-AIFlow 正在关闭...")
app = FastAPI(
title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0",
lifespan=lifespan,
)
# API调用追踪中间件
app.add_middleware(APITrackingMiddleware)
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 前台 API
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
# 后台 API
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
app.include_router(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
@app.get("/app/api/health")
async def health_check():
return {"status": "ok", "service": "Qihu-AIFlow"}
+114
View File
@@ -0,0 +1,114 @@
"""Phase 2 context-engine regression: _build_chat_history budget/layering/summary.
Pure unit test with a fake DB session — no network, no real model. Guards the
layered-memory + compression + isolation behaviour against regressions.
"""
from app.api.chat_context import _estimate_tokens, _build_chat_history
class _Msg:
def __init__(self, role, content, cid="c1"):
self.role = role
self.content = content
self.conversation_id = cid
class _Summary:
def __init__(self, text):
self.summary_text = text
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
# test pre-sorts newest-first to match desc(created_at)
return self
def limit(self, n=None):
return self
def all(self):
return self._rows
def first(self):
return self._rows[0] if self._rows else None
class _DB:
def __init__(self, messages, summary=None):
self._messages = messages
self._summary = summary
def query(self, model):
if getattr(model, "__name__", "") == "ConversationSummary":
return _Query([self._summary] if self._summary else [])
return _Query(self._messages)
def _newest_first(msgs):
# _build_chat_history expects desc(created_at) -> newest first.
# Test authors write the list already newest-first, so return as-is.
return list(msgs)
def test_estimate_tokens():
assert _estimate_tokens("") == 0
assert _estimate_tokens(None) == 0
assert _estimate_tokens("abcd") == 2 # 4//2
assert _estimate_tokens("a" * 5) == 2 # max(1, 5//2)=2
# just ensure positive monotonic
assert _estimate_tokens("x" * 100) == 50
def test_history_excludes_latest_user():
# newest-first: [current user, prev assistant, older user]
msgs = _newest_first([
_Msg("user", "current question"),
_Msg("assistant", "previous answer"),
_Msg("user", "older question"),
])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
roles = [m["role"] for m in out]
# current user dropped; chronological order = [older user, previous assistant]
assert roles == ["user", "assistant"]
assert out[0]["content"] == "older question"
def test_summary_injected_as_system_first():
msgs = _newest_first([
_Msg("user", "hi"),
_Msg("assistant", "hello"),
])
db = _DB(msgs, summary=_Summary("用户想做图片生成"))
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
assert out[0]["role"] == "system"
assert out[0]["content"].startswith("【对话历史摘要")
assert "用户想做图片生成" in out[0]["content"]
# after summary: chronological history (assistant then user 'hi' excluded? exclude_latest_user drops 'hi')
assert out[1]["role"] == "assistant"
def test_budget_truncation():
# 5 assistant messages each ~200 tokens; budget small -> truncated
msgs = _newest_first([_Msg("assistant", "x" * 400) for _ in range(5)])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=500, exclude_latest_user=False)
# each msg ~200 tokens; budget 500 -> keep newest ~2
assert len(out) <= 3
total = sum(_estimate_tokens(m["content"]) for m in out)
assert total <= 500 + 200 # within budget (+1 msg slack)
def test_no_summary_when_absent():
msgs = _newest_first([_Msg("user", "hi"), _Msg("assistant", "hello")])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
# 'hi' excluded; only assistant remains, no system summary
assert all(m["role"] != "system" for m in out)
assert [m["role"] for m in out] == ["assistant"]
+177 -175
View File
@@ -1,175 +1,177 @@
import React, { useState, useEffect, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
// Layouts(保留为即时加载,构成应用外壳)
import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout';
// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖,
// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。
const LandingPage = React.lazy(() => import('@/pages/front/LandingPage'));
const LoginPage = React.lazy(() => import('@/pages/front/LoginPage'));
const ChatPage = React.lazy(() => import('@/pages/front/ChatPage'));
const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage'));
const MarketPage = React.lazy(() => import('@/pages/front/MarketPage'));
const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage'));
const PricingPage = React.lazy(() => import('@/pages/front/PricingPage'));
const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage'));
const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage'));
const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage'));
const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage'));
const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard'));
const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard'));
const AdminUsers = React.lazy(() => import('@/pages/admin/Users'));
const AdminModels = React.lazy(() => import('@/pages/admin/Models'));
const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings'));
const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig'));
const AdminPayment = React.lazy(() => import('@/pages/admin/Payment'));
const AdminServices = React.lazy(() => import('@/pages/admin/Services'));
const AdminMembership = React.lazy(() => import('@/pages/admin/Membership'));
const AdminAds = React.lazy(() => import('@/pages/admin/Ads'));
const AdminMarket = React.lazy(() => import('@/pages/admin/Market'));
const AdminLogs = React.lazy(() => import('@/pages/admin/Logs'));
const AdminSkills = React.lazy(() => import('@/pages/admin/Skills'));
const AdminAgents = React.lazy(() => import('@/pages/admin/Agents'));
const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec'));
const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder'));
const ImBots = React.lazy(() => import('@/pages/admin/ImBots'));
const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings'));
const Promotion = React.lazy(() => import('@/pages/admin/Promotion'));
const Achievement = React.lazy(() => import('@/pages/admin/Achievement'));
const Tenant = React.lazy(() => import('@/pages/admin/Tenant'));
const Billing = React.lazy(() => import('@/pages/admin/Billing'));
const Analytics = React.lazy(() => import('@/pages/admin/Analytics'));
const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer'));
const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools'));
const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation'));
const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp'));
const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
const pageFallback = <div className="loading-screen">加载中...</div>;
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 初始化:从localStorage恢复登录状态
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user');
if (token && cachedUser) {
try {
setUser(JSON.parse(cachedUser));
} catch {
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
}
}
setLoading(false);
}, []);
const login = (u: User) => {
setUser(u);
localStorage.setItem('aiflow_user', JSON.stringify(u));
};
const logout = () => {
setUser(null);
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
};
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
<Suspense fallback={pageFallback}>
<Routes>
{/* Login */}
<Route path="/login" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
} />
{/* Root - Landing for guests, redirect for authenticated users */}
<Route path="/" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
} />
{/* Front Routes (authenticated only) */}
<Route path="/" element={<FrontLayout />}>
<Route path="chat" element={<ChatPage />} />
<Route path="chat/:id" element={<ChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="market" element={<MarketPage />} />
<Route path="token" element={<TokenDashboardPage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="notifications" element={<NotificationPage />} />
<Route path="assets" element={<AssetsPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="app-services" element={<AppServices />} />
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} />
<Route path="settings" element={<AdminSiteSettings />} />
<Route path="sms" element={<AdminSmsConfig />} />
<Route path="payment" element={<AdminPayment />} />
<Route path="services" element={<AdminServices />} />
<Route path="membership" element={<AdminMembership />} />
<Route path="ads" element={<AdminAds />} />
<Route path="market" element={<AdminMarket />} />
<Route path="logs" element={<AdminLogs />} />
<Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} />
<Route path="code-exec" element={<AdminCodeExec />} />
<Route path="distributions" element={<DistributionBuilder />} />
<Route path="im-bots" element={<ImBots />} />
<Route path="privacy" element={<PrivacySettings />} />
<Route path="promotion" element={<Promotion />} />
<Route path="achievement" element={<Achievement />} />
<Route path="tenant" element={<Tenant />} />
<Route path="billing" element={<Billing />} />
<Route path="analytics" element={<Analytics />} />
<Route path="knowledge" element={<AdminKnowledge />} />
<Route path="workflow" element={<AdminWorkflow />} />
<Route path="storage" element={<AdminStorage />} />
<Route path="developer" element={<AdminDeveloper />} />
<Route path="developer-tools" element={<AdminDeveloperTools />} />
<Route path="automation" element={<AdminAutomation />} />
<Route path="mcp" element={<AdminMcp />} />
<Route path="apps" element={<AdminApps />} />
<Route path="assets" element={<AssetCenter />} />
<Route path="portal" element={<UserPortal />} />
</Route>
{/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */}
<Route path="/home" element={<LandingPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</AuthContext.Provider>
);
};
export default App;
import React, { useState, useEffect, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
// Layouts(保留为即时加载,构成应用外壳)
import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout';
// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖,
// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。
const LandingPage = React.lazy(() => import('@/pages/front/LandingPage'));
const LoginPage = React.lazy(() => import('@/pages/front/LoginPage'));
const ChatPage = React.lazy(() => import('@/pages/front/ChatPage'));
const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage'));
const MarketPage = React.lazy(() => import('@/pages/front/MarketPage'));
const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage'));
const PricingPage = React.lazy(() => import('@/pages/front/PricingPage'));
const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage'));
const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage'));
const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage'));
const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage'));
const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard'));
const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard'));
const AdminUsers = React.lazy(() => import('@/pages/admin/Users'));
const AdminModels = React.lazy(() => import('@/pages/admin/Models'));
const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings'));
const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig'));
const AdminPayment = React.lazy(() => import('@/pages/admin/Payment'));
const AdminServices = React.lazy(() => import('@/pages/admin/Services'));
const AdminMembership = React.lazy(() => import('@/pages/admin/Membership'));
const AdminAds = React.lazy(() => import('@/pages/admin/Ads'));
const AdminMarket = React.lazy(() => import('@/pages/admin/Market'));
const AdminLogs = React.lazy(() => import('@/pages/admin/Logs'));
const AdminSkills = React.lazy(() => import('@/pages/admin/Skills'));
const AdminAgents = React.lazy(() => import('@/pages/admin/Agents'));
const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec'));
const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder'));
const ImBots = React.lazy(() => import('@/pages/admin/ImBots'));
const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings'));
const Promotion = React.lazy(() => import('@/pages/admin/Promotion'));
const Achievement = React.lazy(() => import('@/pages/admin/Achievement'));
const Tenant = React.lazy(() => import('@/pages/admin/Tenant'));
const Billing = React.lazy(() => import('@/pages/admin/Billing'));
const Analytics = React.lazy(() => import('@/pages/admin/Analytics'));
const Observability = React.lazy(() => import('@/pages/admin/Observability'));
const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer'));
const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools'));
const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation'));
const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp'));
const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
const pageFallback = <div className="loading-screen">加载中...</div>;
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 初始化:从localStorage恢复登录状态
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user');
if (token && cachedUser) {
try {
setUser(JSON.parse(cachedUser));
} catch {
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
}
}
setLoading(false);
}, []);
const login = (u: User) => {
setUser(u);
localStorage.setItem('aiflow_user', JSON.stringify(u));
};
const logout = () => {
setUser(null);
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
};
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
<Suspense fallback={pageFallback}>
<Routes>
{/* Login */}
<Route path="/login" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
} />
{/* Root - Landing for guests, redirect for authenticated users */}
<Route path="/" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
} />
{/* Front Routes (authenticated only) */}
<Route path="/" element={<FrontLayout />}>
<Route path="chat" element={<ChatPage />} />
<Route path="chat/:id" element={<ChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="market" element={<MarketPage />} />
<Route path="token" element={<TokenDashboardPage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="notifications" element={<NotificationPage />} />
<Route path="assets" element={<AssetsPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="app-services" element={<AppServices />} />
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} />
<Route path="settings" element={<AdminSiteSettings />} />
<Route path="sms" element={<AdminSmsConfig />} />
<Route path="payment" element={<AdminPayment />} />
<Route path="services" element={<AdminServices />} />
<Route path="membership" element={<AdminMembership />} />
<Route path="ads" element={<AdminAds />} />
<Route path="market" element={<AdminMarket />} />
<Route path="logs" element={<AdminLogs />} />
<Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} />
<Route path="code-exec" element={<AdminCodeExec />} />
<Route path="distributions" element={<DistributionBuilder />} />
<Route path="im-bots" element={<ImBots />} />
<Route path="privacy" element={<PrivacySettings />} />
<Route path="promotion" element={<Promotion />} />
<Route path="achievement" element={<Achievement />} />
<Route path="tenant" element={<Tenant />} />
<Route path="billing" element={<Billing />} />
<Route path="analytics" element={<Analytics />} />
<Route path="observability" element={<Observability />} />
<Route path="knowledge" element={<AdminKnowledge />} />
<Route path="workflow" element={<AdminWorkflow />} />
<Route path="storage" element={<AdminStorage />} />
<Route path="developer" element={<AdminDeveloper />} />
<Route path="developer-tools" element={<AdminDeveloperTools />} />
<Route path="automation" element={<AdminAutomation />} />
<Route path="mcp" element={<AdminMcp />} />
<Route path="apps" element={<AdminApps />} />
<Route path="assets" element={<AssetCenter />} />
<Route path="portal" element={<UserPortal />} />
</Route>
{/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */}
<Route path="/home" element={<LandingPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</AuthContext.Provider>
);
};
export default App;
+1991 -1986
View File
File diff suppressed because it is too large. Load diff
+437 -435
View File
@@ -1,435 +1,437 @@
import React, { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
interface NavItem {
path: string;
icon: string;
label: string;
exact?: boolean;
}
interface NavGroup {
title: string;
icon: string;
items: NavItem[];
}
const adminNavGroups: NavGroup[] = [
{
title: '数据驾驶舱',
icon: 'dashboard',
items: [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
],
},
{
title: 'AI技能中心',
icon: 'agents',
items: [
{ path: '/admin/models', icon: 'models', label: '大模型库' },
{ path: '/admin/agents', icon: 'agents', label: 'Agent 管理' },
{ path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' },
{ path: '/admin/market', icon: 'market', label: 'AI 技能审核' },
],
},
{
title: '平台运营',
icon: 'users',
items: [
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员管理' },
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
],
},
{
title: '商业变现',
icon: 'billing',
items: [
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道' },
{ path: '/admin/services', icon: 'services', label: '增值服务' },
{ path: '/admin/ads', icon: 'ads', label: '广告管理' },
{ path: '/admin/assets', icon: 'assets', label: '资产管理' },
],
},
{
title: '系统管理',
icon: 'settings',
items: [
{ path: '/admin/settings', icon: 'settings', label: '网站设置' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/sms', icon: 'sms', label: '短信配置' },
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
],
},
{
title: '待处理',
icon: 'developer',
items: [
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
],
},
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
agents: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>,
skills: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
code: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
distro: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
privacy: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>,
promotion: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="7"/><path d="M8.5 12.5L6 21l6-2 6 2-2.5-8.5"/><circle cx="12" cy="8" r="3"/></svg>,
achievement: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/></svg>,
tenant: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21V8l9-5 9 5v13"/><path d="M9 21v-6h6v6"/><path d="M3 13h18"/></svg>,
billing: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>,
analytics: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/><line x1="3" y1="20" x2="21" y2="20"/></svg>,
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
storage: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
chevron: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>,
sms: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// 后台必须登录且为管理员
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
if (!token || !user || user.role !== 'admin') {
logout();
navigate('/login', { replace: true });
}
}, [user, logout, navigate]);
const navCrumb = (() => {
for (const g of adminNavGroups) {
for (const it of g.items) {
const match = it.exact
? location.pathname === it.path
: location.pathname === it.path || location.pathname.startsWith(it.path + '/');
if (match) return { group: g.title, label: it.label };
}
}
return { group: '', label: '' };
})();
const navGroup = navCrumb.group;
const navLabel = navCrumb.label;
// 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
return new Set(adminNavGroups.map((g) => g.title));
});
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(title)) next.delete(title);
else next.add(title);
return next;
});
};
const handleLogout = () => {
logout();
navigate('/');
};
const handleVisitFrontend = () => {
const url = window.location.origin + '/chat';
window.open(url, '_blank');
};
return (
<div className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-sidebar-header">
<h1 className="admin-logo">七虎AI</h1>
<span className="admin-subtitle">管理控制台</span>
</div>
<button onClick={handleVisitFrontend} className="admin-frontend-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span>访问前台</span>
</button>
<nav className="sidebar-nav">
{adminNavGroups.map((group) => {
const isExpanded = expandedGroups.has(group.title);
const hasActiveChild = group.items.some(item =>
item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path)
);
return (
<div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
<div
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
onClick={() => toggleGroup(group.title)}
>
<span className="nav-group-chevron-wrap">
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
{adminIconPaths.chevron}
</span>
</span>
<span className="nav-group-title">{group.title}</span>
</div>
{isExpanded && (
<div className="nav-group-items">
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) =>
`nav-item ${isActive ? 'nav-item-active' : ''}`
}
>
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
<span className="nav-item-label">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
);
})}
</nav>
<div className="admin-sidebar-footer">
<div className="admin-user-info">
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<div className="admin-user-detail">
<div className="admin-user-name">{user?.username || 'admin'}</div>
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
</div>
</div>
</div>
</aside>
<main className="admin-content">
<header className="admin-topbar">
<nav className="admin-breadcrumb" aria-label="breadcrumb">
<span className="crumb-root">管理控制台</span>
{navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)}
{navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)}
</nav>
<div className="admin-topbar-user">
<div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<span className="admin-topbar-username">{user?.username || 'admin'}</span>
</div>
</header>
<Outlet />
</main>
</div>
);
};
export default AdminLayout;
import React, { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
interface NavItem {
path: string;
icon: string;
label: string;
exact?: boolean;
}
interface NavGroup {
title: string;
icon: string;
items: NavItem[];
}
const adminNavGroups: NavGroup[] = [
{
title: '数据驾驶舱',
icon: 'dashboard',
items: [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
],
},
{
title: 'AI技能中心',
icon: 'agents',
items: [
{ path: '/admin/models', icon: 'models', label: '大模型库' },
{ path: '/admin/agents', icon: 'agents', label: 'Agent 管理' },
{ path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' },
{ path: '/admin/market', icon: 'market', label: 'AI 技能审核' },
],
},
{
title: '平台运营',
icon: 'users',
items: [
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员管理' },
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
],
},
{
title: '商业变现',
icon: 'billing',
items: [
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道' },
{ path: '/admin/services', icon: 'services', label: '增值服务' },
{ path: '/admin/ads', icon: 'ads', label: '广告管理' },
{ path: '/admin/assets', icon: 'assets', label: '资产管理' },
],
},
{
title: '系统管理',
icon: 'settings',
items: [
{ path: '/admin/settings', icon: 'settings', label: '网站设置' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/sms', icon: 'sms', label: '短信配置' },
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
],
},
{
title: '待处理',
icon: 'developer',
items: [
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
{ path: '/admin/observability', icon: 'observability', label: '调用可观测' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
],
},
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
agents: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>,
skills: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
code: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
distro: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
privacy: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>,
promotion: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="7"/><path d="M8.5 12.5L6 21l6-2 6 2-2.5-8.5"/><circle cx="12" cy="8" r="3"/></svg>,
achievement: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/></svg>,
tenant: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21V8l9-5 9 5v13"/><path d="M9 21v-6h6v6"/><path d="M3 13h18"/></svg>,
billing: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>,
analytics: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/><line x1="3" y1="20" x2="21" y2="20"/></svg>,
observability: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>,
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
storage: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
chevron: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>,
sms: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// 后台必须登录且为管理员
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
if (!token || !user || user.role !== 'admin') {
logout();
navigate('/login', { replace: true });
}
}, [user, logout, navigate]);
const navCrumb = (() => {
for (const g of adminNavGroups) {
for (const it of g.items) {
const match = it.exact
? location.pathname === it.path
: location.pathname === it.path || location.pathname.startsWith(it.path + '/');
if (match) return { group: g.title, label: it.label };
}
}
return { group: '', label: '' };
})();
const navGroup = navCrumb.group;
const navLabel = navCrumb.label;
// 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
return new Set(adminNavGroups.map((g) => g.title));
});
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(title)) next.delete(title);
else next.add(title);
return next;
});
};
const handleLogout = () => {
logout();
navigate('/');
};
const handleVisitFrontend = () => {
const url = window.location.origin + '/chat';
window.open(url, '_blank');
};
return (
<div className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-sidebar-header">
<h1 className="admin-logo">七虎AI</h1>
<span className="admin-subtitle">管理控制台</span>
</div>
<button onClick={handleVisitFrontend} className="admin-frontend-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span>访问前台</span>
</button>
<nav className="sidebar-nav">
{adminNavGroups.map((group) => {
const isExpanded = expandedGroups.has(group.title);
const hasActiveChild = group.items.some(item =>
item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path)
);
return (
<div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
<div
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
onClick={() => toggleGroup(group.title)}
>
<span className="nav-group-chevron-wrap">
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
{adminIconPaths.chevron}
</span>
</span>
<span className="nav-group-title">{group.title}</span>
</div>
{isExpanded && (
<div className="nav-group-items">
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) =>
`nav-item ${isActive ? 'nav-item-active' : ''}`
}
>
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
<span className="nav-item-label">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
);
})}
</nav>
<div className="admin-sidebar-footer">
<div className="admin-user-info">
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<div className="admin-user-detail">
<div className="admin-user-name">{user?.username || 'admin'}</div>
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
</div>
</div>
</div>
</aside>
<main className="admin-content">
<header className="admin-topbar">
<nav className="admin-breadcrumb" aria-label="breadcrumb">
<span className="crumb-root">管理控制台</span>
{navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)}
{navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)}
</nav>
<div className="admin-topbar-user">
<div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<span className="admin-topbar-username">{user?.username || 'admin'}</span>
</div>
</header>
<Outlet />
</main>
</div>
);
};
export default AdminLayout;
+107 -3
View File
@@ -105,6 +105,10 @@ const Agents: React.FC = () => {
// Available skills for linking
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
// Available MCP servers for tool_policy sandbox allowlist
const [availableMcps, setAvailableMcps] = useState<any[]>([]);
// Tool policy (sandbox) — null means disabled / no policy
const [toolPolicy, setToolPolicy] = useState<any>(null);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
@@ -144,14 +148,17 @@ const Agents: React.FC = () => {
// Load available skills & models
const loadAvailable = async () => {
try {
const [skillsResult, modelsResult] = await Promise.all([
const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
adminAPI.getAdminAvailableModels() as any,
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
]);
const skills = (skillsResult.data || skillsResult).items || [];
const models = (modelsResult.data || modelsResult) || [];
const mcps = (mcpsResult.data || mcpsResult).items || [];
setAvailableSkills(skills);
setAvailableModels(models);
setAvailableMcps(mcps);
} catch {}
};
@@ -187,6 +194,7 @@ const Agents: React.FC = () => {
memory_enabled: false,
reasoning_enabled: false,
});
setToolPolicy(null);
setShowModal(true);
};
@@ -194,6 +202,7 @@ const Agents: React.FC = () => {
const openEdit = async (agent: Agent) => {
setEditingAgent(agent);
await loadAvailable();
await openEditLoadPolicy(agent.id);
const cfg = agent.config || {};
setForm({
@@ -213,7 +222,15 @@ const Agents: React.FC = () => {
setShowModal(true);
};
// Open detail
// Fetch existing tool_policy (sandbox) for this agent
const openEditLoadPolicy = async (agentId: string) => {
try {
const tpRes = await adminAPI.getAgentToolPolicy(agentId) as any;
setToolPolicy(tpRes?.data?.tool_policy || null);
} catch {
setToolPolicy(null);
}
};
const openDetail = async (agent: Agent) => {
setDetailAgent(agent);
setShowDetail(true);
@@ -246,7 +263,13 @@ const Agents: React.FC = () => {
try {
if (editingAgent) {
await adminAPI.updateAdminAgent(editingAgent.id, payload);
showToast('Agent 已更新');
// Phase 3.1: persist tool_policy sandbox (null clears it)
try {
await adminAPI.setAgentToolPolicy(editingAgent.id, toolPolicy);
showToast('Agent 已更新');
} catch (pe: any) {
showToast(`Agent 已更新,但策略保存失败: ${pe.message || pe}`, 'error');
}
} else {
await adminAPI.createAdminAgent(payload);
showToast('Agent 创建成功');
@@ -612,6 +635,87 @@ const Agents: React.FC = () => {
</div>
)}
</div>
{editingAgent && (
<div style={{ gridColumn: '1 / -1' }}>
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
<div style={{ fontWeight: 600, marginBottom: 12 }}>🛡️ 工具权限沙箱 (tool_policy)</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 12 }}>
<input type="checkbox" checked={toolPolicy !== null} onChange={e => {
if (e.target.checked) {
setToolPolicy({ allowed_tools: { skill_ids: [], mcp_ids: [] }, max_calls_per_conversation: null, per_round_cap: null, fallback_message: '' });
} else {
setToolPolicy(null);
}
}} />
启用工具/MCP 权限沙箱(限制该 Agent 可调用的工具与调用频次)
</label>
{toolPolicy && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">允许调用的技能(不选 = 禁止调用任何技能)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
{availableSkills.length === 0 ? (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
) : availableSkills.map((skill: any) => {
const checked = (toolPolicy.allowed_tools?.skill_ids || []).includes(skill.id);
return (
<label key={skill.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
const cur = toolPolicy.allowed_tools?.skill_ids || [];
const next = checked ? cur.filter((x: string) => x !== skill.id) : [...cur, skill.id];
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), skill_ids: next } });
}} />
{skill.skill_name}
</label>
);
})}
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">允许调用的 MCP 服务(不选 = 禁止调用任何 MCP)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
{availableMcps.length === 0 ? (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无 MCP 服务</span>
) : availableMcps.map((m: any) => {
const checked = (toolPolicy.allowed_tools?.mcp_ids || []).includes(m.id);
return (
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
const cur = toolPolicy.allowed_tools?.mcp_ids || [];
const next = checked ? cur.filter((x: string) => x !== m.id) : [...cur, m.id];
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), mcp_ids: next } });
}} />
{m.name}
</label>
);
})}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">单对话最大调用次数(留空=不限制)</label>
<input className="admin-form-input" type="number" min="0"
value={toolPolicy.max_calls_per_conversation ?? ''}
onChange={e => setToolPolicy({ ...toolPolicy, max_calls_per_conversation: e.target.value === '' ? null : parseInt(e.target.value) })} />
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">单轮最大调用次数(留空=不限制)</label>
<input className="admin-form-input" type="number" min="0"
value={toolPolicy.per_round_cap ?? ''}
onChange={e => setToolPolicy({ ...toolPolicy, per_round_cap: e.target.value === '' ? null : parseInt(e.target.value) })} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">额度耗尽提示语(可选)</label>
<textarea className="admin-form-input" rows={2}
value={toolPolicy.fallback_message || ''}
onChange={e => setToolPolicy({ ...toolPolicy, fallback_message: e.target.value })}
placeholder="例如:该智能体已达到工具调用上限,请开启新对话或调整智能体设置。" />
</div>
</div>
)}
</div>
)}
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { adminAPI } from '@/api';
interface Summary {
totals: { calls: number; tokens: number; cost: number; users: number };
by_model: { model_name: string; calls: number; tokens: number; cost: number }[];
by_user: { user_id: string; username: string; calls: number; tokens: number; cost: number }[];
daily: { date: string; tokens: number; cost: number; calls: number }[];
recent: { trace_id: string | null; model_name: string; tokens: number; cost: number; created_at: string | null }[];
}
const ACCENT = '#5B5FED';
const ACCENT_HOVER = '#4A4ECC';
export default function Observability() {
const [days, setDays] = useState(30);
const [data, setData] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
useEffect(() => {
let alive = true;
setLoading(true);
setErr('');
adminAPI.getObservabilitySummary(days)
.then((d: Summary) => { if (alive) setData(d); })
.catch((e: any) => { if (alive) setErr(e?.message || '加载失败'); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [days]);
const maxTokens = data ? Math.max(1, ...data.daily.map(d => d.tokens)) : 1;
const maxModel = data ? Math.max(1, ...data.by_model.map(m => m.tokens)) : 1;
const fmt = (n: number) => n.toLocaleString('zh-CN');
const fmtCost = (n: number) => `¥${n.toFixed(4)}`;
const card: React.CSSProperties = {
background: '#fff', border: '1px solid #eceef5', borderRadius: 16,
padding: 20, boxShadow: '0 1px 3px rgba(20,30,60,0.04)', marginBottom: 18,
};
const stat: React.CSSProperties = {
flex: 1, minWidth: 150, background: '#fff', border: '1px solid #eceef5',
borderRadius: 16, padding: 18, boxShadow: '0 1px 3px rgba(20,30,60,0.04)',
};
return (
<div style={{ padding: 24, maxWidth: 1160, margin: '0 auto', color: '#1f2330' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
<div>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>调用可观测</h2>
<div style={{ color: '#8a90a2', fontSize: 13, marginTop: 4 }}>
媒体 / LLM 调用归因 · 基于 trace_id 关联请求链路(Phase 2.2)
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{[7, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
style={{
padding: '7px 14px', borderRadius: 10, cursor: 'pointer', fontSize: 13,
border: `1px solid ${days === d ? ACCENT : '#e2e5ee'}`,
background: days === d ? ACCENT : '#fff',
color: days === d ? '#fff' : '#5b6172', fontWeight: 600,
transition: 'all .15s',
}}>
{d} 天
</button>
))}
</div>
</div>
{err && <div style={{ ...card, borderColor: '#f3c2c2', color: '#c0392b' }}>{err}</div>}
{loading && !data && <div style={{ color: '#8a90a2' }}>加载中…</div>}
{data && (
<>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>调用次数</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.calls)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>Token 消耗</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.tokens)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>成本</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6, color: ACCENT }}>{fmtCost(data.totals.cost)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>活跃用户</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.users)}</div></div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>按模型</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.by_model.length === 0 && <div style={{ color: '#8a90a2', fontSize: 13 }}>暂无数据</div>}
{data.by_model.map(m => (
<div key={m.model_name} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 150, fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_name}</div>
<div style={{ flex: 1, height: 10, background: '#f1f3f9', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ width: `${(m.tokens / maxModel) * 100}%`, height: '100%', background: ACCENT, borderRadius: 6 }} />
</div>
<div style={{ width: 90, textAlign: 'right', fontSize: 13, color: '#5b6172' }}>{fmt(m.tokens)}</div>
<div style={{ width: 90, textAlign: 'right', fontSize: 13, color: ACCENT }}>{fmtCost(m.cost)}</div>
</div>
))}
</div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>每日趋势(Token)</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 140, overflowX: 'auto', paddingBottom: 4 }}>
{data.daily.length === 0 && <div style={{ color: '#8a90a2', fontSize: 13 }}>暂无数据</div>}
{data.daily.map(d => (
<div key={d.date} title={`${d.date} · ${fmt(d.tokens)} tokens · ${fmtCost(d.cost)}`}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 22 }}>
<div style={{ width: 16, height: `${(d.tokens / maxTokens) * 110}px`, background: ACCENT, borderRadius: '4px 4px 0 0', opacity: 0.85 }} />
<div style={{ fontSize: 9, color: '#aab', transform: 'rotate(-45deg)', whiteSpace: 'nowrap' }}>{d.date.slice(5)}</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<div style={{ ...card, flex: 1, minWidth: 320 }}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>按用户(Top 20)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ color: '#8a90a2', textAlign: 'left' }}>
<th style={th}>用户</th><th style={thR}>调用</th><th style={thR}>Token</th><th style={thR}>成本</th>
</tr></thead>
<tbody>
{data.by_user.map(u => (
<tr key={u.user_id} style={{ borderTop: '1px solid #f1f3f9' }}>
<td style={td}>{u.username}</td>
<td style={tdR}>{fmt(u.calls)}</td>
<td style={tdR}>{fmt(u.tokens)}</td>
<td style={{ ...tdR, color: ACCENT }}>{fmtCost(u.cost)}</td>
</tr>
))}
{data.by_user.length === 0 && <tr><td colSpan={4} style={{ ...td, color: '#8a90a2' }}>暂无数据</td></tr>}
</tbody>
</table>
</div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>最近调用(trace_id 可下钻)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ color: '#8a90a2', textAlign: 'left' }}>
<th style={th}>Trace ID</th><th style={th}>模型</th><th style={thR}>Token</th><th style={thR}>成本</th><th style={thR}>时间</th>
</tr></thead>
<tbody>
{data.recent.map((r, i) => (
<tr key={(r.trace_id || '') + i} style={{ borderTop: '1px solid #f1f3f9' }}>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.trace_id || '—'}</td>
<td style={td}>{r.model_name}</td>
<td style={tdR}>{fmt(r.tokens)}</td>
<td style={{ ...tdR, color: ACCENT }}>{fmtCost(r.cost)}</td>
<td style={tdR}>{r.created_at || '—'}</td>
</tr>
))}
{data.recent.length === 0 && <tr><td colSpan={5} style={{ ...td, color: '#8a90a2' }}>暂无数据</td></tr>}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
const th: React.CSSProperties = { padding: '8px 6px', fontWeight: 600, fontSize: 12 };
const thR: React.CSSProperties = { ...th, textAlign: 'right' };
const td: React.CSSProperties = { padding: '8px 6px' };
const tdR: React.CSSProperties = { ...td, textAlign: 'right', color: '#5b6172' };
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS conversation_summaries (
conversation_id VARCHAR(64) NOT NULL,
summary_text TEXT NULL,
token_count INT NULL DEFAULT 0,
updated_at DATETIME(3) NULL,
PRIMARY KEY (conversation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+15
View File
@@ -0,0 +1,15 @@
-- Phase 3.1: MarketApp 增加 tool_policy(Agent 工具/MCP 权限沙箱策略)
-- 幂等:仅当列不存在时才 ALTER
SET @db = 'aiflow';
SET @tbl = 'market_apps';
SET @col = 'tool_policy';
SET @exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @db AND table_name = @tbl AND column_name = @col
);
SET @sql = IF(@exists = 0,
'ALTER TABLE market_apps ADD COLUMN tool_policy JSON NULL',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+11
View File
@@ -0,0 +1,11 @@
-- Phase 3.2: 放宽 market_apps.agent_api_key 容量以容纳 Fernet 密文
SET @db = DATABASE();
SET @tbl = 'market_apps';
SET @col = 'agent_api_key';
SELECT COLUMN_TYPE INTO @ctype FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME=@tbl AND COLUMN_NAME=@col;
SET @sql = IF(@ctype IS NULL OR @ctype NOT LIKE '%text%',
CONCAT('ALTER TABLE ', @tbl, ' MODIFY COLUMN ', @col, ' TEXT NULL COMMENT \'Agent 接入 API Key(3.2 起 Fernet 加密存储)\''),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+15
View File
@@ -0,0 +1,15 @@
-- Phase 3.3: MarketApp 增加 orchestration(Agent 多 Agent 编排图,JSON)
-- 幂等:仅当列不存在时才 ALTER
SET @db = 'aiflow';
SET @tbl = 'market_apps';
SET @col = 'orchestration';
SET @exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @db AND table_name = @tbl AND column_name = @col
);
SET @sql = IF(@exists = 0,
"ALTER TABLE market_apps ADD COLUMN orchestration JSON NULL COMMENT 'Agent 多Agent编排图(3.3)'",
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;