Compare commits

...
22 changed files with 971 additions and 97 deletions

No files matched your search

+1 -43
View File
@@ -1,43 +1 @@
# 七虎AI PaaS 平台 — 版本清单 (VERSION) v2026.07.19.6
# 版本规范见 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。
@@ -555,3 +555,56 @@ async def get_available_models(
for m, p in 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}
@@ -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
],
}
+1 -1
View File
@@ -44,7 +44,7 @@ from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model from ..capability_registry import resolve_auto_model
from app.api.chat_agents import (_call_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url) from app.api.chat_agents import (_call_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url)
from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context) from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history)
from app.api.chat_knowledge import (_build_knowledge_graph) from app.api.chat_knowledge import (_build_knowledge_graph)
from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp) from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp)
from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent) from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent)
+9 -1
View File
@@ -1,6 +1,8 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). # Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. # Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from app.core.credential import decrypt_secret
from app.core.observability import get_trace_id
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func from sqlalchemy import and_, desc, asc, func
@@ -900,7 +902,13 @@ async def _call_external_agent(db, agent: dict, query: str, user_id: str,
api_key = agent.get("agent_api_key") or "" _row = db.query(MarketApp).filter(MarketApp.id == agent.get("id")).first()
_raw = _row.agent_api_key if _row else None
try:
api_key = decrypt_secret(_raw) or ""
except Exception as _e:
api_key = ""
logging.error("[credential] 解密 Agent 凭据失败 id=%s trace=%s: %s" % (agent.get("id"), get_trace_id(), _e))
+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 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
+89 -44
View File
@@ -16,7 +16,7 @@ import httpx
import os import os
import logging import logging
from ..database import get_db, SessionLocal from ..database import get_db, SessionLocal
from app.core.observability import span from app.core.observability import span, get_trace_id
from ..models import ( from ..models import (
@@ -48,7 +48,7 @@ from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model from ..capability_registry import resolve_auto_model
from app.api.chat_agents import (_call_external_agent) from app.api.chat_agents import (_call_external_agent)
from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context) from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history)
from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers)
from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent)
from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills) from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills)
@@ -538,23 +538,7 @@ async def _send_message_core(
_hist_rows = db.query(Message).filter( _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True)
Message.conversation_id == conv_id
).order_by(asc(Message.created_at)).limit(10).all()
_history = [{"role": h.role, "content": h.content}
for h in _hist_rows if h.role in ("user", "assistant")]
@@ -1066,31 +1050,8 @@ async def _send_message_core(
# 加载对话历史(最多10条) # 分层上下文引擎:预算感知历史 + 压缩摘要(若有)
messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True))
history = db.query(Message).filter(
Message.conversation_id == conv_id
).order_by(asc(Message.created_at)).limit(10).all()
for h in history:
if h.role == "user" or h.role == "assistant":
messages.append({"role": h.role, "content": h.content})
@@ -1122,7 +1083,11 @@ async def _send_message_core(
# ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 =====
_agent_tool_policy = _resolve_agent_tool_policy(agents)
_tool_call_count = 0
for round_num in range(max_tool_rounds): for round_num in range(max_tool_rounds):
_round_tool_count = 0
@@ -1336,6 +1301,26 @@ async def _send_message_core(
func_name = tc.get("function", {}).get("name", "") func_name = tc.get("function", {}).get("name", "")
# ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) =====
_mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None
_policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count)
if not _policy_check["allowed"]:
_reason = _policy_check["reason"]
logger.warning(
f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} "
f"tool={func_name} reason={_reason} trace={get_trace_id()}"
)
tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}"
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", f"call_{round_num}"),
"content": tool_result,
})
_tool_call_count += 1
_round_tool_count += 1
continue
_tool_call_count += 1
_round_tool_count += 1
@@ -1782,6 +1767,8 @@ async def _send_message_core(
conversation_id=conv_id, conversation_id=conv_id,
trace_id=get_trace_id(),
@@ -1933,6 +1920,9 @@ async def _send_message_core(
# 后台异步压缩历史(分层记忆),不阻塞本次响应
asyncio.create_task(_maybe_compress_history(conv_id))
db.refresh(user_msg) db.refresh(user_msg)
@@ -2043,3 +2033,58 @@ async def _send_message_core(
} }
# ======================================================================
# 3.1 tool_policy 沙箱辅助函数
# ======================================================================
def _resolve_agent_tool_policy(agents):
"""从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。"""
if not agents:
return None
for ag in agents:
tp = ag.get("tool_policy")
if tp and isinstance(tp, dict):
tp = dict(tp)
tp["agent_id"] = ag.get("id")
return tp
return None
def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count):
"""校验单个工具调用是否被策略允许。
返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str}
policy 为 None/空 -> 允许(向后兼容)。
"""
if not policy:
return {"allowed": True, "tool_id": None}
allowed = policy.get("allowed_tools") or {}
skill_ids = allowed.get("skill_ids") or []
mcp_ids = allowed.get("mcp_ids") or []
is_mcp = func_name.startswith("mcp_tool_")
tool_id = None
if is_mcp:
if mcp_info and isinstance(mcp_info, dict):
tool_id = mcp_info.get("server_id")
if mcp_ids and tool_id not in mcp_ids:
return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id,
"message": "MCP 服务不在该智能体的允许列表中"}
else:
if skills:
_s = next((s for s in skills if s.get("func_name") == func_name), None)
if _s:
tool_id = _s.get("id")
if skill_ids and tool_id not in skill_ids:
return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id,
"message": "技能不在该智能体的允许列表中"}
budget = policy.get("max_calls_per_conversation")
if budget is not None and call_count >= int(budget):
return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id,
"message": policy.get("fallback_message")
or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"}
per_round = policy.get("per_round_cap")
if per_round is not None and round_count >= int(per_round):
return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id,
"message": "本轮工具调用已达上限"}
return {"allowed": True, "tool_id": tool_id}
+3 -1
View File
@@ -1,6 +1,7 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). # Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. # 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 fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func 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)运行期远程调用所需字段: # 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段:
@@ -529,7 +531,7 @@ 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,
+2
View File
@@ -25,6 +25,7 @@ from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
from app.core.observability import get_trace_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -95,6 +96,7 @@ def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, promp
user_id=user_id, user_id=user_id,
model_id=ai_model.id, model_id=ai_model.id,
model_name=ai_model.name, model_name=ai_model.name,
trace_id=get_trace_id(),
prompt_tokens=est, prompt_tokens=est,
completion_tokens=0, completion_tokens=0,
total_tokens=est, total_tokens=est,
+2 -1
View File
@@ -1,3 +1,4 @@
from app.core.credential import encrypt_secret
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func 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, 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, join_promotion=req.join_promotion if req.join_promotion is not None else False,
agent_endpoint=req.agent_endpoint, agent_endpoint=req.agent_endpoint,
agent_api_key=req.api_key, agent_api_key=encrypt_secret(req.api_key),
status="published", # 用户创建的应用直接发布 status="published", # 用户创建的应用直接发布
created_at=datetime.now(), created_at=datetime.now(),
updated_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:]
+12 -1
View File
@@ -5,6 +5,7 @@ AIFlow 数据库模型定义 - SQLAlchemy ORM
import uuid import uuid
from datetime import datetime, date from datetime import datetime, date
from sqlalchemy import ( from sqlalchemy import (
Text,
Column, String, Integer, BigInteger, Float, Boolean, Column, String, Integer, BigInteger, Float, Boolean,
DateTime, Date, Text, Enum, ForeignKey, Index, JSON, DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
DECIMAL, UniqueConstraint DECIMAL, UniqueConstraint
@@ -299,7 +300,8 @@ class MarketApp(Base):
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze") platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video") capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
agent_endpoint = Column(String(500), default=None) agent_endpoint = Column(String(500), default=None)
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key") agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)")
tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)")
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -1125,3 +1127,12 @@ class SmsVerificationCode(Base):
used = Column(Boolean, default=False) used = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
class ConversationSummary(Base):
__tablename__ = "conversation_summaries"
conversation_id = Column(String(64), primary_key=True)
summary_text = Column(Text, nullable=False, default="")
token_count = Column(Integer, default=0)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
+2 -1
View File
@@ -17,7 +17,7 @@ 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.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.casdoor_auth import router as casdoor_router 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.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_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_router from app.api.im_bot_platform import router as im_bot_platform_router
from app.api.portal import user_portal from app.api.portal import user_portal
@@ -239,6 +239,7 @@ app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", ta
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) 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(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(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(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(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"]) app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
+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"]
+2
View File
@@ -45,6 +45,7 @@ const Achievement = React.lazy(() => import('@/pages/admin/Achievement'));
const Tenant = React.lazy(() => import('@/pages/admin/Tenant')); const Tenant = React.lazy(() => import('@/pages/admin/Tenant'));
const Billing = React.lazy(() => import('@/pages/admin/Billing')); const Billing = React.lazy(() => import('@/pages/admin/Billing'));
const Analytics = React.lazy(() => import('@/pages/admin/Analytics')); 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 AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow')); const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage')); const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
@@ -151,6 +152,7 @@ const App: React.FC = () => {
<Route path="tenant" element={<Tenant />} /> <Route path="tenant" element={<Tenant />} />
<Route path="billing" element={<Billing />} /> <Route path="billing" element={<Billing />} />
<Route path="analytics" element={<Analytics />} /> <Route path="analytics" element={<Analytics />} />
<Route path="observability" element={<Observability />} />
<Route path="knowledge" element={<AdminKnowledge />} /> <Route path="knowledge" element={<AdminKnowledge />} />
<Route path="workflow" element={<AdminWorkflow />} /> <Route path="workflow" element={<AdminWorkflow />} />
<Route path="storage" element={<AdminStorage />} /> <Route path="storage" element={<AdminStorage />} />
+5
View File
@@ -527,6 +527,7 @@ export const adminAPI = {
getRecentAudit: () => get<any[]>('/admin/dashboard/recent-audit'), getRecentAudit: () => get<any[]>('/admin/dashboard/recent-audit'),
getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`), getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`),
getObservabilitySummary: (days?: number) => get<any>(`/admin/observability/summary${days ? `?days=${days}` : ''}`),
// 用户管理 // 用户管理
@@ -886,6 +887,10 @@ export const adminAPI = {
getAdminAgentReviews: (id: string) => get<any>(`/admin/agents/${id}/reviews`), getAdminAgentReviews: (id: string) => get<any>(`/admin/agents/${id}/reviews`),
// Phase 3.1: 工具/MCP 权限沙箱 (tool_policy)
getAgentToolPolicy: (id: string) => get<any>(`/admin/agents/${id}/tool-policy`),
setAgentToolPolicy: (id: string, toolPolicy: any) => put<any>(`/admin/agents/${id}/tool-policy`, { tool_policy: toolPolicy }),
getAdminAvailableSkills: (params?: any) => { getAdminAvailableSkills: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
@@ -97,6 +97,7 @@ const adminNavGroups: NavGroup[] = [
icon: 'developer', icon: 'developer',
items: [ items: [
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' }, { path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
{ path: '/admin/observability', icon: 'observability', label: '调用可观测' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' }, { path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
@@ -154,6 +155,7 @@ const adminIconPaths: Record<string, JSX.Element> = {
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>, 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>, 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>, 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>,
+107 -3
View File
@@ -105,6 +105,10 @@ const Agents: React.FC = () => {
// Available skills for linking // Available skills for linking
const [availableSkills, setAvailableSkills] = useState<any[]>([]); const [availableSkills, setAvailableSkills] = useState<any[]>([]);
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]); 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 // Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
@@ -144,14 +148,17 @@ const Agents: React.FC = () => {
// Load available skills & models // Load available skills & models
const loadAvailable = async () => { const loadAvailable = async () => {
try { try {
const [skillsResult, modelsResult] = await Promise.all([ const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any, adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
adminAPI.getAdminAvailableModels() as any, adminAPI.getAdminAvailableModels() as any,
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
]); ]);
const skills = (skillsResult.data || skillsResult).items || []; const skills = (skillsResult.data || skillsResult).items || [];
const models = (modelsResult.data || modelsResult) || []; const models = (modelsResult.data || modelsResult) || [];
const mcps = (mcpsResult.data || mcpsResult).items || [];
setAvailableSkills(skills); setAvailableSkills(skills);
setAvailableModels(models); setAvailableModels(models);
setAvailableMcps(mcps);
} catch {} } catch {}
}; };
@@ -187,6 +194,7 @@ const Agents: React.FC = () => {
memory_enabled: false, memory_enabled: false,
reasoning_enabled: false, reasoning_enabled: false,
}); });
setToolPolicy(null);
setShowModal(true); setShowModal(true);
}; };
@@ -194,6 +202,7 @@ const Agents: React.FC = () => {
const openEdit = async (agent: Agent) => { const openEdit = async (agent: Agent) => {
setEditingAgent(agent); setEditingAgent(agent);
await loadAvailable(); await loadAvailable();
await openEditLoadPolicy(agent.id);
const cfg = agent.config || {}; const cfg = agent.config || {};
setForm({ setForm({
@@ -213,7 +222,15 @@ const Agents: React.FC = () => {
setShowModal(true); 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) => { const openDetail = async (agent: Agent) => {
setDetailAgent(agent); setDetailAgent(agent);
setShowDetail(true); setShowDetail(true);
@@ -246,7 +263,13 @@ const Agents: React.FC = () => {
try { try {
if (editingAgent) { if (editingAgent) {
await adminAPI.updateAdminAgent(editingAgent.id, payload); 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 { } else {
await adminAPI.createAdminAgent(payload); await adminAPI.createAdminAgent(payload);
showToast('Agent 创建成功'); showToast('Agent 创建成功');
@@ -612,6 +635,87 @@ const Agents: React.FC = () => {
</div> </div>
)} )}
</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>
<div className="modal-footer"> <div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button> <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;