Compare commits

..
14 changed files with 8168 additions and 7678 deletions

No files matched your search

+1 -43
View File
@@ -1,43 +1 @@
# 七虎AI PaaS 平台 — 版本清单 (VERSION) v2026.07.19.5
# 版本规范见 docs/VERSIONING.md
# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始)
# 环境标签: prod = 正式版 | test = 测试版
VERSION=v2026.07.19.3
ENV=prod
DATE=2026-07-19
BASE_IMAGE=qihu-aiflow:with-deps
BACKUP_IMAGE=qihu-aiflow:backup-20260719-003
## 本版本变更 (v2026.07.19.3 / prod)
- Phase2.2 成本归因:token_usage_logs 新增 trace_id 列(迁移 020_add_trace_id_token_usage_logs.sql,幂等 ALTER),打通「链路 -> 成本」归集
- _write_media_token_log(媒体图片/视频)注入 get_trace_id(),每条媒体 TokenUsageLog 现可回溯到请求链路
- LogService.log_token_usage 增加 trace_id 参数(为后续文本 LLM token 记账接通成本归因预留)
- chat_service / chat_media 在 LLM 补全、MCP 工具拉取/调用、技能执行、AGNES 媒体生成处加 span 子链路(async with span),一次请求可下钻耗时分布
- 注意:_generate_media_task 为后台 fire-and-forget,其 span 会因处于 asyncio.create_task 表达式内而非法,已移除;真实媒体耗时由 chat_media 内 media.agnes_call span 覆盖
- 纯增量埋点,不改接口返回结构与业务行为;pytest 9 passed + vitest 4 passed 门禁绿
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-003
## 本版本变更 (v2026.07.19.1 / prod)
- Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成)
- 路由处理器(/conversations、/messages、/media/relay、/models 等全部 10 个路由)保留在 chat.py,业务/服务函数迁至子模块并通过 import 再导出
- 关键修复:此前拆分误删了全部 @router 装饰器导致路由为 0(重启即全 404),本版本以 AST 比对原始提交恢复装饰器,并补回 chat_service._send_message_core 导入
- 测试安全网守住 R1/R2/R3 不回退:后端 9 passed(smoke/market/sms + R1/R2/R3),前端 4 passed(datetime 时区格式化);ci_gate 全绿
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-001(旧 chat.py 10070 行)
## 本版本变更 (v2026.07.19.2 / prod)
- Phase2.1 统一可观测基座:新增 app/core/observability.py(trace_id contextvar + TraceIdFilter + span 计时器 + 可选 JSON formatter),零侵入为全平台日志注入链路 ID
- main.py APITrackingMiddleware 复用既有 request_id 作为 trace_id,并为每个响应注入 X-Trace-Id 响应头(前端可关联排查)
- config.py setup_file_logging 接入 TraceIdFilter 并将日志格式升级为 [tid:%(trace_id)s],所有 getLogger(__name__) 日志自动带链路 ID
- chat.py send_message 路由用 span 包裹 _send_message_core(耗时+trace_id+conv_id/user_id),覆盖 LLM/MCP/媒体生成整体耗时,为后续成本归因建立回归基线
- 纯增量埋点,不改任何接口返回结构与业务行为;向后兼容既有 logging 用法
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-002
## 历史
- v2026.07.18.5 (prod):Phase0 收官 — 最小 CI 门禁(ci_gate.py + pre-push 钩子 + 休眠 Gitea workflow)。后端 9 passed,前端 4 passed。
- v2026.07.18.4 (prod):前端 vitest 基建落地(4 passed,datetime 时区格式化)。
- v2026.07.18.3 (prod):回归用例 R1/R2/R3(9 passed)— 微信Bot截断、技能/MCP覆盖规则、媒体生成超时回写。
- v2026.07.18.2 (prod):后端 pytest 基建落地(6 passed)— 冒烟/市场14类/短信宝MD5。
- v2026.07.18.1 (prod):Phase0 启动 — 部署前镜像备份 SOP + 版本号规范 + 引入测试基建。
- 旧方案:镜像曾用 AIFlow v2.0.1 / v2026.07.14 / v2026.07.14-nav;git 曾用 v2026.07.12-recover / v2026.07.12-ui-recover / v2026.07.13-recover。自 v2026.07.18.1 起统一为 vYYYY.MM.DD.N + prod/test,便于正式版推广后与测试版做环境切割。
- 历史功能:AI聊天(real API + tool-call function calling)、知识库文件存储 + AI 图谱、频道二维码、邮件/短信配置 + AI 生成、支付配置、平台驾驶舱(token 累计 + 审计/操作日志)、Agent 资源 API、evolution pipeline 自动恢复、全局默认模型、广告位图片上传 + AI 绘图、应用市场安装/卸载/搜索/去重、资产文件类型分类 + 在线预览。迁移 001-015。
@@ -0,0 +1,103 @@
"""调用可观测面板后端 API(媒体/LLM 调用归因)。
路由前缀: /app/api/admin/observability
数据来源: token_usage_logs(Phase 2.2 已加 trace_id 列,可经 X-Trace-Id 关联请求链路)。
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, Depends
from ...database import get_db
from ..auth import get_current_user
from sqlalchemy import text
from sqlalchemy.orm import Session
router = APIRouter()
@router.get("/summary")
async def observability_summary(
days: int = Query(30, description="统计天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""媒体/LLM 调用归因:总览 + 按模型 + 按用户 + 每日趋势 + 最近调用(trace_id 下钻)。"""
since = datetime.now() - timedelta(days=days)
totals = db.execute(text("""
SELECT COUNT(*),
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(cost), 0),
COUNT(DISTINCT user_id)
FROM token_usage_logs WHERE created_at >= :since
"""), {"since": since}).fetchone()
by_model = db.execute(text("""
SELECT model_name, COUNT(*), COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cost), 0)
FROM token_usage_logs WHERE created_at >= :since
GROUP BY model_name ORDER BY COALESCE(SUM(total_tokens), 0) DESC
"""), {"since": since}).fetchall()
by_user = db.execute(text("""
SELECT t.user_id, u.username, COUNT(*), COALESCE(SUM(t.total_tokens), 0), COALESCE(SUM(t.cost), 0)
FROM token_usage_logs t LEFT JOIN users u ON u.id = t.user_id
WHERE t.created_at >= :since
GROUP BY t.user_id, u.username ORDER BY COALESCE(SUM(t.total_tokens), 0) DESC LIMIT 20
"""), {"since": since}).fetchall()
daily = db.execute(text("""
SELECT DATE(created_at) AS d, COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cost), 0), COUNT(*)
FROM token_usage_logs WHERE created_at >= :since
GROUP BY DATE(created_at) ORDER BY d
"""), {"since": since}).fetchall()
recent = db.execute(text("""
SELECT trace_id, model_name, total_tokens, cost, created_at
FROM token_usage_logs WHERE created_at >= :since
ORDER BY created_at DESC LIMIT 50
"""), {"since": since}).fetchall()
return {
"totals": {
"calls": int(totals[0] or 0),
"tokens": int(totals[1] or 0),
"cost": float(totals[2] or 0),
"users": int(totals[3] or 0),
},
"by_model": [
{
"model_name": m[0] or "unknown",
"calls": int(m[1] or 0),
"tokens": int(m[2] or 0),
"cost": float(m[3] or 0),
}
for m in by_model
],
"by_user": [
{
"user_id": u[0],
"username": u[1] or u[0],
"calls": int(u[2] or 0),
"tokens": int(u[3] or 0),
"cost": float(u[4] or 0),
}
for u in by_user
],
"daily": [
{
"date": str(d[0]),
"tokens": int(d[1] or 0),
"cost": float(d[2] or 0),
"calls": int(d[3] or 0),
}
for d in daily
],
"recent": [
{
"trace_id": r[0],
"model_name": r[1] or "unknown",
"tokens": int(r[2] or 0),
"cost": float(r[3] or 0),
"created_at": r[4].strftime("%Y-%m-%d %H:%M:%S") if r[4] else None,
}
for r in recent
],
}
File diff suppressed because it is too large. Load diff
+161 -1
View File
@@ -20,7 +20,7 @@ from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary,
@@ -1230,3 +1230,163 @@ def _get_conversation_context(db: Session, conv_id: str) -> dict:
return context return context
# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 =====
logger = logging.getLogger(__name__)
def _estimate_tokens(text):
"""粗略 token 估算:中英文混排约 2 字符/token。"""
if not text:
return 0
return max(1, len(text) // 2)
def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
exclude_latest_user=True, include_summary=True):
"""会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。
- 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
"""
summary_block = None
if include_summary:
try:
summ = db.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id
).first()
if summ and summ.summary_text:
summary_block = {
"role": "system",
"content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text,
}
except Exception:
pass
rows = db.query(Message).filter(
Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]),
).order_by(desc(Message.created_at)).limit(max_messages).all()
if exclude_latest_user:
for i, m in enumerate(rows):
if m.role == "user":
rows = rows[:i] + rows[i + 1:]
break
kept = []
used = 0
for m in rows: # 新->旧
t = _estimate_tokens(m.content or "")
if kept and used + t > max_tokens:
break
kept.append({"role": m.role, "content": m.content})
used += t
kept.reverse() # 时间正序
out = []
if summary_block:
out.append(summary_block)
out.extend(kept)
return out
async def _summarize_via_llm(prompt):
"""调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。"""
try:
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import _proxy_for_url
sdb = SessionLocal()
try:
ai_model = resolve_auto_model("text", sdb)
if not ai_model:
return None
provider = sdb.query(ModelProvider).filter(
ModelProvider.id == ai_model.provider_id
).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
if not provider or not provider.api_base_url or not api_key:
return None
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
body = {
"model": ai_model.name,
"messages": [
{"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"},
{"role": "user", "content": prompt},
],
"max_tokens": 512,
"temperature": 0.2,
}
async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client:
resp = await client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=body,
)
if resp.status_code != 200:
return None
data = resp.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
finally:
sdb.close()
except Exception as e:
logger.warning(f"[context] summarize llm failed: {e}")
return None
async def _maybe_compress_history(conv_id):
"""后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
"""
sdb = None
try:
sdb = SessionLocal()
all_msgs = sdb.query(Message).filter(
Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]),
).order_by(asc(Message.created_at)).all()
PROTECTED = 20
if len(all_msgs) <= PROTECTED:
return
compressible = all_msgs[: len(all_msgs) - PROTECTED]
if len(compressible) < 8:
return
chunk = compressible[:30]
text = "\n".join(f"{m.role}: {m.content}" for m in chunk)
existing = sdb.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id
).first()
prior = existing.summary_text if existing else ""
prompt = (
"请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。"
"若提供『已有摘要』,请合并而非重复。\n\n"
f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}"
)
summary = await _summarize_via_llm(prompt)
if not summary:
return
if existing:
existing.summary_text = summary
existing.token_count = _estimate_tokens(summary)
existing.updated_at = datetime.now()
else:
sdb.add(ConversationSummary(
conversation_id=conv_id,
summary_text=summary,
token_count=_estimate_tokens(summary),
updated_at=datetime.now(),
))
sdb.commit()
except Exception as e:
logger.warning(f"[context] compress history failed conv={conv_id}: {e}")
if sdb is not None:
try:
sdb.rollback()
except Exception:
pass
finally:
if sdb is not None:
try:
sdb.close()
except Exception:
pass
File diff suppressed because it is too large. Load diff
+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})"
+1136 -1127
View File
File diff suppressed because it is too large. Load diff
+250 -249
View File
@@ -1,249 +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.core.observability import set_trace_id from app.core.observability import set_trace_id
from app.database import SessionLocal from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) # 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转) # 初始化文件日志(按天轮转)
access_logger = setup_file_logging() access_logger = setup_file_logging()
logger = logging.getLogger("aiflow") logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware): class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求""" """API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
set_trace_id(request_id) set_trace_id(request_id)
start_time = time.time() start_time = time.time()
path = request.url.path path = request.url.path
method = request.method method = request.method
# 跳过健康检查和静态资源 # 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"] skip_paths = ["/app/api/health", "/favicon.ico"]
should_skip = any(path.startswith(p) for p in skip_paths) should_skip = any(path.startswith(p) for p in skip_paths)
try: try:
response = await call_next(request) response = await call_next(request)
elapsed = time.time() - start_time elapsed = time.time() - start_time
status_code = response.status_code status_code = response.status_code
response.headers["X-Trace-Id"] = request_id response.headers["X-Trace-Id"] = request_id
if not should_skip: if not should_skip:
access_logger.info( access_logger.info(
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
) )
# 异步记录API调用统计 # 异步记录API调用统计
try: try:
db = SessionLocal() db = SessionLocal()
try: try:
_record_api_call(db, method, path, status_code, elapsed) _record_api_call(db, method, path, status_code, elapsed)
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
logger.warning(f"API统计记录失败: {e}") logger.warning(f"API统计记录失败: {e}")
return response return response
except Exception as e: except Exception as e:
elapsed = time.time() - start_time elapsed = time.time() - start_time
if not should_skip: if not should_skip:
access_logger.info( access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
) )
raise raise
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
if scope["type"] != "http": if scope["type"] != "http":
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
await super().__call__(scope, receive, send) await super().__call__(scope, receive, send)
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
"""记录API调用到数据库统计表""" """记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID) # 简化路径以减少基数(去掉动态ID)
parts = path.split("/") parts = path.split("/")
simplified_parts = [] simplified_parts = []
for part in parts: for part in parts:
# 将UUID样式的路径段替换为{id} # 将UUID样式的路径段替换为{id}
if len(part) == 36 and part.count("-") == 4: if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}") simplified_parts.append("{id}")
# 将纯数字路径段替换为{id} # 将纯数字路径段替换为{id}
elif part.isdigit(): elif part.isdigit():
simplified_parts.append("{id}") simplified_parts.append("{id}")
else: else:
simplified_parts.append(part) simplified_parts.append(part)
simplified_path = "/".join(simplified_parts) simplified_path = "/".join(simplified_parts)
# 统计记录通过数据库实现,简化版记录到日志 # 统计记录通过数据库实现,简化版记录到日志
is_error = status_code >= 400 is_error = status_code >= 400
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
if is_slow: if is_slow:
logger.warning( logger.warning(
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
) )
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...") logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try: try:
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager
from app.models import UserEvolutionConfig from app.models import UserEvolutionConfig
db = SessionLocal() db = SessionLocal()
try: try:
configs = db.query(UserEvolutionConfig).filter( configs = db.query(UserEvolutionConfig).filter(
UserEvolutionConfig.watcher_status == "running" UserEvolutionConfig.watcher_status == "running"
).all() ).all()
if configs: if configs:
watcher = EvolutionWatcherManager() watcher = EvolutionWatcherManager()
for cfg in configs: for cfg in configs:
try: try:
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
except Exception as e: except Exception as e:
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
else: else:
logger.info("[Evolution] 无待恢复的进化配置") logger.info("[Evolution] 无待恢复的进化配置")
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
# 启动Redis事件总线 # 启动Redis事件总线
try: try:
from app.event_bus import get_event_bus from app.event_bus import get_event_bus
event_bus = get_event_bus() event_bus = get_event_bus()
event_bus.start(num_consumers=2) event_bus.start(num_consumers=2)
logger.info("[EventBus] Redis事件总线已启动") logger.info("[EventBus] Redis事件总线已启动")
except Exception as e: except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成") logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield yield
# 停止Redis事件总线 # 停止Redis事件总线
try: try:
from app.event_bus import get_event_bus from app.event_bus import get_event_bus
get_event_bus().stop() get_event_bus().stop()
logger.info("[EventBus] Redis事件总线已停止") logger.info("[EventBus] Redis事件总线已停止")
except Exception: except Exception:
pass pass
# 优雅关闭:停止所有 Evolution Pipeline # 优雅关闭:停止所有 Evolution Pipeline
try: try:
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager
EvolutionWatcherManager().shutdown_all() EvolutionWatcherManager().shutdown_all()
logger.info("[Evolution] 所有Pipeline已停止") logger.info("[Evolution] 所有Pipeline已停止")
except Exception: except Exception:
pass pass
logger.info("Qihu-AIFlow 正在关闭...") logger.info("Qihu-AIFlow 正在关闭...")
app = FastAPI( app = FastAPI(
title="Qihu-AIFlow - 智能自动化PaaS平台", title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
# API调用追踪中间件 # API调用追踪中间件
app.add_middleware(APITrackingMiddleware) app.add_middleware(APITrackingMiddleware)
# CORS配置 # CORS配置
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# 前台 API # 前台 API
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
# 后台 API # 后台 API
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", 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(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"]) app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"]) app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"]) app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"]) app.include_router(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"]) app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"]) app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
@app.get("/app/api/health")
async def health_check(): @app.get("/app/api/health")
return {"status": "ok", "service": "Qihu-AIFlow"} async def health_check():
return {"status": "ok", "service": "Qihu-AIFlow"}
+114
View File
@@ -0,0 +1,114 @@
"""Phase 2 context-engine regression: _build_chat_history budget/layering/summary.
Pure unit test with a fake DB session — no network, no real model. Guards the
layered-memory + compression + isolation behaviour against regressions.
"""
from app.api.chat_context import _estimate_tokens, _build_chat_history
class _Msg:
def __init__(self, role, content, cid="c1"):
self.role = role
self.content = content
self.conversation_id = cid
class _Summary:
def __init__(self, text):
self.summary_text = text
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
# test pre-sorts newest-first to match desc(created_at)
return self
def limit(self, n=None):
return self
def all(self):
return self._rows
def first(self):
return self._rows[0] if self._rows else None
class _DB:
def __init__(self, messages, summary=None):
self._messages = messages
self._summary = summary
def query(self, model):
if getattr(model, "__name__", "") == "ConversationSummary":
return _Query([self._summary] if self._summary else [])
return _Query(self._messages)
def _newest_first(msgs):
# _build_chat_history expects desc(created_at) -> newest first.
# Test authors write the list already newest-first, so return as-is.
return list(msgs)
def test_estimate_tokens():
assert _estimate_tokens("") == 0
assert _estimate_tokens(None) == 0
assert _estimate_tokens("abcd") == 2 # 4//2
assert _estimate_tokens("a" * 5) == 2 # max(1, 5//2)=2
# just ensure positive monotonic
assert _estimate_tokens("x" * 100) == 50
def test_history_excludes_latest_user():
# newest-first: [current user, prev assistant, older user]
msgs = _newest_first([
_Msg("user", "current question"),
_Msg("assistant", "previous answer"),
_Msg("user", "older question"),
])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
roles = [m["role"] for m in out]
# current user dropped; chronological order = [older user, previous assistant]
assert roles == ["user", "assistant"]
assert out[0]["content"] == "older question"
def test_summary_injected_as_system_first():
msgs = _newest_first([
_Msg("user", "hi"),
_Msg("assistant", "hello"),
])
db = _DB(msgs, summary=_Summary("用户想做图片生成"))
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
assert out[0]["role"] == "system"
assert out[0]["content"].startswith("【对话历史摘要")
assert "用户想做图片生成" in out[0]["content"]
# after summary: chronological history (assistant then user 'hi' excluded? exclude_latest_user drops 'hi')
assert out[1]["role"] == "assistant"
def test_budget_truncation():
# 5 assistant messages each ~200 tokens; budget small -> truncated
msgs = _newest_first([_Msg("assistant", "x" * 400) for _ in range(5)])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=500, exclude_latest_user=False)
# each msg ~200 tokens; budget 500 -> keep newest ~2
assert len(out) <= 3
total = sum(_estimate_tokens(m["content"]) for m in out)
assert total <= 500 + 200 # within budget (+1 msg slack)
def test_no_summary_when_absent():
msgs = _newest_first([_Msg("user", "hi"), _Msg("assistant", "hello")])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
# 'hi' excluded; only assistant remains, no system summary
assert all(m["role"] != "system" for m in out)
assert [m["role"] for m in out] == ["assistant"]
+177 -175
View File
@@ -1,175 +1,177 @@
import React, { useState, useEffect, Suspense } from 'react'; import 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;
+1987 -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;
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { adminAPI } from '@/api';
interface Summary {
totals: { calls: number; tokens: number; cost: number; users: number };
by_model: { model_name: string; calls: number; tokens: number; cost: number }[];
by_user: { user_id: string; username: string; calls: number; tokens: number; cost: number }[];
daily: { date: string; tokens: number; cost: number; calls: number }[];
recent: { trace_id: string | null; model_name: string; tokens: number; cost: number; created_at: string | null }[];
}
const ACCENT = '#5B5FED';
const ACCENT_HOVER = '#4A4ECC';
export default function Observability() {
const [days, setDays] = useState(30);
const [data, setData] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
useEffect(() => {
let alive = true;
setLoading(true);
setErr('');
adminAPI.getObservabilitySummary(days)
.then((d: Summary) => { if (alive) setData(d); })
.catch((e: any) => { if (alive) setErr(e?.message || '加载失败'); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [days]);
const maxTokens = data ? Math.max(1, ...data.daily.map(d => d.tokens)) : 1;
const maxModel = data ? Math.max(1, ...data.by_model.map(m => m.tokens)) : 1;
const fmt = (n: number) => n.toLocaleString('zh-CN');
const fmtCost = (n: number) => `¥${n.toFixed(4)}`;
const card: React.CSSProperties = {
background: '#fff', border: '1px solid #eceef5', borderRadius: 16,
padding: 20, boxShadow: '0 1px 3px rgba(20,30,60,0.04)', marginBottom: 18,
};
const stat: React.CSSProperties = {
flex: 1, minWidth: 150, background: '#fff', border: '1px solid #eceef5',
borderRadius: 16, padding: 18, boxShadow: '0 1px 3px rgba(20,30,60,0.04)',
};
return (
<div style={{ padding: 24, maxWidth: 1160, margin: '0 auto', color: '#1f2330' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
<div>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>调用可观测</h2>
<div style={{ color: '#8a90a2', fontSize: 13, marginTop: 4 }}>
媒体 / LLM 调用归因 · 基于 trace_id 关联请求链路(Phase 2.2)
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{[7, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
style={{
padding: '7px 14px', borderRadius: 10, cursor: 'pointer', fontSize: 13,
border: `1px solid ${days === d ? ACCENT : '#e2e5ee'}`,
background: days === d ? ACCENT : '#fff',
color: days === d ? '#fff' : '#5b6172', fontWeight: 600,
transition: 'all .15s',
}}>
{d} 天
</button>
))}
</div>
</div>
{err && <div style={{ ...card, borderColor: '#f3c2c2', color: '#c0392b' }}>{err}</div>}
{loading && !data && <div style={{ color: '#8a90a2' }}>加载中…</div>}
{data && (
<>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>调用次数</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.calls)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>Token 消耗</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.tokens)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>成本</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6, color: ACCENT }}>{fmtCost(data.totals.cost)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>活跃用户</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.users)}</div></div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>按模型</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.by_model.length === 0 && <div style={{ color: '#8a90a2', fontSize: 13 }}>暂无数据</div>}
{data.by_model.map(m => (
<div key={m.model_name} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 150, fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_name}</div>
<div style={{ flex: 1, height: 10, background: '#f1f3f9', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ width: `${(m.tokens / maxModel) * 100}%`, height: '100%', background: ACCENT, borderRadius: 6 }} />
</div>
<div style={{ width: 90, textAlign: 'right', fontSize: 13, color: '#5b6172' }}>{fmt(m.tokens)}</div>
<div style={{ width: 90, textAlign: 'right', fontSize: 13, color: ACCENT }}>{fmtCost(m.cost)}</div>
</div>
))}
</div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>每日趋势(Token)</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 140, overflowX: 'auto', paddingBottom: 4 }}>
{data.daily.length === 0 && <div style={{ color: '#8a90a2', fontSize: 13 }}>暂无数据</div>}
{data.daily.map(d => (
<div key={d.date} title={`${d.date} · ${fmt(d.tokens)} tokens · ${fmtCost(d.cost)}`}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 22 }}>
<div style={{ width: 16, height: `${(d.tokens / maxTokens) * 110}px`, background: ACCENT, borderRadius: '4px 4px 0 0', opacity: 0.85 }} />
<div style={{ fontSize: 9, color: '#aab', transform: 'rotate(-45deg)', whiteSpace: 'nowrap' }}>{d.date.slice(5)}</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<div style={{ ...card, flex: 1, minWidth: 320 }}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>按用户(Top 20)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ color: '#8a90a2', textAlign: 'left' }}>
<th style={th}>用户</th><th style={thR}>调用</th><th style={thR}>Token</th><th style={thR}>成本</th>
</tr></thead>
<tbody>
{data.by_user.map(u => (
<tr key={u.user_id} style={{ borderTop: '1px solid #f1f3f9' }}>
<td style={td}>{u.username}</td>
<td style={tdR}>{fmt(u.calls)}</td>
<td style={tdR}>{fmt(u.tokens)}</td>
<td style={{ ...tdR, color: ACCENT }}>{fmtCost(u.cost)}</td>
</tr>
))}
{data.by_user.length === 0 && <tr><td colSpan={4} style={{ ...td, color: '#8a90a2' }}>暂无数据</td></tr>}
</tbody>
</table>
</div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>最近调用(trace_id 可下钻)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ color: '#8a90a2', textAlign: 'left' }}>
<th style={th}>Trace ID</th><th style={th}>模型</th><th style={thR}>Token</th><th style={thR}>成本</th><th style={thR}>时间</th>
</tr></thead>
<tbody>
{data.recent.map((r, i) => (
<tr key={(r.trace_id || '') + i} style={{ borderTop: '1px solid #f1f3f9' }}>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.trace_id || '—'}</td>
<td style={td}>{r.model_name}</td>
<td style={tdR}>{fmt(r.tokens)}</td>
<td style={{ ...tdR, color: ACCENT }}>{fmtCost(r.cost)}</td>
<td style={tdR}>{r.created_at || '—'}</td>
</tr>
))}
{data.recent.length === 0 && <tr><td colSpan={5} style={{ ...td, color: '#8a90a2' }}>暂无数据</td></tr>}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
const th: React.CSSProperties = { padding: '8px 6px', fontWeight: 600, fontSize: 12 };
const thR: React.CSSProperties = { ...th, textAlign: 'right' };
const td: React.CSSProperties = { padding: '8px 6px' };
const tdR: React.CSSProperties = { ...td, textAlign: 'right', color: '#5b6172' };
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS conversation_summaries (
conversation_id VARCHAR(64) NOT NULL,
summary_text TEXT NULL,
token_count INT NULL DEFAULT 0,
updated_at DATETIME(3) NULL,
PRIMARY KEY (conversation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;