Compare commits

..
Author SHA1 Message Date
QihuAI Design 3b34ba78b8 feat(agents): Phase3.2 Agent凭据Fernet加密存储 写入加密+调用点解密+返回脱敏+主密钥持久化防竞态 [v2026.07.19.7]
CI / gate (push) Has been cancelled
2026-07-19 15:27:15 +08:00
QihuAI Design 0600c37b9e feat(agents): Phase3.1 工具/MCP权限沙箱(tool_policy) 白名单+预算熔断+每轮上限+后台管理UI [v2026.07.19.6]
CI / gate (push) Has been cancelled
2026-07-19 12:34:40 +08:00
QihuAI Design 2969544d54 feat(observability): Phase2.3 媒体/LLM 可观测面板(后端归因聚合+前端页) 并修复 token_usage_logs trace_id 写入(chat_service/im_llm) [v2026.07.19.5]
CI / gate (push) Has been cancelled
2026-07-19 10:18:14 +08:00
QihuAI Design 491f31cead refactor(context): Phase2 上下文引擎升级 分层记忆+压缩+隔离 [v2026.07.19.4]
CI / gate (push) Has been cancelled
2026-07-19 09:29:57 +08:00
QihuAI Design a7a57f9a03 refactor(observability): Phase2.2 成本归因 trace_id列+token日志写入+LLM/MCP/媒体子span [v2026.07.19.3]
CI / gate (push) Has been cancelled
2026-07-19 08:51:28 +08:00
QihuAI Design 6aefdfa5c2 refactor(observability): Phase2.1 统一可观测基座 trace_id+结构化日志+span计时 [v2026.07.19.2]
CI / gate (push) Has been cancelled
2026-07-19 07:47:25 +08:00
QihuAI Design 1b6a184d27 refactor(chat): split 10070-line monolith into 8 service submodules (Phase1) [v2026.07.19.1]
CI / gate (push) Has been cancelled
2026-07-19 00:59:03 +08:00
QihuAI Design 4167e23e50 ci: add minimal CI gate (pre-push hook + ci_gate.py + dormant Gitea workflow) [v2026.07.18.5]
CI / gate (push) Has been cancelled
2026-07-19 00:01:07 +08:00
QihuAI Design dd10245ad5 test: 前端 vitest 测试基建 (Phase0) — 北京时间渲染回归 4 用例全绿 2026-07-18 23:47:21 +08:00
38 changed files with 14806 additions and 13788 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# qihuaiflow minimal CI (Gitea Actions)
#
# NOTE: This workflow is DORMANT until a Gitea act_runner is registered with
# this repo. The active gate today is the host-side pre-push hook
# (scripts/git-hooks/pre-push) + scripts/ci_gate.py, because our deployment
# model keeps the app running in a long-lived `qihu-aiflow` container on the
# docker host rather than building it fresh in CI.
#
# To activate later:
# 1. Install act_runner on the docker host (sharing the docker socket).
# 2. Register it against this repo with label `ubuntu-latest`.
# 3. The runner can then `docker exec qihu-aiflow ...` to reach the app.
#
# Until then, the pre-push hook already blocks broken pushes.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Backend pytest
run: docker exec qihu-aiflow bash -c 'cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q'
- name: Frontend vitest
run: docker exec qihu-aiflow bash -c 'cd /app/frontend && npx vitest run --reporter=dot'
+1 -21
View File
@@ -1,21 +1 @@
# 七虎AI PaaS 平台 — 版本清单 (VERSION) v2026.07.19.6
# 版本规范见 docs/VERSIONING.md
# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始)
# 环境标签: prod = 正式版 | test = 测试版
VERSION=v2026.07.18.1
ENV=prod
DATE=2026-07-18
BASE_IMAGE=qihu-aiflow:with-deps
BACKUP_IMAGE=qihu-aiflow:backup-20260718-001
## 本版本变更 (v2026.07.18.1 / prod)
- Phase0 工程纪律止血 启动
- 新增「部署前镜像备份 SOP」:scripts/backup_before_deploy.py,每次部署前自动提交递增序号镜像快照
- 统一版本号规范 vYYYY.MM.DD.N + prod/test 标签(取代旧 -recover 标签)
- 引入后端 pytest / 前端 vitest 测试基建,作为迭代式重构的安全网
- 固化反复问题的回归用例 R1/R2/R3
## 历史
- 旧方案:镜像曾用 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。
File diff suppressed because it is too large. Load diff
@@ -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
],
}
+1385 -10070
View File
File diff suppressed because it is too large. Load diff
+951
View File
@@ -0,0 +1,951 @@
# 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
from app.core.observability import get_trace_id
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import (_extract_text_from_json, _proxy_for_url)
def _normalize_dify_chat_url(endpoint: str) -> str:
"""把用户填写的 Dify 地址归一化为 /v1/chat-messages 端点。与 market.py 保持一致。"""
ep = (endpoint or "").rstrip("/")
if ep.endswith("/chat-messages"):
return ep
if ep.endswith("/v1"):
return ep + "/chat-messages"
return ep + "/v1/chat-messages"
async def _call_dify_agent(endpoint: str, api_key: str, query: str,
user_id: str, ext_conv_id: str | None) -> tuple:
"""调用 Dify(新版 Agent) /v1/chat-messages,流式(SSE)解析最终回答。
新版 Agent API 仅支持 response_mode=streaming(blocking 会 400)。事件流中
message / agent_message 事件携带增量 answer,累加即为最终回答;每个事件都带
conversation_id,用于维持多轮上下文。返回 (answer, new_conversation_id)。
"""
url = _normalize_dify_chat_url(endpoint)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"inputs": {},
"query": query,
"user": (user_id or "web-user")[:64],
"response_mode": "streaming",
"conversation_id": ext_conv_id or "",
}
answer_parts = []
new_conv_id = ext_conv_id
err_msg = None
wf_outputs = None
# httpx 默认 follow_redirects=False;stream POST 若遇 301/302 会直接返回该状态码。
# 且 httpx 自动跟随会把 POST 降级为 GET(对 /v1/chat-messages 变 405)。
# 因此手动跟随 301/302/307/308,保持 POST 与请求体不变(最多 4 跳)。
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False,
follow_redirects=False) as client:
cur_url = url
for _redir in range(4):
async with client.stream("POST", cur_url, headers=headers, json=payload) as resp:
if resp.status_code in (301, 302, 307, 308):
loc = resp.headers.get("location") or resp.headers.get("Location")
await resp.aclose()
if not loc:
raise RuntimeError(f"Dify HTTP {resp.status_code}: 缺少重定向地址")
# 处理相对地址
if loc.startswith("/"):
from urllib.parse import urlsplit, urlunsplit
p = urlsplit(cur_url)
loc = urlunsplit((p.scheme, p.netloc, loc, "", ""))
cur_url = loc
continue
if resp.status_code != 200:
body = (await resp.aread()).decode("utf-8", "replace")
raise RuntimeError(f"Dify HTTP {resp.status_code}: {body[:300]}")
async for line in resp.aiter_lines():
if not line:
continue
line = line.strip()
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
evt = json.loads(data_str)
except Exception:
continue
ev = evt.get("event")
if evt.get("conversation_id"):
new_conv_id = evt["conversation_id"]
data = evt.get("data") or {}
# 兼容三类 Dify 应用:
# 1) Chat/Agent 应用: message / agent_message 事件带增量 answer
# 2) Chatflow/Workflow 应用: text_chunk 事件带 data.text;
# 结束时 workflow_finished 带 status / outputs / error
if ev in ("message", "agent_message"):
answer_parts.append(evt.get("answer", "") or "")
elif ev == "message_replace":
# 内容审查替换:以替换后的完整 answer 为准
answer_parts = [evt.get("answer", "") or ""]
elif ev == "text_chunk":
answer_parts.append((data.get("text") or ""))
elif ev == "node_finished":
# answer / end 节点的 outputs 可能含最终文本
if data.get("node_type") in ("answer", "end") and data.get("outputs"):
wf_outputs = data.get("outputs")
if data.get("status") == "failed" and data.get("error"):
err_msg = str(data.get("error"))
elif ev == "workflow_finished":
if data.get("status") == "failed":
err_msg = str(data.get("error") or "Dify 工作流执行失败")
elif data.get("outputs"):
wf_outputs = data.get("outputs")
elif ev == "error":
err_msg = evt.get("message") or evt.get("code") or "Dify 流式返回错误"
break
else:
raise RuntimeError("Dify 重定向次数过多(>4),请直接填写重定向后的地址")
answer = "".join(answer_parts).strip()
# 流式无增量文本时,尝试从 workflow 结束 outputs 兜底提取
if not answer and wf_outputs:
answer = _extract_text_from_json(wf_outputs).strip()
if not answer and err_msg:
raise RuntimeError(err_msg)
return answer, new_conv_id
async def _call_n8n_agent(endpoint: str, api_key: str, query: str,
user_id: str, history: list) -> tuple:
"""调用 n8n 生产 Webhook (POST)。webhook 用 Respond to Webhook 节点返回 JSON,
尽力从中抽取文本回答。n8n webhook 无平台级会话态,多轮靠我们下发 history。
返回 (answer, None)。"""
url = (endpoint or "").strip()
headers = {"Content-Type": "application/json"}
if api_key:
# n8n Header Auth 自定义头名不固定,同时下发常见几种以提高兼容性
headers["Authorization"] = f"Bearer {api_key}"
headers["X-N8N-API-KEY"] = api_key
# 携带多种字段别名,兼容 n8n AI Agent(chatInput) 与自定义工作流
payload = {
"query": query,
"chatInput": query,
"message": query,
"input": query,
"user": (user_id or "web-user")[:64],
"history": history or [],
}
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client:
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code not in (200, 201):
raise RuntimeError(f"n8n Webhook HTTP {resp.status_code}: {resp.text[:300]}")
ctype = resp.headers.get("content-type", "")
if "application/json" in ctype:
try:
answer = _extract_text_from_json(resp.json())
except Exception:
answer = resp.text
else:
answer = resp.text
return (answer or "").strip(), None
async def _call_coze_agent(endpoint: str, api_key: str, query: str,
user_id: str, ext_conv_id: str | None) -> tuple:
"""调用扣子(Coze) v3 对话 API:create → 轮询 retrieve → 拉取 message list。
约定 agent_endpoint 存放 bot_id(纯 id),或形如
https://api.coze.cn/v3/chat?bot_id=xxx 的完整地址。默认基址 api.coze.cn。
返回 (answer, conversation_id)。
"""
ep = (endpoint or "").strip()
from urllib.parse import urlparse, parse_qs
bot_id = None
base = "https://api.coze.cn"
if ep.startswith("http"):
pr = urlparse(ep)
base = f"{pr.scheme}://{pr.netloc}"
qs = parse_qs(pr.query)
bot_id = (qs.get("bot_id") or [None])[0]
if not bot_id and "/bot/" in pr.path:
bot_id = pr.path.rstrip("/").split("/bot/")[-1]
else:
bot_id = ep # 纯 bot_id
if not bot_id:
raise RuntimeError("Coze 接入缺少 bot_id(请在 Endpoint 填入 bot_id 或带 ?bot_id= 的地址)")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
create_url = f"{base}/v3/chat"
if ext_conv_id:
create_url += f"?conversation_id={ext_conv_id}"
payload = {
"bot_id": bot_id,
"user_id": (user_id or "web-user")[:64],
"stream": False,
"auto_save_history": True,
"additional_messages": [
{"role": "user", "content": query, "content_type": "text"}
],
}
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(create_url), trust_env=False) as client:
r = await client.post(create_url, headers=headers, json=payload)
if r.status_code != 200:
raise RuntimeError(f"Coze create HTTP {r.status_code}: {r.text[:300]}")
cd = r.json()
if cd.get("code") not in (0, None):
raise RuntimeError(f"Coze 错误: {cd.get('msg') or cd}")
chat = cd.get("data") or {}
chat_id = chat.get("id")
conv_id2 = chat.get("conversation_id") or ext_conv_id
if not chat_id or not conv_id2:
raise RuntimeError(f"Coze 未返回 chat_id/conversation_id: {str(cd)[:200]}")
# 轮询直到完成(最多约 3 分钟)
status = chat.get("status")
for _ in range(60):
if status == "completed":
break
if status in ("failed", "requires_action", "canceled"):
raise RuntimeError(f"Coze 对话状态 {status}")
await asyncio.sleep(3)
rr = await client.get(
f"{base}/v3/chat/retrieve?chat_id={chat_id}&conversation_id={conv_id2}",
headers=headers,
)
if rr.status_code == 200:
status = ((rr.json() or {}).get("data") or {}).get("status")
# 拉取消息列表,取 type=answer 的内容
rm = await client.get(
f"{base}/v3/chat/message/list?chat_id={chat_id}&conversation_id={conv_id2}",
headers=headers,
)
answer = ""
if rm.status_code == 200:
for m in ((rm.json() or {}).get("data") or []):
if m.get("type") == "answer" and m.get("role") == "assistant":
answer = m.get("content", "") or answer
return (answer or "").strip(), conv_id2
async def _call_external_agent(db, agent: dict, query: str, user_id: str,
ext_conversation_id: str | None,
history: list) -> tuple:
"""按平台分发到对应外部 Agent 远程调用。返回 (answer, new_ext_conversation_id)。"""
platform = (agent.get("platform") or "dify").lower()
endpoint = agent.get("agent_endpoint")
_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))
if not endpoint:
raise RuntimeError("该 Agent 未配置远程调用地址(agent_endpoint)")
if platform == "dify":
return await _call_dify_agent(endpoint, api_key, query, user_id, ext_conversation_id)
if platform == "n8n":
return await _call_n8n_agent(endpoint, api_key, query, user_id, history)
if platform == "coze":
return await _call_coze_agent(endpoint, api_key, query, user_id, ext_conversation_id)
# 未知平台:默认按 Dify 协议尝试
return await _call_dify_agent(endpoint, api_key, query, user_id, ext_conversation_id)
File diff suppressed because it is too large. Load diff
+319
View File
@@ -0,0 +1,319 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import (cst_now)
def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict:
"""从知识库文档构建知识图谱节点和边,返回 graph_data。"""
documents = db.query(KnowledgeDocument).filter(
and_(
KnowledgeDocument.knowledge_base_id == kb.id,
KnowledgeDocument.status == "completed",
)
).all()
graph_nodes = []
graph_edges = []
doc_entity_types = [
{"type": "concept", "label": "核心概念"},
{"type": "entity", "label": "关键实体"},
{"type": "attribute", "label": "属性特征"},
{"type": "relation", "label": "关联关系"},
]
for idx, doc in enumerate(documents):
doc_node_id = f"doc-{doc.id[:8]}"
graph_nodes.append({
"id": doc_node_id, "label": doc.filename, "type": "document", "size": 18, "group": "document",
})
filename_base = doc.filename.rsplit(".", 1)[0]
words = filename_base.replace("_", " ").replace("-", " ").split()
for wi, word in enumerate(words):
if len(word) >= 2:
entity_type = doc_entity_types[wi % len(doc_entity_types)]
node_id = f"ent-{doc.id[:8]}-{wi}"
graph_nodes.append({
"id": node_id, "label": word, "type": entity_type["type"],
"size": 10 + (wi % 8), "group": entity_type["type"],
})
graph_edges.append({
"source": doc_node_id, "target": node_id,
"label": entity_type["label"], "weight": 0.5 + (0.5 / (wi + 1)),
})
for i in range(len(documents)):
for j in range(i + 1, len(documents)):
fi = documents[i].filename.lower().rsplit(".", 1)[0]
fj = documents[j].filename.lower().rsplit(".", 1)[0]
words_i = set(fi.replace("_", " ").replace("-", " ").split())
words_j = set(fj.replace("_", " ").replace("-", " ").split())
common = words_i & words_j
if common and len(common) > 0:
graph_edges.append({
"source": f"doc-{documents[i].id[:8]}",
"target": f"doc-{documents[j].id[:8]}",
"label": f"共有关键词: {', '.join(list(common)[:3])}",
"weight": min(0.9, 0.3 * len(common)),
})
graph_data = {
"nodes": graph_nodes,
"edges": graph_edges,
"stats": {
"total_nodes": len(graph_nodes), "total_edges": len(graph_edges),
"document_count": len(documents),
},
}
kb.knowledge_graph_count = len(graph_nodes)
kb.updated_at = cst_now()
return graph_data
+240
View File
@@ -0,0 +1,240 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list:
"""
加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。
返回: [{id, name, transport, endpoint, description}]
"""
if not mcp_ids:
return []
mcps = db.query(MarketApp).filter(
MarketApp.id.in_(mcp_ids),
MarketApp.type == "mcp"
).all()
results = []
for m in mcps:
config = {}
try:
if m.skill_content:
config = json.loads(m.skill_content)
except Exception:
config = {}
results.append({
"id": m.id,
"name": m.name,
"transport": m.mcp_transport or config.get("transport", "stdio"),
"endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""),
"description": m.description or config.get("description", ""),
})
return results
def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list:
"""
解析用户选中的 MCP 服务连接信息(来自 mcp_servers 表,import 与后台添加通用)。
返回: [{id, name, description, transport, endpoint, url, headers}]
"""
if not mcp_ids:
return []
servers = db.query(MCPServer).filter(
MCPServer.id.in_(mcp_ids),
MCPServer.is_enabled == True,
).all()
out = []
for s in servers:
try:
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
except Exception:
headers = {}
out.append({
"id": s.id,
"name": s.name,
"description": s.description or "",
"transport": s.transport_type or "streamable_http",
"endpoint": s.url or "",
"url": s.url or "",
"headers": headers,
})
return out
def _list_enabled_mcp_servers(db: Session) -> list:
"""返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。"""
servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
out = []
for s in servers:
try:
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
except Exception:
headers = {}
out.append({
"id": s.id,
"name": s.name,
"description": s.description or "",
"transport": s.transport_type or "streamable_http",
"endpoint": s.url or "",
"url": s.url or "",
"headers": headers,
})
return out
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+378
View File
@@ -0,0 +1,378 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
logger = logging.getLogger(__name__)
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
"""Get current time in CST timezone"""
return datetime.now(CST_TZ)
def _html_escape(s: str) -> str:
return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;").replace("'", "&#39;"))
def _outbound_proxy() -> str:
"""出网代理地址(兜底)。默认经 172.18.0.1:7890 智能路由代理;
运行时若未注入代理环境变量则回退到该地址。"""
import os
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理。
2026-07-15 调整:AGNES(apihub.agnes-ai.com / platform-outputs.agnes-ai.space)
经用户实测走香港代理 103.242.14.208:11495 延迟更低、更稳定,故改为走 HK 代理
(带鉴权),不再直连,也不经智能路由代理(172.18.0.1:7890 对 AGNES 返 502)。
其余国内/平台域名直连,真正的境外域名走 _outbound_proxy()。
注意:httpx 默认 trust_env=True 会忽略 proxy 参数而改用环境变量 HTTP_PROXY,
故调用方必须同时传 trust_env=False 才能真正走此处指定的代理。
"""
import os
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
# 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。
if "qihuyun.com" in host:
return None
# 尊重 NO_PROXY:国内域名(硅基流动/DeepSeek/小米MoE 等)直连更稳定,
# 避免经易波动的智能路由代理 172.18.0.1:7890(对国内域名偶发连接重置)。
_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 _outbound_proxy()
def _extract_text_from_json(obj) -> str:
"""从 n8n / 通用 webhook 的任意 JSON 响应中尽力抽取文本回答。"""
if obj is None:
return ""
if isinstance(obj, str):
return obj
if isinstance(obj, (int, float, bool)):
return str(obj)
if isinstance(obj, list):
# 取第一个可抽取到文本的元素
for it in obj:
t = _extract_text_from_json(it)
if t:
return t
return ""
if isinstance(obj, dict):
# 常见回答字段优先级
for k in ("output", "text", "answer", "response", "reply",
"message", "content", "result", "data"):
if k in obj:
t = _extract_text_from_json(obj[k])
if t:
return t
# 兜底:整体序列化
try:
return json.dumps(obj, ensure_ascii=False)
except Exception:
return str(obj)
return str(obj)
class CreateConversationRequest(BaseModel):
title: str = Field(default="新对话")
model_id: Optional[str] = None
class UpdateConversationRequest(BaseModel):
title: Optional[str] = None
status: Optional[str] = None # active, archived
pinned: Optional[bool] = None
agent_ids: Optional[list] = None
skill_ids: Optional[list] = None
knowledge_base_ids: Optional[list] = None
class SendMessageRequest(BaseModel):
content: str
model_id: Optional[str] = None
skill_ids: Optional[list] = None
agent_ids: Optional[list] = None
knowledge_base_ids: Optional[list] = None
mcp_ids: Optional[list] = None
attachments: Optional[list] = None
use_tools: Optional[bool] = False
+234 -232
View File
@@ -1,232 +1,234 @@
""" """
IM Bot 真实 LLM 调用。 IM Bot 真实 LLM 调用。
原实现位于 channels.py(通用频道管理模块),该模块已移除。 原实现位于 channels.py(通用频道管理模块),该模块已移除。
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。 为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
行为(对齐 AI 聊天): 行为(对齐 AI 聊天):
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由), - Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
不再对"制作图片"这类请求只回文本。 不再对"制作图片"这类请求只回文本。
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能, - Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。 多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败; - 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。 媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
""" """
import asyncio import asyncio
import json import json
import logging import logging
import re as _re import re as _re
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
from urllib.parse import urlparse as _urlparse from urllib.parse import urlparse as _urlparse
import httpx import httpx
from sqlalchemy.orm import Session 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__)
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495" # 香港出网代理(实测对 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 同逻辑)。 def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
国内域名(尊重 NO_PROXY)直连,更稳定。 2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
""" 国内域名(尊重 NO_PROXY)直连,更稳定。
import os """
host = (_urlparse(url).hostname or "").lower() import os
if "agnes-ai.com" in host or "agnes-ai.space" in host: host = (_urlparse(url).hostname or "").lower()
return _HK_PROXY if "agnes-ai.com" in host or "agnes-ai.space" in host:
if "qihuyun.com" in host: return _HK_PROXY
return None if "qihuyun.com" in host:
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()] return None
for _np in _no_proxy: _no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
if host == _np or host.endswith("." + _np) or _np in host: for _np in _no_proxy:
return None if host == _np or host.endswith("." + _np) or _np in host:
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890" 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):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。 def _run_async(coro, timeout=None):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。 IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
""" timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
try: """
loop = asyncio.get_running_loop() try:
except RuntimeError: loop = asyncio.get_running_loop()
loop = None except RuntimeError:
loop = None
if timeout is not None:
async def _timed(): if timeout is not None:
return await asyncio.wait_for(coro, timeout=timeout) async def _timed():
target = _timed() return await asyncio.wait_for(coro, timeout=timeout)
else: target = _timed()
target = coro else:
target = coro
if loop is not None and loop.is_running():
import concurrent.futures if loop is not None and loop.is_running():
with concurrent.futures.ThreadPoolExecutor(1) as ex: import concurrent.futures
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None) with concurrent.futures.ThreadPoolExecutor(1) as ex:
return asyncio.run(target) 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,供驾驶舱统计。 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 一致的估算。 此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里“查无此项”。
""" 媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
if not user_id or not ai_model: """
return if not user_id or not ai_model:
try: return
base = 1200 if media_type == "video" else 400 try:
est = base + (len(prompt or "") // 4) base = 1200 if media_type == "video" else 400
db.add(TokenUsageLog( est = base + (len(prompt or "") // 4)
id=gen_uuid(), db.add(TokenUsageLog(
user_id=user_id, id=gen_uuid(),
model_id=ai_model.id, user_id=user_id,
model_name=ai_model.name, model_id=ai_model.id,
prompt_tokens=est, model_name=ai_model.name,
completion_tokens=0, trace_id=get_trace_id(),
total_tokens=est, prompt_tokens=est,
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None), completion_tokens=0,
)) total_tokens=est,
db.commit() created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
except Exception as e: ))
try: db.commit()
db.rollback() except Exception as e:
except Exception: try:
pass db.rollback()
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}") 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: def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
from app.api.chat import ( """调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。"""
_generate_media, try:
detect_media_intent, from app.api.chat import (
_discover_installed_skills, _generate_media,
_build_system_prompt, detect_media_intent,
_execute_skill_tool, _discover_installed_skills,
) _build_system_prompt,
_execute_skill_tool,
# ===== 1) 媒体意图路由(Issue 7)===== )
intent = detect_media_intent(prompt)
if intent: # ===== 1) 媒体意图路由(Issue 7)=====
ai_model = resolve_auto_model(intent, db) intent = detect_media_intent(prompt)
if ai_model: if intent:
try: ai_model = resolve_auto_model(intent, db)
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300) if ai_model:
except Exception: try:
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示, result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
# 绝不直接把原始异常(如 "Server disconnected")甩给用户 except Exception:
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。" # 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
if result: # 绝不直接把原始异常(如 "Server disconnected")甩给用户
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频 result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
s = result.strip() if result:
if s.startswith("<img") or s.startswith("<video"): # 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
m = _re.search(r'src="([^"]+)"', s) s = result.strip()
url = m.group(1) if m else "" if s.startswith("<img") or s.startswith("<video"):
media_type = "image" if s.startswith("<img") else "video" m = _re.search(r'src="([^"]+)"', s)
if not url: url = m.group(1) if m else ""
return "(生成完成,但未能提取媒体地址)" media_type = "image" if s.startswith("<img") else "video"
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据 if not url:
_log_media_usage(db, user_id, ai_model, media_type, prompt) return "(生成完成,但未能提取媒体地址)"
return {"__media__": True, "media_type": media_type, "url": url} # IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙), _log_media_usage(db, user_id, ai_model, media_type, prompt)
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户 return {"__media__": True, "media_type": media_type, "url": url}
if s.startswith("⚠️") or s.startswith("(媒体"): # 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
return s # 直接透传,绝不把原始提示词/标签/JSON 透传给用户
return "(媒体生成未能返回有效内容,请重试)" if s.startswith("⚠️") or s.startswith("(媒体"):
return s
# ===== 2) 文本回复 + 技能自动发现(Issue 8)===== return "(媒体生成未能返回有效内容,请重试)"
ai_model = resolve_auto_model("text", db)
if not ai_model: # ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
return "(未配置默认文本模型,无法生成回复)" ai_model = resolve_auto_model("text", db)
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() if not ai_model:
api_key = ai_model.api_key or (provider.api_key if provider else None) return "(未配置默认文本模型,无法生成回复)"
api_url = ai_model.endpoint provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
if not api_url and provider and provider.api_base_url: api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" api_url = ai_model.endpoint
if not (api_key and api_url): if not api_url and provider and provider.api_base_url:
return "(模型未配置 API Key 或地址,无法生成回复)" 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 = []
skills = _discover_installed_skills(db, user_id)[:15] if user_id:
except Exception: try:
skills = [] skills = _discover_installed_skills(db, user_id)[:15]
except Exception:
system_prompt, function_definitions = _build_system_prompt( skills = []
skills=skills,
agents=[], system_prompt, function_definitions = _build_system_prompt(
kb_context="", skills=skills,
user_content=prompt, agents=[],
mcp=[], kb_context="",
auto_discovered=bool(skills), user_content=prompt,
) mcp=[],
messages = [ auto_discovered=bool(skills),
{"role": "system", "content": system_prompt}, )
{"role": "user", "content": prompt}, messages = [
] {"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
max_rounds = 3 ]
ai_content = ""
for _ in range(max_rounds): max_rounds = 3
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048} ai_content = ""
if function_definitions and skills: for _ in range(max_rounds):
request_body["tools"] = [ request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
{"type": "function", "function": fd} for fd in function_definitions if function_definitions and skills:
] request_body["tools"] = [
request_body["tool_choice"] = "auto" {"type": "function", "function": fd} for fd in function_definitions
with httpx.Client( ]
timeout=ai_model.timeout or 120, request_body["tool_choice"] = "auto"
proxy=_proxy_for_url(api_url), with httpx.Client(
trust_env=False, timeout=ai_model.timeout or 120,
) as client: proxy=_proxy_for_url(api_url),
resp = client.post( trust_env=False,
api_url, ) as client:
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, resp = client.post(
json=request_body, api_url,
) headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
if resp.status_code != 200: json=request_body,
return f"(AI 模型调用失败 HTTP {resp.status_code})" )
data = resp.json() if resp.status_code != 200:
message = data.get("choices", [{}])[0].get("message", {}) return f"(AI 模型调用失败 HTTP {resp.status_code})"
tool_calls = message.get("tool_calls", []) data = resp.json()
if tool_calls and skills: message = data.get("choices", [{}])[0].get("message", {})
messages.append({ tool_calls = message.get("tool_calls", [])
"role": "assistant", if tool_calls and skills:
"content": message.get("content") or "", messages.append({
"tool_calls": tool_calls, "role": "assistant",
}) "content": message.get("content") or "",
for tc in tool_calls: "tool_calls": tool_calls,
func_name = tc.get("function", {}).get("name", "") })
try: for tc in tool_calls:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}")) func_name = tc.get("function", {}).get("name", "")
except Exception: try:
func_args = {"input": tc.get("function", {}).get("arguments", "")} func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt) except Exception:
messages.append({ func_args = {"input": tc.get("function", {}).get("arguments", "")}
"role": "tool", tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
"tool_call_id": tc.get("id", "call_1"), messages.append({
"content": tool_result, "role": "tool",
}) "tool_call_id": tc.get("id", "call_1"),
continue "content": tool_result,
ai_content = message.get("content", "") })
break continue
return ai_content or "(AI 回复为空)" ai_content = message.get("content", "")
except Exception as e: break
return f"(AI 回复生成失败:{e})" 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 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(),
+11 -4
View File
@@ -5,6 +5,7 @@ import os
import logging import logging
from pathlib import Path from pathlib import Path
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from app.core.observability import TraceIdFilter, JsonFormatter
from dotenv import load_dotenv from dotenv import load_dotenv
# 加载 .env 文件(Docker 环境跳过,直接使用容器环境变量) # 加载 .env 文件(Docker 环境跳过,直接使用容器环境变量)
@@ -80,10 +81,14 @@ SLOW_REQUEST_THRESHOLD = float(os.getenv("SLOW_REQUEST_THRESHOLD", "3.0"))
def setup_file_logging(): def setup_file_logging():
"""配置应用日志按天轮转输出到文件""" """配置应用日志按天轮转输出到文件"""
log_format = logging.Formatter( use_json = os.getenv("AIFLOW_JSON_LOGS", "0") == "1"
"%(asctime)s [%(levelname)s] %(name)s - %(message)s", if use_json:
datefmt="%Y-%m-%d %H:%M:%S", log_format = JsonFormatter()
) else:
log_format = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s [tid:%(trace_id)s] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 应用日志 - 按天轮转,保留30天 # 应用日志 - 按天轮转,保留30天
app_handler = TimedRotatingFileHandler( app_handler = TimedRotatingFileHandler(
@@ -123,10 +128,12 @@ def setup_file_logging():
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
root_logger.addHandler(app_handler) root_logger.addHandler(app_handler)
root_logger.addHandler(error_handler) root_logger.addHandler(error_handler)
root_logger.addFilter(TraceIdFilter())
# 配置访问日志专用logger # 配置访问日志专用logger
access_logger = logging.getLogger("aiflow.access") access_logger = logging.getLogger("aiflow.access")
access_logger.addHandler(access_handler) access_logger.addHandler(access_handler)
access_logger.propagate = False access_logger.propagate = False
access_logger.addFilter(TraceIdFilter())
return access_logger return access_logger
+1
View File
@@ -0,0 +1 @@
# app/core package - 统一可观测基座 (Phase 2.1)
+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:]
+174
View File
@@ -0,0 +1,174 @@
"""
Qihu-AIFlow 统一可观测基座 (Phase 2.1)
=====================================
提供贯穿一次请求的全局链路 ID(trace_id)与结构化日志能力,
为后续重构(上下文引擎、媒体/LLM 成本归因)建立回归基线。
设计原则(Strangler Fig 增量):
- 纯增量埋点,不改任何既有接口的返回结构。
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的
TraceIdFilter 自动注入,调用方无需改动。
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
使用方式:
from app.core.observability import set_trace_id, get_trace_id, span
# 在中间件里为每个请求注入:
set_trace_id(request_id)
# 在关键调用处打计时 span(自动带 trace_id):
async with span("chat.send_message", conv_id=conv_id):
return await _send_message_core(...)
"""
import logging
import time
import uuid
import json
from contextvars import ContextVar
# ---------------------------------------------------------------------------
# trace_id:贯穿一次请求(含其内所有子调用/协程)的全局链路 ID
# ---------------------------------------------------------------------------
_trace_id_var: ContextVar[str] = ContextVar("aiflow_trace_id", default="-")
def new_trace_id() -> str:
"""生成一个崭新的链路 ID(32 位 hex)。"""
return uuid.uuid4().hex
def set_trace_id(tid: str | None = None) -> str:
"""设置当前上下文的 trace_id;不传则自动生成。返回最终使用的 ID。"""
tid = tid or new_trace_id()
_trace_id_var.set(tid)
return tid
def get_trace_id() -> str:
"""读取当前上下文的 trace_id;未设置时返回占位 '-'。"""
return _trace_id_var.get()
class TraceIdFilter(logging.Filter):
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承,
从而让全平台既有日志自动带上链路 ID,零侵入。
"""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "trace_id"):
try:
record.trace_id = get_trace_id()
except Exception:
record.trace_id = "-"
return True
# 标准 logging 字段,JSON 序列化时需排除,避免与自定义 extra 冲突
_STD_ATTRS = set(logging.makeLogRecord({}).__dict__.keys()) | {
"message", "asctime", "msg", "trace_id", "exc_info", "stack_info", "args",
}
class JsonFormatter(logging.Formatter):
"""结构化 JSON 日志格式(通过环境变量 AIFLOW_JSON_LOGS=1 启用)。
默认不启用,避免破坏现有日志采集/人工查阅习惯。
"""
def format(self, record: logging.LogRecord) -> str:
data = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"trace_id": getattr(record, "trace_id", "-"),
"msg": record.getMessage(),
}
for k, v in record.__dict__.items():
if k not in _STD_ATTRS:
try:
json.dumps(v, ensure_ascii=False)
data[k] = v
except (TypeError, ValueError):
data[k] = str(v)
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
return json.dumps(data, ensure_ascii=False)
def get_logger(name: str | None = None) -> logging.Logger:
"""获取 logger(向后兼容现有用法)。trace_id 由 root filter 自动注入。"""
return logging.getLogger(name)
# ---------------------------------------------------------------------------
# span:结构化计时上下文管理器(耗时 + trace_id + 自定义字段)
# ---------------------------------------------------------------------------
class _Span:
"""同步/异步通用的计时 span。退出时记录 elapsed_ms 与状态。"""
def __init__(self, name: str, **fields):
self.name = name
self.fields = fields
self._logger = logging.getLogger("aiflow.span")
self._start = None
self.elapsed_ms = 0.0
def _enter(self):
self._start = time.perf_counter()
self._logger.info(
f"SPAN_START {self.name}",
extra={"span": self.name, "event": "start", "trace_id": get_trace_id(), **self.fields},
)
return self
def _exit(self, exc_type, exc, tb):
self.elapsed_ms = (time.perf_counter() - self._start) * 1000
status = "error" if exc_type else "ok"
self._logger.info(
f"SPAN_END {self.name} {self.elapsed_ms:.1f}ms",
extra={
"span": self.name,
"event": "end",
"elapsed_ms": round(self.elapsed_ms, 1),
"status": status,
"trace_id": get_trace_id(),
**self.fields,
},
)
return False # 不吞掉异常
# 同步协议
def __enter__(self):
return self._enter()
def __exit__(self, exc_type, exc, tb):
return self._exit(exc_type, exc, tb)
# 异步协议
async def __aenter__(self):
return self._enter()
async def __aexit__(self, exc_type, exc, tb):
return self._exit(exc_type, exc, tb)
def span(name: str, **fields):
"""创建一个计时 span。可同步或异步 with。
async with span("chat.send_message", conv_id=cid):
...
"""
return _Span(name, **fields)
__all__ = [
"new_trace_id",
"set_trace_id",
"get_trace_id",
"TraceIdFilter",
"JsonFormatter",
"get_logger",
"span",
]
+2 -1
View File
@@ -57,7 +57,7 @@ class LogService:
app_id: str = None, app_name: str = None, app_id: str = None, app_name: str = None,
conversation_id: str = None, prompt_tokens: int = 0, conversation_id: str = None, prompt_tokens: int = 0,
completion_tokens: int = 0, total_tokens: int = 0, completion_tokens: int = 0, total_tokens: int = 0,
cost: float = 0): cost: float = 0, trace_id: str = None):
"""记录Token消耗日志""" """记录Token消耗日志"""
db = SessionLocal() db = SessionLocal()
try: try:
@@ -73,6 +73,7 @@ class LogService:
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
total_tokens=total_tokens, total_tokens=total_tokens,
cost=cost, cost=cost,
trace_id=trace_id,
) )
db.add(log) db.add(log)
db.commit() db.commit()
+13 -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)
@@ -537,6 +539,7 @@ class TokenUsageLog(Base):
app_id = Column(String(36), default=None) app_id = Column(String(36), default=None)
app_name = Column(String(200), default=None) app_name = Column(String(200), default=None)
conversation_id = Column(String(36), default=None) conversation_id = Column(String(36), default=None)
trace_id = Column(String(64), default=None)
prompt_tokens = Column(Integer, default=0) prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0) completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0) total_tokens = Column(Integer, default=0)
@@ -1124,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)
+250 -246
View File
@@ -1,246 +1,250 @@
""" """
Qihu-AIFlow FastAPI 主应用 Qihu-AIFlow FastAPI 主应用
完整后端API,监听18080端口,所有接口以 /app 为前缀 完整后端API,监听18080端口,所有接口以 /app 为前缀
""" """
import sys import sys
import os import os
import time import time
import uuid import uuid
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from datetime import datetime from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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
from app.api.admin.developer_tools_admin import router as developer_tools_router 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.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin from app.api.admin import asset_admin, achievement_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal from app.core.observability import set_trace_id
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 # 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转)
access_logger = setup_file_logging() # 初始化文件日志(按天轮转)
logger = logging.getLogger("aiflow") access_logger = setup_file_logging()
logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求""" class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4()) async def dispatch(self, request: Request, call_next):
start_time = time.time() request_id = str(uuid.uuid4())
path = request.url.path set_trace_id(request_id)
method = request.method 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) # 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"]
try: should_skip = any(path.startswith(p) for p in skip_paths)
response = await call_next(request)
elapsed = time.time() - start_time try:
status_code = response.status_code response = await call_next(request)
elapsed = time.time() - start_time
if not should_skip: status_code = response.status_code
access_logger.info( response.headers["X-Trace-Id"] = request_id
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
) if not should_skip:
access_logger.info(
# 异步记录API调用统计 f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
try: )
db = SessionLocal()
try: # 异步记录API调用统计
_record_api_call(db, method, path, status_code, elapsed) try:
finally: db = SessionLocal()
db.close() try:
except Exception as e: _record_api_call(db, method, path, status_code, elapsed)
logger.warning(f"API统计记录失败: {e}") finally:
db.close()
return response except Exception as e:
except Exception as e: logger.warning(f"API统计记录失败: {e}")
elapsed = time.time() - start_time
if not should_skip: return response
access_logger.info( except Exception as e:
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" elapsed = time.time() - start_time
) if not should_skip:
raise access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
async def __call__(self, scope, receive, send): )
if scope["type"] != "http": raise
await self.app(scope, receive, send)
return async def __call__(self, scope, receive, send):
await super().__call__(scope, receive, send) if scope["type"] != "http":
await self.app(scope, receive, send)
return
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): await super().__call__(scope, receive, send)
"""记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID)
parts = path.split("/") def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
simplified_parts = [] """记录API调用到数据库统计表"""
for part in parts: # 简化路径以减少基数(去掉动态ID)
# 将UUID样式的路径段替换为{id} parts = path.split("/")
if len(part) == 36 and part.count("-") == 4: simplified_parts = []
simplified_parts.append("{id}") for part in parts:
# 将纯数字路径段替换为{id} # 将UUID样式的路径段替换为{id}
elif part.isdigit(): if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}") simplified_parts.append("{id}")
else: # 将纯数字路径段替换为{id}
simplified_parts.append(part) elif part.isdigit():
simplified_path = "/".join(simplified_parts) simplified_parts.append("{id}")
else:
# 统计记录通过数据库实现,简化版记录到日志 simplified_parts.append(part)
is_error = status_code >= 400 simplified_path = "/".join(simplified_parts)
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
# 统计记录通过数据库实现,简化版记录到日志
if is_slow: is_error = status_code >= 400
logger.warning( is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
) 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 启动中...")
@asynccontextmanager
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) async def lifespan(app: FastAPI):
try: logger.info("Qihu-AIFlow v2.0.0 启动中...")
from app.services.evolution_service import EvolutionWatcherManager
from app.models import UserEvolutionConfig # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
db = SessionLocal() try:
try: from app.services.evolution_service import EvolutionWatcherManager
configs = db.query(UserEvolutionConfig).filter( from app.models import UserEvolutionConfig
UserEvolutionConfig.watcher_status == "running" db = SessionLocal()
).all() try:
if configs: configs = db.query(UserEvolutionConfig).filter(
watcher = EvolutionWatcherManager() UserEvolutionConfig.watcher_status == "running"
for cfg in configs: ).all()
try: if configs:
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) watcher = EvolutionWatcherManager()
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") for cfg in configs:
except Exception as e: try:
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
else: except Exception as e:
logger.info("[Evolution] 无待恢复的进化配置") logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
finally: logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
db.close() else:
except Exception as e: logger.info("[Evolution] 无待恢复的进化配置")
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") finally:
db.close()
# 启动Redis事件总线 except Exception as e:
try: logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
from app.event_bus import get_event_bus
event_bus = get_event_bus() # 启动Redis事件总线
event_bus.start(num_consumers=2) try:
logger.info("[EventBus] Redis事件总线已启动") from app.event_bus import get_event_bus
except Exception as e: event_bus = get_event_bus()
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") event_bus.start(num_consumers=2)
logger.info("[EventBus] Redis事件总线已启动")
logger.info("Qihu-AIFlow v2.0.0 启动完成") except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
yield
logger.info("Qihu-AIFlow v2.0.0 启动完成")
# 停止Redis事件总线
try: yield
from app.event_bus import get_event_bus
get_event_bus().stop() # 停止Redis事件总线
logger.info("[EventBus] Redis事件总线已停止") try:
except Exception: from app.event_bus import get_event_bus
pass get_event_bus().stop()
logger.info("[EventBus] Redis事件总线已停止")
# 优雅关闭:停止所有 Evolution Pipeline except Exception:
try: pass
from app.services.evolution_service import EvolutionWatcherManager
EvolutionWatcherManager().shutdown_all() # 优雅关闭:停止所有 Evolution Pipeline
logger.info("[Evolution] 所有Pipeline已停止") try:
except Exception: from app.services.evolution_service import EvolutionWatcherManager
pass EvolutionWatcherManager().shutdown_all()
logger.info("[Evolution] 所有Pipeline已停止")
logger.info("Qihu-AIFlow 正在关闭...") except Exception:
pass
app = FastAPI( logger.info("Qihu-AIFlow 正在关闭...")
title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0",
lifespan=lifespan, app = FastAPI(
) title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0",
# API调用追踪中间件 lifespan=lifespan,
app.add_middleware(APITrackingMiddleware) )
# CORS配置 # API调用追踪中间件
app.add_middleware( app.add_middleware(APITrackingMiddleware)
CORSMiddleware,
allow_origins=["*"], # CORS配置
allow_credentials=True, app.add_middleware(
allow_methods=["*"], CORSMiddleware,
allow_headers=["*"], allow_origins=["*"],
) allow_credentials=True,
allow_methods=["*"],
# 前台 API allow_headers=["*"],
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=["知识库"]) # 前台 API
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
# 后台 API app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) # 后台 API
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
app.include_router(logs.router, prefix="/app/api/admin/logs", 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(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
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(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"]) app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"]) app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"]) app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"]) app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"]) app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"]) 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.get("/app/api/health") app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
async def health_check(): app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
return {"status": "ok", "service": "Qihu-AIFlow"}
@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"]
+25 -20
View File
@@ -1,40 +1,46 @@
"""Phase 0 回归用例 R1/R2/R3 — 固化三个反复出现的问题。 """Phase 1 回归用例 R1/R2/R3 — 固化三个反复出现的问题(适配 chat.py 拆分后)。
R1: 技能列表不得被硬性截断为 15 条(微信 Bot 曾只返回 15 个技能)。 R1: 技能列表不得被硬性截断为 15 条(微信 Bot 曾只返回 15 个技能)。
R2: 显式选中技能/MCP 时,system prompt 必须注入「最高优先级覆盖规则」。 R2: 显式选中技能/MCP 时,system prompt 必须注入「最高优先级覆盖规则」。
R3: 媒体生成(图像/视频)必须有超时保护,超时后回写用户可懂的提示而非永久挂起。 R3: 媒体生成(图像/视频)必须有超时保护,超时后回写用户可懂的提示而非永久挂起。
说明:在 chat.py 巨石(10070 行)尚未拆分前,R1/R2 采用「源码契约」守卫, Phase 1 已将 chat.py 巨石拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py,
这是最低风险且能切实挡住回归的方式;R3 为行为级单元测试(mock 内部生成调用模拟超时)。 因此本文件采用「扫描整个 app/api 包」做源码契约守卫,并对行为级用例从正确的子模块导入。
Phase 1 抽出 chat_media / chat_mcp / chat_skill 干净 seam 后,R1/R2 应升级为行为测试。
""" """
import asyncio import asyncio
import os import os
import glob
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
CHAT_PY = os.path.abspath(os.path.join(_HERE, "..", "app", "api", "chat.py")) API_DIR = os.path.abspath(os.path.join(_HERE, "..", "app", "api"))
def _read_chat_source(): def _read_api_package():
with open(CHAT_PY, "r", encoding="utf-8") as f: parts = []
return f.read() for p in sorted(glob.glob(os.path.join(API_DIR, "chat*.py"))):
with open(p, "r", encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
# ---------- R1 ---------- # ---------- R1 ----------
def test_r1_skill_list_not_hard_capped_at_15(): def test_r1_skill_list_not_hard_capped_at_15():
"""微信 Bot 技能列表曾因 limit(15) 只返回 15 个,必须防止回归。""" """微信 Bot 技能列表曾因 limit(15) 只返回 15 个,必须防止回归。"""
src = _read_chat_source() src = _read_api_package()
assert "limit(15)" not in src, "检测到 limit(15),技能列表可能被硬性截断" assert "limit(15)" not in src, "检测到 limit(15),技能列表可能被硬性截断"
assert "LIMIT 15" not in src, "检测到 LIMIT 15,技能列表可能被硬性截断" assert "LIMIT 15" not in src, "检测到 LIMIT 15,技能列表可能被硬性截断"
# ---------- R2 ---------- # ---------- R2 ----------
def test_r2_explicit_selection_override_rule_present(): def test_r2_explicit_selection_override_rule_present():
"""显式选中技能/MCP 时,system prompt 必须包含最高优先级覆盖规则。""" """显式选中技能/MCP 时,system prompt 必须包含最高优先级覆盖规则。
src = _read_chat_source()
注:_build_system_prompt 已随 Phase 1 拆分迁入 chat_context.py,故扫描整个 api 包。
"""
src = _read_api_package()
assert "最高优先级覆盖规则" in src, "显式选中工具的覆盖规则文本缺失" assert "最高优先级覆盖规则" in src, "显式选中工具的覆盖规则文本缺失"
assert "只基于上文提供的该工具具体资料作答" in src, "覆盖规则应要求只基于已选工具资料作答" assert "只基于上文提供的该工具具体资料作答" in src, "覆盖规则应要求只基于已选工具资料作答"
assert "平台知识库" in src and "不要引用" in src, "覆盖规则应明确禁止引用平台知识库通用描述" assert "平台知识库" in src and "不要引用" in src, "覆盖规则应明确禁止引用平台知识库通用描述"
@@ -45,37 +51,36 @@ def test_r2_explicit_selection_override_rule_present():
async def test_r3_media_generation_timeout_returns_user_message(monkeypatch): async def test_r3_media_generation_timeout_returns_user_message(monkeypatch):
"""媒体生成超时必须回写用户可懂的超时提示,而非让前端永远『生成中…』。 """媒体生成超时必须回写用户可懂的超时提示,而非让前端永远『生成中…』。
注:_generate_media_task 是后台 fire-and-forget 任务(通过 asyncio.create_task 调用), _generate_media_task 位于 chat_media(经 _send_message_core 的 asyncio.create_task 调用),
本身返回 None,超时保护体现在把提示回写到消息 content。故此处断言副作用。 本身是 fire-and-forget、返回 None,超时保护体现在把提示回写到消息 content。
""" """
from app.api import chat as chat_mod from app.api import chat_media as media_mod
class FakeModel: class FakeModel:
id = "m_test" id = "m_test"
capabilities = ["image"] # 图像 -> 90s 超时 capabilities = ["image"] # 图像 -> 90s 超时
msg_mock = MagicMock() # 捕获被回写的消息对象 msg_mock = MagicMock() # 捕获被回写的消息对象
fake_session = MagicMock() fake_session = MagicMock()
def _query_side_effect(model_cls): def _query_side_effect(model_cls):
q = MagicMock() q = MagicMock()
if model_cls is chat_mod.AIModel: if model_cls is media_mod.AIModel:
q.filter.return_value.first.return_value = FakeModel() q.filter.return_value.first.return_value = FakeModel()
elif model_cls is chat_mod.Message: elif model_cls is media_mod.Message:
q.filter.return_value.first.return_value = msg_mock q.filter.return_value.first.return_value = msg_mock
return q return q
fake_session.query.side_effect = _query_side_effect fake_session.query.side_effect = _query_side_effect
monkeypatch.setattr(chat_mod, "SessionLocal", lambda: fake_session) monkeypatch.setattr(media_mod, "SessionLocal", lambda: fake_session)
# 内部生成调用直接抛出 TimeoutError,等价模拟「生成耗时超过超时阈值」 # 内部生成调用直接抛出 TimeoutError,等价模拟「生成耗时超过超时阈值」
async def _hang(db, ai_model, prompt, user_id): async def _hang(db, ai_model, prompt, user_id):
raise asyncio.TimeoutError() raise asyncio.TimeoutError()
monkeypatch.setattr(chat_mod, "_generate_media", _hang) monkeypatch.setattr(media_mod, "_generate_media", _hang)
await chat_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1") await media_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1")
# 副作用:消息 content 被回写为用户可懂的超时提示 # 副作用:消息 content 被回写为用户可懂的超时提示
assert msg_mock.content is not None, "超时后必须回写消息内容,不能让前端永远『生成中…』" assert msg_mock.content is not None, "超时后必须回写消息内容,不能让前端永远『生成中…』"
+35 -33
View File
@@ -1,33 +1,35 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
}, "test": "vitest run"
"keywords": [], },
"author": "", "keywords": [],
"license": "ISC", "author": "",
"dependencies": { "license": "ISC",
"@types/qrcode": "^1.5.6", "dependencies": {
"highlight.js": "^11.11.1", "@types/qrcode": "^1.5.6",
"html2canvas": "^1.4.1", "highlight.js": "^11.11.1",
"jspdf": "^4.2.1", "html2canvas": "^1.4.1",
"katex": "^0.17.0", "jspdf": "^4.2.1",
"mermaid": "^11.16.0", "katex": "^0.17.0",
"qrcode": "^1.5.4", "mermaid": "^11.16.0",
"react": "^18.3.1", "qrcode": "^1.5.4",
"react-dom": "^18.3.1", "react": "^18.3.1",
"react-router-dom": "^6.30.3" "react-dom": "^18.3.1",
}, "react-router-dom": "^6.30.3"
"devDependencies": { },
"@types/react": "^18.3.29", "devDependencies": {
"@types/react-dom": "^18.3.7", "@types/react": "^18.3.29",
"@vitejs/plugin-react": "^4.7.0", "@types/react-dom": "^18.3.7",
"typescript": "^5.9.3", "@vitejs/plugin-react": "^4.7.0",
"vite": "^5.4.21" "typescript": "^5.9.3",
} "vite": "^5.4.21",
} "vitest": "^4.1.10"
}
}
+177 -175
View File
@@ -1,175 +1,177 @@
import React, { useState, useEffect, Suspense } from 'react'; import React, { useState, useEffect, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types'; import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api'; import { authAPI } from '@/api';
// Layouts(保留为即时加载,构成应用外壳) // Layouts(保留为即时加载,构成应用外壳)
import FrontLayout from '@/components/layout/FrontLayout'; import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout'; import AdminLayout from '@/components/layout/AdminLayout';
// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖, // 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖,
// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。 // 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。
const LandingPage = React.lazy(() => import('@/pages/front/LandingPage')); const LandingPage = React.lazy(() => import('@/pages/front/LandingPage'));
const LoginPage = React.lazy(() => import('@/pages/front/LoginPage')); const LoginPage = React.lazy(() => import('@/pages/front/LoginPage'));
const ChatPage = React.lazy(() => import('@/pages/front/ChatPage')); const ChatPage = React.lazy(() => import('@/pages/front/ChatPage'));
const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage')); const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage'));
const MarketPage = React.lazy(() => import('@/pages/front/MarketPage')); const MarketPage = React.lazy(() => import('@/pages/front/MarketPage'));
const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage')); const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage'));
const PricingPage = React.lazy(() => import('@/pages/front/PricingPage')); const PricingPage = React.lazy(() => import('@/pages/front/PricingPage'));
const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage')); const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage'));
const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage')); const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage'));
const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage')); const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage'));
const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage')); const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage'));
const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard')); const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard'));
const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard')); const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard'));
const AdminUsers = React.lazy(() => import('@/pages/admin/Users')); const AdminUsers = React.lazy(() => import('@/pages/admin/Users'));
const AdminModels = React.lazy(() => import('@/pages/admin/Models')); const AdminModels = React.lazy(() => import('@/pages/admin/Models'));
const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings')); const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings'));
const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig')); const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig'));
const AdminPayment = React.lazy(() => import('@/pages/admin/Payment')); const AdminPayment = React.lazy(() => import('@/pages/admin/Payment'));
const AdminServices = React.lazy(() => import('@/pages/admin/Services')); const AdminServices = React.lazy(() => import('@/pages/admin/Services'));
const AdminMembership = React.lazy(() => import('@/pages/admin/Membership')); const AdminMembership = React.lazy(() => import('@/pages/admin/Membership'));
const AdminAds = React.lazy(() => import('@/pages/admin/Ads')); const AdminAds = React.lazy(() => import('@/pages/admin/Ads'));
const AdminMarket = React.lazy(() => import('@/pages/admin/Market')); const AdminMarket = React.lazy(() => import('@/pages/admin/Market'));
const AdminLogs = React.lazy(() => import('@/pages/admin/Logs')); const AdminLogs = React.lazy(() => import('@/pages/admin/Logs'));
const AdminSkills = React.lazy(() => import('@/pages/admin/Skills')); const AdminSkills = React.lazy(() => import('@/pages/admin/Skills'));
const AdminAgents = React.lazy(() => import('@/pages/admin/Agents')); const AdminAgents = React.lazy(() => import('@/pages/admin/Agents'));
const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec')); const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec'));
const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder')); const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder'));
const ImBots = React.lazy(() => import('@/pages/admin/ImBots')); const ImBots = React.lazy(() => import('@/pages/admin/ImBots'));
const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings')); const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings'));
const Promotion = React.lazy(() => import('@/pages/admin/Promotion')); const Promotion = React.lazy(() => import('@/pages/admin/Promotion'));
const Achievement = React.lazy(() => import('@/pages/admin/Achievement')); 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 AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge')); const Observability = React.lazy(() => import('@/pages/admin/Observability'));
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow')); const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage')); const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer')); const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools')); const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer'));
const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation')); const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools'));
const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp')); const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation'));
const AdminApps = React.lazy(() => import('@/pages/admin/Apps')); const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp'));
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter')); const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
const UserPortal = React.lazy(() => import('@/pages/UserPortal')); const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot')); const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub')); const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices')); const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
const pageFallback = <div className="loading-screen">加载中...</div>;
const pageFallback = <div className="loading-screen">加载中...</div>;
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null); const App: React.FC = () => {
const [loading, setLoading] = useState(true); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 初始化:从localStorage恢复登录状态
useEffect(() => { // 初始化:从localStorage恢复登录状态
const token = localStorage.getItem('aiflow_token'); useEffect(() => {
const cachedUser = localStorage.getItem('aiflow_user'); const token = localStorage.getItem('aiflow_token');
if (token && cachedUser) { const cachedUser = localStorage.getItem('aiflow_user');
try { if (token && cachedUser) {
setUser(JSON.parse(cachedUser)); try {
} catch { setUser(JSON.parse(cachedUser));
localStorage.removeItem('aiflow_token'); } catch {
localStorage.removeItem('aiflow_user'); localStorage.removeItem('aiflow_token');
} localStorage.removeItem('aiflow_user');
} }
setLoading(false); }
}, []); setLoading(false);
}, []);
const login = (u: User) => {
setUser(u); const login = (u: User) => {
localStorage.setItem('aiflow_user', JSON.stringify(u)); setUser(u);
}; localStorage.setItem('aiflow_user', JSON.stringify(u));
const logout = () => { };
setUser(null); const logout = () => {
localStorage.removeItem('aiflow_token'); setUser(null);
localStorage.removeItem('aiflow_user'); localStorage.removeItem('aiflow_token');
}; localStorage.removeItem('aiflow_user');
const isAdmin = user?.role === 'admin'; };
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>; if (loading) {
} return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}> return (
<Suspense fallback={pageFallback}> <AuthContext.Provider value={{ user, login, logout, isAdmin }}>
<Routes> <Suspense fallback={pageFallback}>
{/* Login */} <Routes>
<Route path="/login" element={ {/* Login */}
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage /> <Route path="/login" element={
} /> user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
} />
{/* Root - Landing for guests, redirect for authenticated users */}
<Route path="/" element={ {/* Root - Landing for guests, redirect for authenticated users */}
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage /> <Route path="/" element={
} /> user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
} />
{/* Front Routes (authenticated only) */}
<Route path="/" element={<FrontLayout />}> {/* Front Routes (authenticated only) */}
<Route path="chat" element={<ChatPage />} /> <Route path="/" element={<FrontLayout />}>
<Route path="chat/:id" element={<ChatPage />} /> <Route path="chat" element={<ChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} /> <Route path="chat/:id" element={<ChatPage />} />
<Route path="market" element={<MarketPage />} /> <Route path="knowledge" element={<KnowledgePage />} />
<Route path="token" element={<TokenDashboardPage />} /> <Route path="market" element={<MarketPage />} />
<Route path="pricing" element={<PricingPage />} /> <Route path="token" element={<TokenDashboardPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="pricing" element={<PricingPage />} />
<Route path="notifications" element={<NotificationPage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="assets" element={<AssetsPage />} /> <Route path="notifications" element={<NotificationPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} /> <Route path="assets" element={<AssetsPage />} />
<Route path="evolution" element={<EvolutionDashboard />} /> <Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="wechat-bot" element={<WechatBot />} /> <Route path="evolution" element={<EvolutionDashboard />} />
<Route path="im-bots" element={<ImBotHub />} /> <Route path="wechat-bot" element={<WechatBot />} />
<Route path="app-services" element={<AppServices />} /> <Route path="im-bots" element={<ImBotHub />} />
</Route> <Route path="app-services" element={<AppServices />} />
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}> {/* Admin Routes */}
<Route index element={<AdminDashboard />} /> <Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<AdminUsers />} /> <Route index element={<AdminDashboard />} />
<Route path="models" element={<AdminModels />} /> <Route path="users" element={<AdminUsers />} />
<Route path="settings" element={<AdminSiteSettings />} /> <Route path="models" element={<AdminModels />} />
<Route path="sms" element={<AdminSmsConfig />} /> <Route path="settings" element={<AdminSiteSettings />} />
<Route path="payment" element={<AdminPayment />} /> <Route path="sms" element={<AdminSmsConfig />} />
<Route path="services" element={<AdminServices />} /> <Route path="payment" element={<AdminPayment />} />
<Route path="membership" element={<AdminMembership />} /> <Route path="services" element={<AdminServices />} />
<Route path="ads" element={<AdminAds />} /> <Route path="membership" element={<AdminMembership />} />
<Route path="market" element={<AdminMarket />} /> <Route path="ads" element={<AdminAds />} />
<Route path="logs" element={<AdminLogs />} /> <Route path="market" element={<AdminMarket />} />
<Route path="skills" element={<AdminSkills />} /> <Route path="logs" element={<AdminLogs />} />
<Route path="agents" element={<AdminAgents />} /> <Route path="skills" element={<AdminSkills />} />
<Route path="code-exec" element={<AdminCodeExec />} /> <Route path="agents" element={<AdminAgents />} />
<Route path="distributions" element={<DistributionBuilder />} /> <Route path="code-exec" element={<AdminCodeExec />} />
<Route path="im-bots" element={<ImBots />} /> <Route path="distributions" element={<DistributionBuilder />} />
<Route path="privacy" element={<PrivacySettings />} /> <Route path="im-bots" element={<ImBots />} />
<Route path="promotion" element={<Promotion />} /> <Route path="privacy" element={<PrivacySettings />} />
<Route path="achievement" element={<Achievement />} /> <Route path="promotion" element={<Promotion />} />
<Route path="tenant" element={<Tenant />} /> <Route path="achievement" element={<Achievement />} />
<Route path="billing" element={<Billing />} /> <Route path="tenant" element={<Tenant />} />
<Route path="analytics" element={<Analytics />} /> <Route path="billing" element={<Billing />} />
<Route path="knowledge" element={<AdminKnowledge />} /> <Route path="analytics" element={<Analytics />} />
<Route path="workflow" element={<AdminWorkflow />} /> <Route path="observability" element={<Observability />} />
<Route path="storage" element={<AdminStorage />} /> <Route path="knowledge" element={<AdminKnowledge />} />
<Route path="developer" element={<AdminDeveloper />} /> <Route path="workflow" element={<AdminWorkflow />} />
<Route path="developer-tools" element={<AdminDeveloperTools />} /> <Route path="storage" element={<AdminStorage />} />
<Route path="automation" element={<AdminAutomation />} /> <Route path="developer" element={<AdminDeveloper />} />
<Route path="mcp" element={<AdminMcp />} /> <Route path="developer-tools" element={<AdminDeveloperTools />} />
<Route path="apps" element={<AdminApps />} /> <Route path="automation" element={<AdminAutomation />} />
<Route path="assets" element={<AssetCenter />} /> <Route path="mcp" element={<AdminMcp />} />
<Route path="portal" element={<UserPortal />} /> <Route path="apps" element={<AdminApps />} />
</Route> <Route path="assets" element={<AssetCenter />} />
<Route path="portal" element={<UserPortal />} />
{/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */} </Route>
<Route path="/home" element={<LandingPage />} />
<Route path="*" element={<Navigate to="/" />} /> {/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */}
</Routes> <Route path="/home" element={<LandingPage />} />
</Suspense> <Route path="*" element={<Navigate to="/" />} />
</AuthContext.Provider> </Routes>
); </Suspense>
}; </AuthContext.Provider>
);
export default App; };
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 React, { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css'; import './AdminLayout.css';
interface NavItem { interface NavItem {
path: string; path: string;
icon: string; icon: string;
label: string; label: string;
exact?: boolean; exact?: boolean;
} }
interface NavGroup { interface NavGroup {
title: string; title: string;
icon: string; icon: string;
items: NavItem[]; items: NavItem[];
} }
const adminNavGroups: NavGroup[] = [ const adminNavGroups: NavGroup[] = [
{ {
title: '数据驾驶舱', title: '数据驾驶舱',
icon: 'dashboard', icon: 'dashboard',
items: [ items: [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
], ],
}, },
{ {
title: 'AI技能中心', title: 'AI技能中心',
icon: 'agents', icon: 'agents',
items: [ items: [
{ path: '/admin/models', icon: 'models', label: '大模型库' }, { path: '/admin/models', icon: 'models', label: '大模型库' },
{ path: '/admin/agents', icon: 'agents', label: 'Agent 管理' }, { path: '/admin/agents', icon: 'agents', label: 'Agent 管理' },
{ path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' }, { path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' }, { path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' },
{ path: '/admin/market', icon: 'market', label: 'AI 技能审核' }, { path: '/admin/market', icon: 'market', label: 'AI 技能审核' },
], ],
}, },
{ {
title: '平台运营', title: '平台运营',
icon: 'users', icon: 'users',
items: [ items: [
{ path: '/admin/users', icon: 'users', label: '用户管理' }, { path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员管理' }, { path: '/admin/membership', icon: 'membership', label: '会员管理' },
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' }, { path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' }, { path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
], ],
}, },
{ {
title: '商业变现', title: '商业变现',
icon: 'billing', icon: 'billing',
items: [ items: [
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' }, { path: '/admin/billing', icon: 'billing', label: '计费与支付' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道' }, { path: '/admin/payment', icon: 'payment', label: '支付渠道' },
{ path: '/admin/services', icon: 'services', label: '增值服务' }, { path: '/admin/services', icon: 'services', label: '增值服务' },
{ path: '/admin/ads', icon: 'ads', label: '广告管理' }, { path: '/admin/ads', icon: 'ads', label: '广告管理' },
{ path: '/admin/assets', icon: 'assets', label: '资产管理' }, { path: '/admin/assets', icon: 'assets', label: '资产管理' },
], ],
}, },
{ {
title: '系统管理', title: '系统管理',
icon: 'settings', icon: 'settings',
items: [ items: [
{ path: '/admin/settings', icon: 'settings', label: '网站设置' }, { path: '/admin/settings', icon: 'settings', label: '网站设置' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' }, { path: '/admin/logs', icon: 'logs', label: '日志与风控' },
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/sms', icon: 'sms', label: '短信配置' }, { path: '/admin/sms', icon: 'sms', label: '短信配置' },
{ path: '/admin/storage', icon: 'storage', label: '存储配置' }, { path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' }, { path: '/admin/automation', icon: 'automation', label: '自动化任务' },
], ],
}, },
{ {
title: '待处理', title: '待处理',
icon: 'developer', icon: 'developer',
items: [ items: [
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' }, { path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, { path: '/admin/observability', icon: 'observability', label: '调用可观测' },
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' }, { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, { path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' }, { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, { path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' }, { path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' }, { path: '/admin/developer', icon: 'developer', label: '开发者 API' },
], { path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
}, ],
},
];
];
const adminIconPaths: Record<string, JSX.Element> = {
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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>,
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>, 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 AdminLayout: React.FC = () => {
const navigate = useNavigate(); const { user, logout } = useAuth();
const location = useLocation(); const navigate = useNavigate();
// 后台必须登录且为管理员 const location = useLocation();
useEffect(() => {
const token = localStorage.getItem('aiflow_token'); // 后台必须登录且为管理员
if (!token || !user || user.role !== 'admin') { useEffect(() => {
logout(); const token = localStorage.getItem('aiflow_token');
navigate('/login', { replace: true }); if (!token || !user || user.role !== 'admin') {
} logout();
}, [user, logout, navigate]); navigate('/login', { replace: true });
}
const navCrumb = (() => { }, [user, logout, navigate]);
for (const g of adminNavGroups) { const navCrumb = (() => {
for (const it of g.items) { for (const g of adminNavGroups) {
const match = it.exact for (const it of g.items) {
? location.pathname === it.path const match = it.exact
: location.pathname === it.path || location.pathname.startsWith(it.path + '/'); ? location.pathname === it.path
if (match) return { group: g.title, label: it.label }; : location.pathname === it.path || location.pathname.startsWith(it.path + '/');
} if (match) return { group: g.title, label: it.label };
} }
return { group: '', label: '' }; }
})(); return { group: '', label: '' };
const navGroup = navCrumb.group; })();
const navLabel = navCrumb.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)); // 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰)
}); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
return new Set(adminNavGroups.map((g) => g.title));
});
const toggleGroup = (title: string) => {
setExpandedGroups(prev => { const toggleGroup = (title: string) => {
const next = new Set(prev); setExpandedGroups(prev => {
if (next.has(title)) next.delete(title); const next = new Set(prev);
else next.add(title); if (next.has(title)) next.delete(title);
return next; else next.add(title);
}); return next;
}; });
};
const handleLogout = () => {
logout(); const handleLogout = () => {
navigate('/'); logout();
}; navigate('/');
};
const handleVisitFrontend = () => {
const url = window.location.origin + '/chat'; const handleVisitFrontend = () => {
window.open(url, '_blank'); const url = window.location.origin + '/chat';
}; window.open(url, '_blank');
};
return (
<div className="admin-layout"> return (
<aside className="admin-sidebar"> <div className="admin-layout">
<div className="admin-sidebar-header"> <aside className="admin-sidebar">
<h1 className="admin-logo">七虎AI</h1> <div className="admin-sidebar-header">
<span className="admin-subtitle">管理控制台</span> <h1 className="admin-logo">七虎AI</h1>
</div> <span className="admin-subtitle">管理控制台</span>
<button onClick={handleVisitFrontend} className="admin-frontend-link"> </div>
<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> <button onClick={handleVisitFrontend} className="admin-frontend-link">
<span>访问前台</span> <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>
</button> <span>访问前台</span>
<nav className="sidebar-nav"> </button>
{adminNavGroups.map((group) => { <nav className="sidebar-nav">
const isExpanded = expandedGroups.has(group.title); {adminNavGroups.map((group) => {
const hasActiveChild = group.items.some(item => const isExpanded = expandedGroups.has(group.title);
item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path) 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' : ''}`}> return (
<div <div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`} <div
onClick={() => toggleGroup(group.title)} className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
> onClick={() => toggleGroup(group.title)}
<span className="nav-group-chevron-wrap"> >
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}> <span className="nav-group-chevron-wrap">
{adminIconPaths.chevron} <span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
</span> {adminIconPaths.chevron}
</span> </span>
<span className="nav-group-title">{group.title}</span> </span>
</div> <span className="nav-group-title">{group.title}</span>
{isExpanded && ( </div>
<div className="nav-group-items"> {isExpanded && (
{group.items.map((item) => ( <div className="nav-group-items">
<NavLink {group.items.map((item) => (
key={item.path} <NavLink
to={item.path} key={item.path}
end={item.exact} to={item.path}
className={({ isActive }) => end={item.exact}
`nav-item ${isActive ? 'nav-item-active' : ''}` 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> <span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
</NavLink> <span className="nav-item-label">{item.label}</span>
))} </NavLink>
</div> ))}
)} </div>
</div> )}
); </div>
})} );
</nav> })}
<div className="admin-sidebar-footer"> </nav>
<div className="admin-user-info"> <div className="admin-sidebar-footer">
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div> <div className="admin-user-info">
<div className="admin-user-detail"> <div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<div className="admin-user-name">{user?.username || 'admin'}</div> <div className="admin-user-detail">
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button> <div className="admin-user-name">{user?.username || 'admin'}</div>
</div> <button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
</div> </div>
</div> </div>
</aside> </div>
<main className="admin-content"> </aside>
<header className="admin-topbar"> <main className="admin-content">
<nav className="admin-breadcrumb" aria-label="breadcrumb"> <header className="admin-topbar">
<span className="crumb-root">管理控制台</span> <nav className="admin-breadcrumb" aria-label="breadcrumb">
{navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)} <span className="crumb-root">管理控制台</span>
{navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)} {navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)}
</nav> {navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)}
<div className="admin-topbar-user"> </nav>
<div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div> <div className="admin-topbar-user">
<span className="admin-topbar-username">{user?.username || 'admin'}</span> <div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
</div> <span className="admin-topbar-username">{user?.username || 'admin'}</span>
</header> </div>
<Outlet /> </header>
</main> <Outlet />
</div> </main>
); </div>
}; );
};
export default AdminLayout;
export default AdminLayout;
+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' };
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { formatBjTime } from './datetime';
// 北京时间渲染曾是反复出现的问题(微信Bot时间错乱、Token驾驶舱时间列不正确等)。
// 该测试固化「后端按北京时间存储、前端显式按 Asia/Shanghai 渲染」的契约。
describe('formatBjTime', () => {
it('空输入返回占位符 -', () => {
expect(formatBjTime(undefined)).toBe('-');
expect(formatBjTime(null)).toBe('-');
expect(formatBjTime('')).toBe('-');
});
it('无时区标记的时间戳按 Asia/Shanghai (+08:00) 渲染', () => {
// 2026-07-18T12:00:00 视为北京时间,渲染应含 12:00:00
const out = formatBjTime('2026-07-18T12:00:00');
expect(out).toContain('2026');
expect(out).toContain('12:00:00');
});
it('UTC(Z) 时间戳正确转换为北京时间 (+8)', () => {
// 2026-07-18T04:00:00Z = 北京时间 12:00:00
const out = formatBjTime('2026-07-18T04:00:00Z');
expect(out).toContain('12:00:00');
});
it('非法日期原样返回', () => {
expect(formatBjTime('not-a-date')).toBe('not-a-date');
});
});
+7 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'vite'; /// <reference types="vitest/config" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
@@ -17,8 +18,6 @@ export default defineConfig({
chunkSizeWarningLimit: 1200, chunkSizeWarningLimit: 1200,
rollupOptions: { rollupOptions: {
output: { output: {
// 仅将 react 核心库单独打包(长期缓存);
// 其余第三方库交给 Rollup 自动按需拆分,避免被强制合并成一个大 vendor 在首屏加载
manualChunks(id) { manualChunks(id) {
if (id.includes('node_modules')) { if (id.includes('node_modules')) {
if ( if (
@@ -37,4 +36,9 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
}, },
// Phase 0 测试基建:vitest 配置(纯函数测试用 node 环境,组件测试可改 jsdom)
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
}); });
@@ -0,0 +1,7 @@
-- Phase 2.2: add trace_id to token_usage_logs for cost attribution (idempotent)
SET @db = DATABASE();
SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @db AND table_name = 'token_usage_logs' AND column_name = 'trace_id');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE token_usage_logs ADD COLUMN trace_id VARCHAR(64) NULL DEFAULT NULL', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -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;
+115
View File
@@ -0,0 +1,115 @@
"""Minimal CI gate for qihuaiflow.
This is the canonical, single-source-of-truth gate. It runs the two test
suites that guard production behavior, INSIDE the long-lived qihu-aiflow
container (our deployment model keeps the app running in a container, not
built fresh in CI):
1. backend pytest -> /app/backend/python/.venv/bin/python -m pytest -c pytest.ini
2. frontend vitest -> /app/frontend && npx vitest run
Exit codes:
0 all suites green -> deploy/push may proceed
1 a test suite failed -> BLOCK (do not deploy/push)
2 environment error -> docker missing or container not running
It auto-detects where it runs:
* If the qihu-aiflow container is reachable via LOCAL docker (i.e. on the
deploy host), it execs directly.
* Otherwise it falls back to ssh_helper (remote control from a laptop),
which reaches the host over SSH and execs into the container there.
Usage:
python scripts/ci_gate.py
"""
import os
import subprocess
import sys
CONTAINER = "qihu-aiflow"
BACKEND_CMD = "cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q"
FRONTEND_CMD = "cd /app/frontend && npx vitest run --reporter=dot"
def shell_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def local_runner(cmd: str):
"""Run via local docker (deploy host)."""
p = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return p.stdout, p.stderr, p.returncode
def get_runner():
# Prefer local docker ONLY if the actual container is reachable here
# (avoids a Windows-docker edge case where `docker ps` works but the
# qihu-aiflow container lives on a different host).
try:
r = subprocess.run(
"docker ps --format '{{.Names}}'",
shell=True, capture_output=True, text=True, timeout=10,
)
if r.returncode == 0 and CONTAINER in r.stdout.split():
return local_runner
except Exception:
pass
# Fall back to SSH control of the real host.
try:
import ssh_helper as s # type: ignore
return lambda cmd: s.run(cmd)
except Exception as e: # pragma: no cover
print(f"[CI Gate] ERROR: docker unavailable locally and ssh_helper missing: {e}",
file=sys.stderr)
sys.exit(2)
def run_in_container(runner, cmd: str):
return runner(f"docker exec {CONTAINER} bash -c {shell_quote(cmd)}")
def container_up(runner) -> bool:
out, _, _ = runner(f"docker ps --format {shell_quote('{{.Names}}')}")
return CONTAINER in out.split()
def main():
runner = get_runner()
if not container_up(runner):
print(f"[CI Gate] ERROR: container {CONTAINER} is not running", file=sys.stderr)
sys.exit(2)
failures = []
print("=== [CI Gate] backend pytest ===")
o, e, rc = run_in_container(runner, BACKEND_CMD)
sys.stdout.write(o)
if e.strip():
print("STDERR:", e.strip(), file=sys.stderr)
if rc != 0:
failures.append("backend pytest")
print(f"[CI Gate] backend pytest FAILED (rc={rc})", file=sys.stderr)
else:
print("[CI Gate] backend pytest OK")
print("=== [CI Gate] frontend vitest ===")
o, e, rc = run_in_container(runner, FRONTEND_CMD)
sys.stdout.write(o)
if e.strip():
print("STDERR:", e.strip(), file=sys.stderr)
if rc != 0:
failures.append("frontend vitest")
print(f"[CI Gate] frontend vitest FAILED (rc={rc})", file=sys.stderr)
else:
print("[CI Gate] frontend vitest OK")
if failures:
print(f"\n[CI Gate] BLOCKED - failed: {', '.join(failures)}", file=sys.stderr)
sys.exit(1)
print("\n[CI Gate] PASSED - all suites green")
sys.exit(0)
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# qihuaiflow minimal CI gate - git pre-push hook.
#
# Runs backend pytest + frontend vitest INSIDE the long-lived qihu-aiflow
# container before any `git push` leaves this host. If a suite fails the
# push is blocked (exit non-zero), so a broken tree can never be published.
#
# Install (on the deploy host, inside the repo):
# cp scripts/git-hooks/pre-push .git/hooks/pre-push
# chmod +x .git/hooks/pre-push
#
# This is a self-contained bash gate (no python dependency on the host).
# The canonical, documented version lives in scripts/ci_gate.py.
set -uo pipefail
CONTAINER="qihu-aiflow"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "[CI Gate] ERROR: container $CONTAINER is not running - aborting push" >&2
exit 2
fi
echo "=== [CI Gate] backend pytest ==="
docker exec "$CONTAINER" bash -c 'cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q'
backend_rc=$?
if [ "$backend_rc" -ne 0 ]; then
echo "[CI Gate] backend pytest FAILED (rc=$backend_rc) - push blocked" >&2
exit 1
fi
echo "[CI Gate] backend pytest OK"
echo "=== [CI Gate] frontend vitest ==="
docker exec "$CONTAINER" bash -c 'cd /app/frontend && npx vitest run --reporter=dot'
frontend_rc=$?
if [ "$frontend_rc" -ne 0 ]; then
echo "[CI Gate] frontend vitest FAILED (rc=$frontend_rc) - push blocked" >&2
exit 1
fi
echo "[CI Gate] frontend vitest OK"
echo "[CI Gate] PASSED - all suites green - push allowed"
exit 0