chore: normalize line endings to LF via .gitattributes
Root-cause fix for mixed CRLF/LF in the repo (no .gitattributes previously). Adds '* text=auto eol=lf' and explicit binary rules so all future checkouts are LF. Renormalizes all tracked text files (no logical content change). Backup tags: image qihuaiflow:backup-lineend-20260725-121632, git backup-lineend-pre-20260725-121632.
This commit is contained in:
1 parent
2a7bb875f9
commit
771c8456b8
39 files changed
+34810
-34777
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
# Normalize all text files to LF on checkout/commit (root-cause fix for mixed line endings)
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary assets: never normalize line endings or diff as text
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.otf binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.tar binary
|
||||||
|
*.gz binary
|
||||||
|
*.bz2 binary
|
||||||
|
*.7z binary
|
||||||
|
*.pyc binary
|
||||||
|
*.so binary
|
||||||
|
*.dll binary
|
||||||
|
*.exe binary
|
||||||
|
*.db binary
|
||||||
|
*.sqlite binary
|
||||||
|
*.sqlite3 binary
|
||||||
|
*.wasm binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.avi binary
|
||||||
|
*.mov binary
|
||||||
@@ -1,179 +1,179 @@
|
|||||||
"""
|
"""
|
||||||
AnySearch 联网检索客户端
|
AnySearch 联网检索客户端
|
||||||
======================
|
======================
|
||||||
调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search),
|
调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search),
|
||||||
为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。
|
为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。
|
||||||
|
|
||||||
约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用
|
约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用
|
||||||
trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。
|
trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。
|
||||||
失败一律返回空/降级结果,保证聊天链路不中断。
|
失败一律返回空/降级结果,保证聊天链路不中断。
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger("aiflow.anysearch")
|
logger = logging.getLogger("aiflow.anysearch")
|
||||||
|
|
||||||
DEFAULT_API_URL = "https://api.anysearch.com/v1/search"
|
DEFAULT_API_URL = "https://api.anysearch.com/v1/search"
|
||||||
DEFAULT_ZONE = "cn"
|
DEFAULT_ZONE = "cn"
|
||||||
REQUEST_TIMEOUT = 8.0
|
REQUEST_TIMEOUT = 8.0
|
||||||
|
|
||||||
|
|
||||||
async def anysearch_search(
|
async def anysearch_search(
|
||||||
api_key: str,
|
api_key: str,
|
||||||
api_url: str = DEFAULT_API_URL,
|
api_url: str = DEFAULT_API_URL,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
zone: str = DEFAULT_ZONE,
|
zone: str = DEFAULT_ZONE,
|
||||||
freshness: Optional[str] = None,
|
freshness: Optional[str] = None,
|
||||||
timeout: float = REQUEST_TIMEOUT,
|
timeout: float = REQUEST_TIMEOUT,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""调用 AnySearch 搜索,返回结构化结果列表。
|
"""调用 AnySearch 搜索,返回结构化结果列表。
|
||||||
|
|
||||||
每个结果:
|
每个结果:
|
||||||
{
|
{
|
||||||
"content": snippet 摘要文本,
|
"content": snippet 摘要文本,
|
||||||
"score": 0.9..0.5 递减,
|
"score": 0.9..0.5 递减,
|
||||||
"source": "web_anysearch",
|
"source": "web_anysearch",
|
||||||
"metadata": {"url", "title", "source_domain", "zone"}
|
"metadata": {"url", "title", "source_domain", "zone"}
|
||||||
}
|
}
|
||||||
失败时返回 [] (不抛异常)。
|
失败时返回 [] (不抛异常)。
|
||||||
"""
|
"""
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("httpx not available for AnySearch")
|
logger.warning("httpx not available for AnySearch")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"query": query,
|
"query": query,
|
||||||
"zone": zone,
|
"zone": zone,
|
||||||
"top_k": max(1, min(int(top_k), 20)),
|
"top_k": max(1, min(int(top_k), 20)),
|
||||||
}
|
}
|
||||||
if freshness:
|
if freshness:
|
||||||
payload["freshness"] = freshness
|
payload["freshness"] = freshness
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
# trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com
|
# trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com
|
||||||
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
||||||
resp = await client.post(api_url, json=payload, headers=headers)
|
resp = await client.post(api_url, json=payload, headers=headers)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}")
|
logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
return []
|
return []
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
logger.warning(f"AnySearch biz error: {data.get('message')}")
|
logger.warning(f"AnySearch biz error: {data.get('message')}")
|
||||||
return []
|
return []
|
||||||
results = (data.get("data") or {}).get("results") or []
|
results = (data.get("data") or {}).get("results") or []
|
||||||
hits = []
|
hits = []
|
||||||
for idx, r in enumerate(results[:top_k]):
|
for idx, r in enumerate(results[:top_k]):
|
||||||
title = r.get("title", "") or ""
|
title = r.get("title", "") or ""
|
||||||
url = r.get("url", "") or ""
|
url = r.get("url", "") or ""
|
||||||
snippet = r.get("snippet") or r.get("content") or ""
|
snippet = r.get("snippet") or r.get("content") or ""
|
||||||
if not snippet and not title:
|
if not snippet and not title:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
domain = urlparse(url).netloc
|
domain = urlparse(url).netloc
|
||||||
except Exception:
|
except Exception:
|
||||||
domain = ""
|
domain = ""
|
||||||
hits.append({
|
hits.append({
|
||||||
"content": snippet,
|
"content": snippet,
|
||||||
"score": max(0.4, 0.9 - idx * 0.08),
|
"score": max(0.4, 0.9 - idx * 0.08),
|
||||||
"source": "web_anysearch",
|
"source": "web_anysearch",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"url": url,
|
"url": url,
|
||||||
"title": title,
|
"title": title,
|
||||||
"source_domain": domain,
|
"source_domain": domain,
|
||||||
"zone": zone,
|
"zone": zone,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return hits
|
return hits
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"AnySearch search failed: {e}")
|
logger.warning(f"AnySearch search failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict:
|
async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict:
|
||||||
"""连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。"""
|
"""连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。"""
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False}
|
return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False}
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
api_url,
|
api_url,
|
||||||
json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1},
|
json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1},
|
||||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True}
|
return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True}
|
||||||
return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False}
|
return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False}
|
return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False}
|
||||||
|
|
||||||
|
|
||||||
def load_anysearch_config(db_session) -> Dict:
|
def load_anysearch_config(db_session) -> Dict:
|
||||||
"""从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。"""
|
"""从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。"""
|
||||||
if db_session is None:
|
if db_session is None:
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
from app.models import SiteSetting
|
from app.models import SiteSetting
|
||||||
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
||||||
if not row or not row.value:
|
if not row or not row.value:
|
||||||
return {}
|
return {}
|
||||||
cfg = json.loads(row.value)
|
cfg = json.loads(row.value)
|
||||||
if not isinstance(cfg, dict):
|
if not isinstance(cfg, dict):
|
||||||
return {}
|
return {}
|
||||||
return cfg
|
return cfg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"load_anysearch_config failed: {e}")
|
logger.warning(f"load_anysearch_config failed: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def save_anysearch_config(
|
def save_anysearch_config(
|
||||||
db_session,
|
db_session,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
is_enabled: bool = True,
|
is_enabled: bool = True,
|
||||||
api_url: str = DEFAULT_API_URL,
|
api_url: str = DEFAULT_API_URL,
|
||||||
zone: str = DEFAULT_ZONE,
|
zone: str = DEFAULT_ZONE,
|
||||||
freshness: Optional[str] = None,
|
freshness: Optional[str] = None,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""保存/更新全局 AnySearch 配置到 site_settings。"""
|
"""保存/更新全局 AnySearch 配置到 site_settings。"""
|
||||||
if db_session is None:
|
if db_session is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
from app.models import SiteSetting
|
from app.models import SiteSetting
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
cfg = {
|
cfg = {
|
||||||
"api_key": api_key.strip(),
|
"api_key": api_key.strip(),
|
||||||
"is_enabled": bool(is_enabled),
|
"is_enabled": bool(is_enabled),
|
||||||
"api_url": api_url.strip() or DEFAULT_API_URL,
|
"api_url": api_url.strip() or DEFAULT_API_URL,
|
||||||
"zone": zone or DEFAULT_ZONE,
|
"zone": zone or DEFAULT_ZONE,
|
||||||
"freshness": freshness,
|
"freshness": freshness,
|
||||||
"top_k": max(1, min(int(top_k), 20)),
|
"top_k": max(1, min(int(top_k), 20)),
|
||||||
}
|
}
|
||||||
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
||||||
if row:
|
if row:
|
||||||
row.value = json.dumps(cfg, ensure_ascii=False)
|
row.value = json.dumps(cfg, ensure_ascii=False)
|
||||||
row.updated_at = datetime.now()
|
row.updated_at = datetime.now()
|
||||||
else:
|
else:
|
||||||
from app.models import gen_uuid
|
from app.models import gen_uuid
|
||||||
row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False))
|
row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False))
|
||||||
db_session.add(row)
|
db_session.add(row)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"save_anysearch_config failed: {e}")
|
logger.warning(f"save_anysearch_config failed: {e}")
|
||||||
try:
|
try:
|
||||||
db_session.rollback()
|
db_session.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+2905
-2905
File diff suppressed because it is too large.
Load diff
@@ -1,319 +1,319 @@
|
|||||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
from sqlalchemy import and_, desc, asc, func
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from ..database import get_db, SessionLocal
|
from ..database import get_db, SessionLocal
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MCPServer,
|
MCPServer,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gen_uuid
|
gen_uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..platform_knowledge import get_platform_knowledge
|
from ..platform_knowledge import get_platform_knowledge
|
||||||
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
||||||
from ..middleware.log_service import LogService
|
from ..middleware.log_service import LogService
|
||||||
from ..capability_registry import resolve_auto_model
|
from ..capability_registry import resolve_auto_model
|
||||||
from app.api.chat_utils import (cst_now)
|
from app.api.chat_utils import (cst_now)
|
||||||
|
|
||||||
def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict:
|
def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""从知识库文档构建知识图谱节点和边,返回 graph_data。"""
|
"""从知识库文档构建知识图谱节点和边,返回 graph_data。"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
documents = db.query(KnowledgeDocument).filter(
|
documents = db.query(KnowledgeDocument).filter(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
and_(
|
and_(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
KnowledgeDocument.knowledge_base_id == kb.id,
|
KnowledgeDocument.knowledge_base_id == kb.id,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
KnowledgeDocument.status == "completed",
|
KnowledgeDocument.status == "completed",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_nodes = []
|
graph_nodes = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_edges = []
|
graph_edges = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
doc_entity_types = [
|
doc_entity_types = [
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{"type": "concept", "label": "核心概念"},
|
{"type": "concept", "label": "核心概念"},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{"type": "entity", "label": "关键实体"},
|
{"type": "entity", "label": "关键实体"},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{"type": "attribute", "label": "属性特征"},
|
{"type": "attribute", "label": "属性特征"},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{"type": "relation", "label": "关联关系"},
|
{"type": "relation", "label": "关联关系"},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for idx, doc in enumerate(documents):
|
for idx, doc in enumerate(documents):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
doc_node_id = f"doc-{doc.id[:8]}"
|
doc_node_id = f"doc-{doc.id[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_nodes.append({
|
graph_nodes.append({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"id": doc_node_id, "label": doc.filename, "type": "document", "size": 18, "group": "document",
|
"id": doc_node_id, "label": doc.filename, "type": "document", "size": 18, "group": "document",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
filename_base = doc.filename.rsplit(".", 1)[0]
|
filename_base = doc.filename.rsplit(".", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
words = filename_base.replace("_", " ").replace("-", " ").split()
|
words = filename_base.replace("_", " ").replace("-", " ").split()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for wi, word in enumerate(words):
|
for wi, word in enumerate(words):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if len(word) >= 2:
|
if len(word) >= 2:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
entity_type = doc_entity_types[wi % len(doc_entity_types)]
|
entity_type = doc_entity_types[wi % len(doc_entity_types)]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
node_id = f"ent-{doc.id[:8]}-{wi}"
|
node_id = f"ent-{doc.id[:8]}-{wi}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_nodes.append({
|
graph_nodes.append({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"id": node_id, "label": word, "type": entity_type["type"],
|
"id": node_id, "label": word, "type": entity_type["type"],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"size": 10 + (wi % 8), "group": entity_type["type"],
|
"size": 10 + (wi % 8), "group": entity_type["type"],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_edges.append({
|
graph_edges.append({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"source": doc_node_id, "target": node_id,
|
"source": doc_node_id, "target": node_id,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"label": entity_type["label"], "weight": 0.5 + (0.5 / (wi + 1)),
|
"label": entity_type["label"], "weight": 0.5 + (0.5 / (wi + 1)),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for i in range(len(documents)):
|
for i in range(len(documents)):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for j in range(i + 1, len(documents)):
|
for j in range(i + 1, len(documents)):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fi = documents[i].filename.lower().rsplit(".", 1)[0]
|
fi = documents[i].filename.lower().rsplit(".", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fj = documents[j].filename.lower().rsplit(".", 1)[0]
|
fj = documents[j].filename.lower().rsplit(".", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
words_i = set(fi.replace("_", " ").replace("-", " ").split())
|
words_i = set(fi.replace("_", " ").replace("-", " ").split())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
words_j = set(fj.replace("_", " ").replace("-", " ").split())
|
words_j = set(fj.replace("_", " ").replace("-", " ").split())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
common = words_i & words_j
|
common = words_i & words_j
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if common and len(common) > 0:
|
if common and len(common) > 0:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_edges.append({
|
graph_edges.append({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"source": f"doc-{documents[i].id[:8]}",
|
"source": f"doc-{documents[i].id[:8]}",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"target": f"doc-{documents[j].id[:8]}",
|
"target": f"doc-{documents[j].id[:8]}",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"label": f"共有关键词: {', '.join(list(common)[:3])}",
|
"label": f"共有关键词: {', '.join(list(common)[:3])}",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"weight": min(0.9, 0.3 * len(common)),
|
"weight": min(0.9, 0.3 * len(common)),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
graph_data = {
|
graph_data = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"nodes": graph_nodes,
|
"nodes": graph_nodes,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"edges": graph_edges,
|
"edges": graph_edges,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"stats": {
|
"stats": {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"total_nodes": len(graph_nodes), "total_edges": len(graph_edges),
|
"total_nodes": len(graph_nodes), "total_edges": len(graph_edges),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"document_count": len(documents),
|
"document_count": len(documents),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
kb.knowledge_graph_count = len(graph_nodes)
|
kb.knowledge_graph_count = len(graph_nodes)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
kb.updated_at = cst_now()
|
kb.updated_at = cst_now()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return graph_data
|
return graph_data
|
||||||
+240
-240
@@ -1,240 +1,240 @@
|
|||||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
from sqlalchemy import and_, desc, asc, func
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from ..database import get_db, SessionLocal
|
from ..database import get_db, SessionLocal
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MCPServer,
|
MCPServer,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gen_uuid
|
gen_uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..platform_knowledge import get_platform_knowledge
|
from ..platform_knowledge import get_platform_knowledge
|
||||||
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
||||||
from ..middleware.log_service import LogService
|
from ..middleware.log_service import LogService
|
||||||
from ..capability_registry import resolve_auto_model
|
from ..capability_registry import resolve_auto_model
|
||||||
|
|
||||||
def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list:
|
def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。
|
加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
返回: [{id, name, transport, endpoint, description}]
|
返回: [{id, name, transport, endpoint, description}]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if not mcp_ids:
|
if not mcp_ids:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
mcps = db.query(MarketApp).filter(
|
mcps = db.query(MarketApp).filter(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MarketApp.id.in_(mcp_ids),
|
MarketApp.id.in_(mcp_ids),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MarketApp.type == "mcp"
|
MarketApp.type == "mcp"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for m in mcps:
|
for m in mcps:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if m.skill_content:
|
if m.skill_content:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
config = json.loads(m.skill_content)
|
config = json.loads(m.skill_content)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"transport": m.mcp_transport or config.get("transport", "stdio"),
|
"transport": m.mcp_transport or config.get("transport", "stdio"),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""),
|
"endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"description": m.description or config.get("description", ""),
|
"description": m.description or config.get("description", ""),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list:
|
def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
解析用户选中的 MCP 服务连接信息(来自 mcp_servers 表,import 与后台添加通用)。
|
解析用户选中的 MCP 服务连接信息(来自 mcp_servers 表,import 与后台添加通用)。
|
||||||
|
|
||||||
返回: [{id, name, description, transport, endpoint, url, headers}]
|
返回: [{id, name, description, transport, endpoint, url, headers}]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not mcp_ids:
|
if not mcp_ids:
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
servers = db.query(MCPServer).filter(
|
servers = db.query(MCPServer).filter(
|
||||||
|
|
||||||
MCPServer.id.in_(mcp_ids),
|
MCPServer.id.in_(mcp_ids),
|
||||||
|
|
||||||
MCPServer.is_enabled == True,
|
MCPServer.is_enabled == True,
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
|
|
||||||
for s in servers:
|
for s in servers:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
|
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
out.append({
|
out.append({
|
||||||
|
|
||||||
"id": s.id,
|
"id": s.id,
|
||||||
|
|
||||||
"name": s.name,
|
"name": s.name,
|
||||||
|
|
||||||
"description": s.description or "",
|
"description": s.description or "",
|
||||||
|
|
||||||
"transport": s.transport_type or "streamable_http",
|
"transport": s.transport_type or "streamable_http",
|
||||||
|
|
||||||
"endpoint": s.url or "",
|
"endpoint": s.url or "",
|
||||||
|
|
||||||
"url": s.url or "",
|
"url": s.url or "",
|
||||||
|
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _list_enabled_mcp_servers(db: Session) -> list:
|
def _list_enabled_mcp_servers(db: Session) -> list:
|
||||||
"""返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。"""
|
"""返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。"""
|
||||||
servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
|
servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
|
||||||
out = []
|
out = []
|
||||||
for s in servers:
|
for s in servers:
|
||||||
try:
|
try:
|
||||||
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
|
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
|
||||||
except Exception:
|
except Exception:
|
||||||
headers = {}
|
headers = {}
|
||||||
out.append({
|
out.append({
|
||||||
"id": s.id,
|
"id": s.id,
|
||||||
"name": s.name,
|
"name": s.name,
|
||||||
"description": s.description or "",
|
"description": s.description or "",
|
||||||
"transport": s.transport_type or "streamable_http",
|
"transport": s.transport_type or "streamable_http",
|
||||||
"endpoint": s.url or "",
|
"endpoint": s.url or "",
|
||||||
"url": s.url or "",
|
"url": s.url or "",
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
+8549
-8549
File diff suppressed because it is too large.
Load diff
+1742
-1742
File diff suppressed because it is too large.
Load diff
+282
-282
@@ -1,282 +1,282 @@
|
|||||||
"""
|
"""
|
||||||
IM Bot 真实 LLM 调用。
|
IM Bot 真实 LLM 调用。
|
||||||
|
|
||||||
原实现位于 channels.py(通用频道管理模块),该模块已移除。
|
原实现位于 channels.py(通用频道管理模块),该模块已移除。
|
||||||
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
|
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
|
||||||
|
|
||||||
行为(对齐 AI 聊天 — 2026-07-23 同步至最新):
|
行为(对齐 AI 聊天 — 2026-07-23 同步至最新):
|
||||||
- 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 生成质量。
|
||||||
- 2026-07-23:对齐 Web Chat 最新能力:
|
- 2026-07-23:对齐 Web Chat 最新能力:
|
||||||
* 模型 failover(Auto 模式自动切换备选模型)
|
* 模型 failover(Auto 模式自动切换备选模型)
|
||||||
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
||||||
"""
|
"""
|
||||||
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 app.core.llm_client import chat_complete # 统一模型层(07-24): web/IM 共用标准 OpenAI SDK 客户端
|
from app.core.llm_client import chat_complete # 统一模型层(07-24): web/IM 共用标准 OpenAI SDK 客户端
|
||||||
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
|
from app.core.observability import get_trace_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
|
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
|
||||||
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
|
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
|
||||||
|
|
||||||
|
|
||||||
def _proxy_for_url(url: str) -> str | None:
|
def _proxy_for_url(url: str) -> str | None:
|
||||||
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
|
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
|
||||||
|
|
||||||
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
|
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
|
||||||
国内域名(尊重 NO_PROXY)直连,更稳定。
|
国内域名(尊重 NO_PROXY)直连,更稳定。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
host = (_urlparse(url).hostname or "").lower()
|
host = (_urlparse(url).hostname or "").lower()
|
||||||
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
||||||
return _HK_PROXY
|
return _HK_PROXY
|
||||||
if "qihuyun.com" in host:
|
if "qihuyun.com" in host:
|
||||||
return None
|
return None
|
||||||
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
||||||
for _np in _no_proxy:
|
for _np in _no_proxy:
|
||||||
if host == _np or host.endswith("." + _np) or _np in host:
|
if host == _np or host.endswith("." + _np) or _np in host:
|
||||||
return None
|
return None
|
||||||
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
|
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 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
|
||||||
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
|
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
|
||||||
|
|
||||||
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
|
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
loop = None
|
loop = None
|
||||||
|
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
async def _timed():
|
async def _timed():
|
||||||
return await asyncio.wait_for(coro, timeout=timeout)
|
return await asyncio.wait_for(coro, timeout=timeout)
|
||||||
target = _timed()
|
target = _timed()
|
||||||
else:
|
else:
|
||||||
target = coro
|
target = coro
|
||||||
|
|
||||||
if loop is not None and loop.is_running():
|
if loop is not None and loop.is_running():
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
with concurrent.futures.ThreadPoolExecutor(1) as ex:
|
with concurrent.futures.ThreadPoolExecutor(1) as ex:
|
||||||
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
|
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
|
||||||
return asyncio.run(target)
|
return asyncio.run(target)
|
||||||
|
|
||||||
|
|
||||||
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
|
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
|
||||||
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
|
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
|
||||||
|
|
||||||
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。
|
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。
|
||||||
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
|
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
|
||||||
"""
|
"""
|
||||||
if not user_id or not ai_model:
|
if not user_id or not ai_model:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
base = 1200 if media_type == "video" else 400
|
base = 1200 if media_type == "video" else 400
|
||||||
est = base + (len(prompt or "") // 4)
|
est = base + (len(prompt or "") // 4)
|
||||||
db.add(TokenUsageLog(
|
db.add(TokenUsageLog(
|
||||||
id=gen_uuid(),
|
id=gen_uuid(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
model_id=ai_model.id,
|
model_id=ai_model.id,
|
||||||
model_name=ai_model.name,
|
model_name=ai_model.name,
|
||||||
trace_id=get_trace_id(),
|
trace_id=get_trace_id(),
|
||||||
prompt_tokens=est,
|
prompt_tokens=est,
|
||||||
completion_tokens=0,
|
completion_tokens=0,
|
||||||
total_tokens=est,
|
total_tokens=est,
|
||||||
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
|
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
try:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
|
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":
|
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
|
||||||
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
|
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
|
||||||
try:
|
try:
|
||||||
from app.api.chat import (
|
from app.api.chat import (
|
||||||
_generate_media,
|
_generate_media,
|
||||||
detect_media_intent,
|
detect_media_intent,
|
||||||
_discover_installed_skills,
|
_discover_installed_skills,
|
||||||
_build_system_prompt,
|
_build_system_prompt,
|
||||||
_execute_skill_tool,
|
_execute_skill_tool,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== 1) 媒体意图路由(Issue 7)=====
|
# ===== 1) 媒体意图路由(Issue 7)=====
|
||||||
intent = detect_media_intent(prompt)
|
intent = detect_media_intent(prompt)
|
||||||
if intent:
|
if intent:
|
||||||
ai_model = resolve_auto_model(intent, db)
|
ai_model = resolve_auto_model(intent, db)
|
||||||
if ai_model:
|
if ai_model:
|
||||||
try:
|
try:
|
||||||
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
|
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
|
||||||
except Exception:
|
except Exception:
|
||||||
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
|
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
|
||||||
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
|
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
|
||||||
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
|
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
|
||||||
if result:
|
if result:
|
||||||
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
|
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
|
||||||
s = result.strip()
|
s = result.strip()
|
||||||
if s.startswith("<img") or s.startswith("<video"):
|
if s.startswith("<img") or s.startswith("<video"):
|
||||||
m = _re.search(r'src="([^"]+)"', s)
|
m = _re.search(r'src="([^"]+)"', s)
|
||||||
url = m.group(1) if m else ""
|
url = m.group(1) if m else ""
|
||||||
media_type = "image" if s.startswith("<img") else "video"
|
media_type = "image" if s.startswith("<img") else "video"
|
||||||
if not url:
|
if not url:
|
||||||
return "(生成完成,但未能提取媒体地址)"
|
return "(生成完成,但未能提取媒体地址)"
|
||||||
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
|
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
|
||||||
_log_media_usage(db, user_id, ai_model, media_type, prompt)
|
_log_media_usage(db, user_id, ai_model, media_type, prompt)
|
||||||
return {"__media__": True, "media_type": media_type, "url": url}
|
return {"__media__": True, "media_type": media_type, "url": url}
|
||||||
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
||||||
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
||||||
if s.startswith("⚠️") or s.startswith("(媒体"):
|
if s.startswith("⚠️") or s.startswith("(媒体"):
|
||||||
return s
|
return s
|
||||||
return "(媒体生成未能返回有效内容,请重试)"
|
return "(媒体生成未能返回有效内容,请重试)"
|
||||||
|
|
||||||
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
|
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
|
||||||
from ..platform_knowledge import get_platform_knowledge
|
from ..platform_knowledge import get_platform_knowledge
|
||||||
|
|
||||||
# 构建候选模型列表(对齐 Web Chat failover)
|
# 构建候选模型列表(对齐 Web Chat failover)
|
||||||
ai_model = resolve_auto_model("text", db)
|
ai_model = resolve_auto_model("text", db)
|
||||||
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
|
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
|
||||||
# Auto 模式附加备选(IM Bot 始终 Auto)
|
# Auto 模式附加备选(IM Bot 始终 Auto)
|
||||||
_fallbacks = db.query(AIModel).filter(
|
_fallbacks = db.query(AIModel).filter(
|
||||||
AIModel.is_enabled == True,
|
AIModel.is_enabled == True,
|
||||||
AIModel.id != (ai_model.id if ai_model else ""),
|
AIModel.id != (ai_model.id if ai_model else ""),
|
||||||
).order_by(AIModel.is_default.desc()).limit(4).all()
|
).order_by(AIModel.is_default.desc()).limit(4).all()
|
||||||
_try_models.extend([m for m in _fallbacks if m not in _try_models])
|
_try_models.extend([m for m in _fallbacks if m not in _try_models])
|
||||||
|
|
||||||
if not _try_models:
|
if not _try_models:
|
||||||
return "(未配置可用文本模型,无法生成回复)"
|
return "(未配置可用文本模型,无法生成回复)"
|
||||||
|
|
||||||
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
|
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
|
||||||
skills = []
|
skills = []
|
||||||
if user_id:
|
if user_id:
|
||||||
try:
|
try:
|
||||||
skills = _discover_installed_skills(db, user_id)[:15]
|
skills = _discover_installed_skills(db, user_id)[:15]
|
||||||
except Exception:
|
except Exception:
|
||||||
skills = []
|
skills = []
|
||||||
|
|
||||||
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
|
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
|
||||||
kb_context = ""
|
kb_context = ""
|
||||||
try:
|
try:
|
||||||
pk = get_platform_knowledge(db=db)
|
pk = get_platform_knowledge(db=db)
|
||||||
if pk:
|
if pk:
|
||||||
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
|
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 尝试每个候选模型
|
# 尝试每个候选模型
|
||||||
_retry_log = []
|
_retry_log = []
|
||||||
for _try_model in _try_models:
|
for _try_model in _try_models:
|
||||||
ai_model = _try_model
|
ai_model = _try_model
|
||||||
|
|
||||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||||
api_url = ai_model.endpoint
|
api_url = ai_model.endpoint
|
||||||
if not api_url and provider and provider.api_base_url:
|
if not api_url and provider and provider.api_base_url:
|
||||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||||
if not (api_key and api_url):
|
if not (api_key and api_url):
|
||||||
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
|
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
system_prompt, function_definitions = _build_system_prompt(
|
system_prompt, function_definitions = _build_system_prompt(
|
||||||
skills=skills,
|
skills=skills,
|
||||||
agents=[],
|
agents=[],
|
||||||
kb_context=kb_context,
|
kb_context=kb_context,
|
||||||
user_content=prompt,
|
user_content=prompt,
|
||||||
mcp=[],
|
mcp=[],
|
||||||
auto_discovered=bool(skills),
|
auto_discovered=bool(skills),
|
||||||
)
|
)
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
]
|
]
|
||||||
|
|
||||||
# 尝试调用
|
# 尝试调用
|
||||||
try:
|
try:
|
||||||
max_rounds = 3
|
max_rounds = 3
|
||||||
ai_content = ""
|
ai_content = ""
|
||||||
_http_ok = True
|
_http_ok = True
|
||||||
for round_num in range(max_rounds):
|
for round_num in range(max_rounds):
|
||||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||||
if function_definitions and skills:
|
if function_definitions and skills:
|
||||||
request_body["tools"] = [
|
request_body["tools"] = [
|
||||||
{"type": "function", "function": fd} for fd in function_definitions
|
{"type": "function", "function": fd} for fd in function_definitions
|
||||||
]
|
]
|
||||||
request_body["tool_choice"] = "auto"
|
request_body["tool_choice"] = "auto"
|
||||||
try:
|
try:
|
||||||
data = chat_complete(
|
data = chat_complete(
|
||||||
api_url,
|
api_url,
|
||||||
api_key,
|
api_key,
|
||||||
request_body,
|
request_body,
|
||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
timeout=ai_model.timeout or 120,
|
timeout=ai_model.timeout or 120,
|
||||||
)
|
)
|
||||||
except Exception as _exc:
|
except Exception as _exc:
|
||||||
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
|
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
|
||||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
|
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
|
||||||
_http_ok = False
|
_http_ok = False
|
||||||
break
|
break
|
||||||
message = data.get("choices", [{}])[0].get("message", {})
|
message = data.get("choices", [{}])[0].get("message", {})
|
||||||
tool_calls = message.get("tool_calls", [])
|
tool_calls = message.get("tool_calls", [])
|
||||||
if tool_calls and skills:
|
if tool_calls and skills:
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": message.get("content") or "",
|
"content": message.get("content") or "",
|
||||||
"tool_calls": tool_calls,
|
"tool_calls": tool_calls,
|
||||||
})
|
})
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
func_name = tc.get("function", {}).get("name", "")
|
func_name = tc.get("function", {}).get("name", "")
|
||||||
try:
|
try:
|
||||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
||||||
except Exception:
|
except Exception:
|
||||||
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
||||||
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.get("id", "call_1"),
|
"tool_call_id": tc.get("id", "call_1"),
|
||||||
"content": tool_result,
|
"content": tool_result,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
ai_content = message.get("content", "")
|
ai_content = message.get("content", "")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 检查本轮调用是否成功
|
# 检查本轮调用是否成功
|
||||||
if _http_ok and ai_content:
|
if _http_ok and ai_content:
|
||||||
return ai_content or "(AI 回复为空)"
|
return ai_content or "(AI 回复为空)"
|
||||||
# 本轮失败:记录日志,切换备选
|
# 本轮失败:记录日志,切换备选
|
||||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
|
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
||||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
|
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 全部失败
|
# 全部失败
|
||||||
_summary = "; ".join(_retry_log[-3:])
|
_summary = "; ".join(_retry_log[-3:])
|
||||||
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
|
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"(AI 回复生成失败:{e})"
|
return f"(AI 回复生成失败:{e})"
|
||||||
+1341
-1341
File diff suppressed because it is too large.
Load diff
@@ -1,395 +1,395 @@
|
|||||||
"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
|
"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
|
||||||
|
|
||||||
设计目标:
|
设计目标:
|
||||||
- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
|
- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
|
||||||
- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
|
- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
|
||||||
- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
|
- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
|
||||||
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
|
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
|
||||||
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
|
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
|
||||||
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
|
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger("aiflow.orchestrator")
|
logger = logging.getLogger("aiflow.orchestrator")
|
||||||
|
|
||||||
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
|
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
|
||||||
|
|
||||||
|
|
||||||
class OrchestrationError(ValueError):
|
class OrchestrationError(ValueError):
|
||||||
"""图结构非法或执行被 halt。"""
|
"""图结构非法或执行被 halt。"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 校验 + 拓扑
|
# 校验 + 拓扑
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def validate_graph(graph: Any) -> None:
|
def validate_graph(graph: Any) -> None:
|
||||||
if not isinstance(graph, dict):
|
if not isinstance(graph, dict):
|
||||||
raise OrchestrationError("orchestration 必须为对象")
|
raise OrchestrationError("orchestration 必须为对象")
|
||||||
nodes = graph.get("nodes")
|
nodes = graph.get("nodes")
|
||||||
edges = graph.get("edges")
|
edges = graph.get("edges")
|
||||||
if not isinstance(nodes, list) or not nodes:
|
if not isinstance(nodes, list) or not nodes:
|
||||||
raise OrchestrationError("nodes 必须为非空数组")
|
raise OrchestrationError("nodes 必须为非空数组")
|
||||||
if not isinstance(edges, list):
|
if not isinstance(edges, list):
|
||||||
raise OrchestrationError("edges 必须为数组")
|
raise OrchestrationError("edges 必须为数组")
|
||||||
|
|
||||||
ids = set()
|
ids = set()
|
||||||
for i, n in enumerate(nodes):
|
for i, n in enumerate(nodes):
|
||||||
if not isinstance(n, dict):
|
if not isinstance(n, dict):
|
||||||
raise OrchestrationError(f"节点 #{i} 必须为对象")
|
raise OrchestrationError(f"节点 #{i} 必须为对象")
|
||||||
nid = n.get("id")
|
nid = n.get("id")
|
||||||
if not nid or not isinstance(nid, str):
|
if not nid or not isinstance(nid, str):
|
||||||
raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
|
raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
|
||||||
if nid in ids:
|
if nid in ids:
|
||||||
raise OrchestrationError(f"节点 id 重复: {nid}")
|
raise OrchestrationError(f"节点 id 重复: {nid}")
|
||||||
ids.add(nid)
|
ids.add(nid)
|
||||||
ntype = n.get("type")
|
ntype = n.get("type")
|
||||||
if ntype not in VALID_NODE_TYPES:
|
if ntype not in VALID_NODE_TYPES:
|
||||||
raise OrchestrationError(
|
raise OrchestrationError(
|
||||||
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
|
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
|
||||||
)
|
)
|
||||||
if ntype in ("local", "external") and not n.get("agent_id"):
|
if ntype in ("local", "external") and not n.get("agent_id"):
|
||||||
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
|
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
|
||||||
|
|
||||||
for i, e in enumerate(edges):
|
for i, e in enumerate(edges):
|
||||||
if not isinstance(e, dict):
|
if not isinstance(e, dict):
|
||||||
raise OrchestrationError(f"边 #{i} 必须为对象")
|
raise OrchestrationError(f"边 #{i} 必须为对象")
|
||||||
f = e.get("from")
|
f = e.get("from")
|
||||||
t = e.get("to")
|
t = e.get("to")
|
||||||
if f not in ids:
|
if f not in ids:
|
||||||
raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
|
raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
|
||||||
if t not in ids:
|
if t not in ids:
|
||||||
raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
|
raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
|
||||||
if f == t:
|
if f == t:
|
||||||
raise OrchestrationError(f"边 #{i} 存在自环: {f}")
|
raise OrchestrationError(f"边 #{i} 存在自环: {f}")
|
||||||
|
|
||||||
|
|
||||||
def topological_layers(graph: dict) -> list:
|
def topological_layers(graph: dict) -> list:
|
||||||
"""Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
|
"""Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
|
||||||
nodes = graph["nodes"]
|
nodes = graph["nodes"]
|
||||||
edges = graph.get("edges", [])
|
edges = graph.get("edges", [])
|
||||||
indeg = {n["id"]: 0 for n in nodes}
|
indeg = {n["id"]: 0 for n in nodes}
|
||||||
adj = {n["id"]: [] for n in nodes}
|
adj = {n["id"]: [] for n in nodes}
|
||||||
for e in edges:
|
for e in edges:
|
||||||
f, t = e["from"], e["to"]
|
f, t = e["from"], e["to"]
|
||||||
adj[f].append(t)
|
adj[f].append(t)
|
||||||
indeg[t] += 1
|
indeg[t] += 1
|
||||||
order = {n["id"]: i for i, n in enumerate(nodes)}
|
order = {n["id"]: i for i, n in enumerate(nodes)}
|
||||||
layers: list = []
|
layers: list = []
|
||||||
remaining = set(indeg)
|
remaining = set(indeg)
|
||||||
while remaining:
|
while remaining:
|
||||||
layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
|
layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
|
||||||
if not layer:
|
if not layer:
|
||||||
cycle = sorted(remaining, key=lambda x: order[x])
|
cycle = sorted(remaining, key=lambda x: order[x])
|
||||||
raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
|
raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
|
||||||
layers.append(layer)
|
layers.append(layer)
|
||||||
for nid in layer:
|
for nid in layer:
|
||||||
remaining.discard(nid)
|
remaining.discard(nid)
|
||||||
for nxt in adj[nid]:
|
for nxt in adj[nid]:
|
||||||
indeg[nxt] -= 1
|
indeg[nxt] -= 1
|
||||||
return layers
|
return layers
|
||||||
|
|
||||||
|
|
||||||
def _node_by_id(graph: dict, nid: str) -> dict:
|
def _node_by_id(graph: dict, nid: str) -> dict:
|
||||||
for n in graph["nodes"]:
|
for n in graph["nodes"]:
|
||||||
if n["id"] == nid:
|
if n["id"] == nid:
|
||||||
return n
|
return n
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _predecessors(graph: dict, nid: str) -> list:
|
def _predecessors(graph: dict, nid: str) -> list:
|
||||||
return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
|
return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
|
||||||
|
|
||||||
|
|
||||||
def _successors(graph: dict, nid: str) -> list:
|
def _successors(graph: dict, nid: str) -> list:
|
||||||
return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
|
return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
|
||||||
|
|
||||||
|
|
||||||
def _find_output_node(graph: dict) -> str:
|
def _find_output_node(graph: dict) -> str:
|
||||||
if graph.get("output_node"):
|
if graph.get("output_node"):
|
||||||
return graph["output_node"]
|
return graph["output_node"]
|
||||||
sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
|
sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
|
||||||
if len(sinks) == 1:
|
if len(sinks) == 1:
|
||||||
return sinks[0]
|
return sinks[0]
|
||||||
for s in sinks:
|
for s in sinks:
|
||||||
if _node_by_id(graph, s).get("type") == "reduce":
|
if _node_by_id(graph, s).get("type") == "reduce":
|
||||||
return s
|
return s
|
||||||
if sinks:
|
if sinks:
|
||||||
return sinks[0]
|
return sinks[0]
|
||||||
return graph["nodes"][-1]["id"]
|
return graph["nodes"][-1]["id"]
|
||||||
|
|
||||||
|
|
||||||
def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
|
def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
|
||||||
"""入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
|
"""入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
|
||||||
if is_entry:
|
if is_entry:
|
||||||
return query or ""
|
return query or ""
|
||||||
if upstream:
|
if upstream:
|
||||||
return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
|
return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
|
||||||
return query or ""
|
return query or ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 模型解析 + LLM 调用原语
|
# 模型解析 + LLM 调用原语
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _resolve_model_and_endpoint(model_id, db):
|
async def _resolve_model_and_endpoint(model_id, db):
|
||||||
from app.models import AIModel, ModelProvider
|
from app.models import AIModel, ModelProvider
|
||||||
from ..capability_registry import resolve_auto_model
|
from ..capability_registry import resolve_auto_model
|
||||||
|
|
||||||
ai_model = None
|
ai_model = None
|
||||||
if model_id and model_id != "auto":
|
if model_id and model_id != "auto":
|
||||||
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
|
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
|
||||||
if not ai_model:
|
if not ai_model:
|
||||||
ai_model = resolve_auto_model("text", db)
|
ai_model = resolve_auto_model("text", db)
|
||||||
if not ai_model:
|
if not ai_model:
|
||||||
raise OrchestrationError("无法解析到可用模型")
|
raise OrchestrationError("无法解析到可用模型")
|
||||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||||
api_url = ai_model.endpoint
|
api_url = ai_model.endpoint
|
||||||
if not api_url and provider and provider.api_base_url:
|
if not api_url and provider and provider.api_base_url:
|
||||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||||
if not api_url or not api_key:
|
if not api_url or not api_key:
|
||||||
raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
|
raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
|
||||||
return ai_model, api_url, api_key
|
return ai_model, api_url, api_key
|
||||||
|
|
||||||
|
|
||||||
async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
|
async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
|
||||||
from app.api.chat_utils import _proxy_for_url
|
from app.api.chat_utils import _proxy_for_url
|
||||||
from app.core.observability import span
|
from app.core.observability import span
|
||||||
|
|
||||||
ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
|
ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_content},
|
{"role": "user", "content": user_content},
|
||||||
]
|
]
|
||||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||||
async with span("orchestrator.llm", model=ai_model.id):
|
async with span("orchestrator.llm", model=ai_model.id):
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=ai_model.timeout or 120,
|
timeout=ai_model.timeout or 120,
|
||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
trust_env=False,
|
trust_env=False,
|
||||||
) as client:
|
) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
api_url,
|
api_url,
|
||||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
json=request_body,
|
json=request_body,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
return data["choices"][0]["message"].get("content") or ""
|
return data["choices"][0]["message"].get("content") or ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
|
# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
|
async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
|
||||||
from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
|
from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
|
||||||
from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
|
from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
|
||||||
from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
|
from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
|
||||||
from app.api.chat_utils import _proxy_for_url
|
from app.api.chat_utils import _proxy_for_url
|
||||||
|
|
||||||
skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id)
|
skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id)
|
||||||
kb_context = await _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
|
kb_context = await _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
|
||||||
system_prompt, function_definitions = _build_system_prompt(
|
system_prompt, function_definitions = _build_system_prompt(
|
||||||
skills=skills,
|
skills=skills,
|
||||||
agents=[agent],
|
agents=[agent],
|
||||||
kb_context=kb_context,
|
kb_context=kb_context,
|
||||||
user_content=node_input,
|
user_content=node_input,
|
||||||
auto_discovered=False,
|
auto_discovered=False,
|
||||||
)
|
)
|
||||||
ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
|
ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
|
||||||
tool_policy = _resolve_agent_tool_policy([agent])
|
tool_policy = _resolve_agent_tool_policy([agent])
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": node_input},
|
{"role": "user", "content": node_input},
|
||||||
]
|
]
|
||||||
called_skills: list = []
|
called_skills: list = []
|
||||||
tool_call_count = 0
|
tool_call_count = 0
|
||||||
max_tool_rounds = 5
|
max_tool_rounds = 5
|
||||||
last_content = ""
|
last_content = ""
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=ai_model.timeout or 120,
|
timeout=ai_model.timeout or 120,
|
||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
trust_env=False,
|
trust_env=False,
|
||||||
) as client:
|
) as client:
|
||||||
for round_num in range(max_tool_rounds):
|
for round_num in range(max_tool_rounds):
|
||||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||||
if function_definitions:
|
if function_definitions:
|
||||||
request_body["tools"] = [
|
request_body["tools"] = [
|
||||||
{"type": "function", "function": fd} for fd in function_definitions
|
{"type": "function", "function": fd} for fd in function_definitions
|
||||||
]
|
]
|
||||||
request_body["tool_choice"] = "auto"
|
request_body["tool_choice"] = "auto"
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
api_url,
|
api_url,
|
||||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
json=request_body,
|
json=request_body,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
message = data["choices"][0]["message"]
|
message = data["choices"][0]["message"]
|
||||||
tool_calls = message.get("tool_calls") or []
|
tool_calls = message.get("tool_calls") or []
|
||||||
content = message.get("content") or ""
|
content = message.get("content") or ""
|
||||||
last_content = content
|
last_content = content
|
||||||
if not tool_calls or not function_definitions:
|
if not tool_calls or not function_definitions:
|
||||||
return {"text": content, "used_tools": called_skills}
|
return {"text": content, "used_tools": called_skills}
|
||||||
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
|
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
|
||||||
round_tool_count = 0
|
round_tool_count = 0
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
func_name = tc.get("function", {}).get("name", "")
|
func_name = tc.get("function", {}).get("name", "")
|
||||||
check = _check_tool_policy(
|
check = _check_tool_policy(
|
||||||
func_name, None, skills, tool_policy, tool_call_count, round_tool_count
|
func_name, None, skills, tool_policy, tool_call_count, round_tool_count
|
||||||
)
|
)
|
||||||
if not check["allowed"]:
|
if not check["allowed"]:
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
||||||
"content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
|
"content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
|
||||||
})
|
})
|
||||||
tool_call_count += 1
|
tool_call_count += 1
|
||||||
round_tool_count += 1
|
round_tool_count += 1
|
||||||
continue
|
continue
|
||||||
tool_call_count += 1
|
tool_call_count += 1
|
||||||
round_tool_count += 1
|
round_tool_count += 1
|
||||||
try:
|
try:
|
||||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
|
func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
func_args = {}
|
func_args = {}
|
||||||
res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
|
res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
|
||||||
called_skills.append(func_name)
|
called_skills.append(func_name)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
||||||
"content": res,
|
"content": res,
|
||||||
})
|
})
|
||||||
return {"text": last_content, "used_tools": called_skills}
|
return {"text": last_content, "used_tools": called_skills}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 单节点执行(含独立 DB 会话,保证并发安全)
|
# 单节点执行(含独立 DB 会话,保证并发安全)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
|
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
|
||||||
from app.api.chat_skill import _resolve_market_agents
|
from app.api.chat_skill import _resolve_market_agents
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
|
|
||||||
nid = node["id"]
|
nid = node["id"]
|
||||||
ntype = node["type"]
|
ntype = node["type"]
|
||||||
preds = _predecessors(graph, nid)
|
preds = _predecessors(graph, nid)
|
||||||
pred_results = [blackboard[p] for p in preds]
|
pred_results = [blackboard[p] for p in preds]
|
||||||
is_entry = not preds
|
is_entry = not preds
|
||||||
|
|
||||||
if pred_results:
|
if pred_results:
|
||||||
parts = []
|
parts = []
|
||||||
for b in pred_results:
|
for b in pred_results:
|
||||||
label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
|
label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
|
||||||
if b.get("ok"):
|
if b.get("ok"):
|
||||||
parts.append(f"【{label}】\n{b.get('output', '')}")
|
parts.append(f"【{label}】\n{b.get('output', '')}")
|
||||||
else:
|
else:
|
||||||
parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
|
parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
|
||||||
upstream = "\n\n".join(parts)
|
upstream = "\n\n".join(parts)
|
||||||
else:
|
else:
|
||||||
upstream = ""
|
upstream = ""
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if ntype in ("llm", "reduce"):
|
if ntype in ("llm", "reduce"):
|
||||||
if ntype == "llm":
|
if ntype == "llm":
|
||||||
sys_prompt = node.get("system_prompt") or (
|
sys_prompt = node.get("system_prompt") or (
|
||||||
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
|
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
|
||||||
"只输出处理后的结果,不要多余解释。"
|
"只输出处理后的结果,不要多余解释。"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sys_prompt = node.get("system_prompt") or (
|
sys_prompt = node.get("system_prompt") or (
|
||||||
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
|
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
|
||||||
)
|
)
|
||||||
uc = _assemble_node_input(query, upstream, is_entry)
|
uc = _assemble_node_input(query, upstream, is_entry)
|
||||||
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
|
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
|
||||||
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
|
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
|
||||||
|
|
||||||
if ntype == "local":
|
if ntype == "local":
|
||||||
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
||||||
if not agents:
|
if not agents:
|
||||||
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
||||||
ni = _assemble_node_input(query, upstream, is_entry)
|
ni = _assemble_node_input(query, upstream, is_entry)
|
||||||
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
|
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"node_id": nid,
|
"node_id": nid,
|
||||||
"node_type": "local",
|
"node_type": "local",
|
||||||
"output": res["text"],
|
"output": res["text"],
|
||||||
"used_tools": res.get("used_tools"),
|
"used_tools": res.get("used_tools"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ntype == "external":
|
if ntype == "external":
|
||||||
from app.api.chat_agents import _call_external_agent
|
from app.api.chat_agents import _call_external_agent
|
||||||
|
|
||||||
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
||||||
if not agents:
|
if not agents:
|
||||||
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
||||||
ni = _assemble_node_input(query, upstream, is_entry)
|
ni = _assemble_node_input(query, upstream, is_entry)
|
||||||
answer, _ = await _call_external_agent(
|
answer, _ = await _call_external_agent(
|
||||||
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
|
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
|
||||||
)
|
)
|
||||||
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
|
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
|
||||||
|
|
||||||
raise OrchestrationError(f"未知节点类型 {ntype}")
|
raise OrchestrationError(f"未知节点类型 {ntype}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 入口
|
# 入口
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
|
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
|
||||||
"""执行编排图,返回 (answer: str, trace: dict)。
|
"""执行编排图,返回 (answer: str, trace: dict)。
|
||||||
|
|
||||||
- 拓扑分层后逐层 asyncio.gather 并行执行。
|
- 拓扑分层后逐层 asyncio.gather 并行执行。
|
||||||
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
|
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
|
||||||
"""
|
"""
|
||||||
validate_graph(graph)
|
validate_graph(graph)
|
||||||
layers = topological_layers(graph)
|
layers = topological_layers(graph)
|
||||||
blackboard: dict = {}
|
blackboard: dict = {}
|
||||||
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
|
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
|
||||||
|
|
||||||
for layer in layers:
|
for layer in layers:
|
||||||
tasks = [
|
tasks = [
|
||||||
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
|
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
|
||||||
for nid in layer
|
for nid in layer
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
for nid, res in zip(layer, results):
|
for nid, res in zip(layer, results):
|
||||||
node = _node_by_id(graph, nid)
|
node = _node_by_id(graph, nid)
|
||||||
if isinstance(res, Exception):
|
if isinstance(res, Exception):
|
||||||
on_error = node.get("on_error", "continue")
|
on_error = node.get("on_error", "continue")
|
||||||
err_msg = str(res)
|
err_msg = str(res)
|
||||||
blackboard[nid] = {
|
blackboard[nid] = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"node_id": nid,
|
"node_id": nid,
|
||||||
"node_type": node["type"],
|
"node_type": node["type"],
|
||||||
"error": err_msg,
|
"error": err_msg,
|
||||||
}
|
}
|
||||||
trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
|
trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
|
||||||
logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
|
logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
|
||||||
if on_error == "halt":
|
if on_error == "halt":
|
||||||
raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
|
raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
|
||||||
else:
|
else:
|
||||||
blackboard[nid] = res
|
blackboard[nid] = res
|
||||||
trace["nodes"].append({
|
trace["nodes"].append({
|
||||||
"id": nid,
|
"id": nid,
|
||||||
"type": res.get("node_type"),
|
"type": res.get("node_type"),
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"used_tools": res.get("used_tools"),
|
"used_tools": res.get("used_tools"),
|
||||||
})
|
})
|
||||||
|
|
||||||
output_node = _find_output_node(graph)
|
output_node = _find_output_node(graph)
|
||||||
final = blackboard.get(output_node, {})
|
final = blackboard.get(output_node, {})
|
||||||
if final.get("ok"):
|
if final.get("ok"):
|
||||||
answer = final.get("output", "")
|
answer = final.get("output", "")
|
||||||
else:
|
else:
|
||||||
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
|
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
|
||||||
trace["output_node"] = output_node
|
trace["output_node"] = output_node
|
||||||
return answer, trace
|
return answer, trace
|
||||||
+131
-131
@@ -1,131 +1,131 @@
|
|||||||
"""
|
"""
|
||||||
OpenTelemetry 桥接 (Phase 1.I 标准化)
|
OpenTelemetry 桥接 (Phase 1.I 标准化)
|
||||||
=====================================
|
=====================================
|
||||||
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §I):
|
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §I):
|
||||||
- 标准:OpenTelemetry SDK + OTLP gRPC exporter —— 云原生可观测事实标准。
|
- 标准:OpenTelemetry SDK + OTLP gRPC exporter —— 云原生可观测事实标准。
|
||||||
- traces:span 经 observability.span() 嵌套导出(见 observability.py)。
|
- traces:span 经 observability.span() 嵌套导出(见 observability.py)。
|
||||||
- metrics:MeterProvider 经 OTLP 导出 span 耗时直方图 + 调用计数,供 Grafana 出图。
|
- metrics:MeterProvider 经 OTLP 导出 span 耗时直方图 + 调用计数,供 Grafana 出图。
|
||||||
- 失败安全(生产红线):若 OTLP 端点不可达 / 初始化异常,自动降级为 no-op,
|
- 失败安全(生产红线):若 OTLP 端点不可达 / 初始化异常,自动降级为 no-op,
|
||||||
绝不阻断业务请求、不产生未捕获异常。
|
绝不阻断业务请求、不产生未捕获异常。
|
||||||
- 门控:OTEL_ENABLED=1 默认开启;置 0 则零开销、不走 OTel。
|
- 门控:OTEL_ENABLED=1 默认开启;置 0 则零开销、不走 OTel。
|
||||||
- 发射目标:OTEL_EXPORTER_OTLP_ENDPOINT,默认 http://qihu-aiflow-alloy:4317
|
- 发射目标:OTEL_EXPORTER_OTLP_ENDPOINT,默认 http://qihu-aiflow-alloy:4317
|
||||||
(经 Grafana Alloy 桥接 OTLP→Prometheus,再由 Prometheus 喂给 Grafana 出图)。
|
(经 Grafana Alloy 桥接 OTLP→Prometheus,再由 Prometheus 喂给 Grafana 出图)。
|
||||||
|
|
||||||
对外 API:
|
对外 API:
|
||||||
init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等)
|
init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等)
|
||||||
get_tracer() 返回 tracer(未启用/失败则 None)
|
get_tracer() 返回 tracer(未启用/失败则 None)
|
||||||
get_meter() 返回 meter(未启用/失败则 None)
|
get_meter() 返回 meter(未启用/失败则 None)
|
||||||
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套)
|
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套)
|
||||||
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数)
|
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ENABLED = os.getenv("OTEL_ENABLED", "1") not in ("0", "false", "False", "")
|
_ENABLED = os.getenv("OTEL_ENABLED", "1") not in ("0", "false", "False", "")
|
||||||
_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://qihu-aiflow-alloy:4317").rstrip("/")
|
_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://qihu-aiflow-alloy:4317").rstrip("/")
|
||||||
if not _ENDPOINT.startswith("http://") and not _ENDPOINT.startswith("https://"):
|
if not _ENDPOINT.startswith("http://") and not _ENDPOINT.startswith("https://"):
|
||||||
_ENDPOINT = "http://" + _ENDPOINT
|
_ENDPOINT = "http://" + _ENDPOINT
|
||||||
|
|
||||||
_tracer = None
|
_tracer = None
|
||||||
_meter = None
|
_meter = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
_duration_hist = None
|
_duration_hist = None
|
||||||
_span_counter = None
|
_span_counter = None
|
||||||
|
|
||||||
|
|
||||||
def init_otel() -> None:
|
def init_otel() -> None:
|
||||||
"""构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
|
"""构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
|
||||||
global _tracer, _meter, _initialized
|
global _tracer, _meter, _initialized
|
||||||
if _initialized:
|
if _initialized:
|
||||||
return
|
return
|
||||||
_initialized = True
|
_initialized = True
|
||||||
if not _ENABLED:
|
if not _ENABLED:
|
||||||
logger.info("[otel] disabled (OTEL_ENABLED=0)")
|
logger.info("[otel] disabled (OTEL_ENABLED=0)")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from opentelemetry import trace, metrics
|
from opentelemetry import trace, metrics
|
||||||
from opentelemetry.sdk.resources import Resource
|
from opentelemetry.sdk.resources import Resource
|
||||||
from opentelemetry.semconv.resource import ResourceAttributes
|
from opentelemetry.semconv.resource import ResourceAttributes
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||||||
OTLPSpanExporter,
|
OTLPSpanExporter,
|
||||||
)
|
)
|
||||||
from opentelemetry.sdk.metrics import MeterProvider
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
||||||
OTLPMetricExporter,
|
OTLPMetricExporter,
|
||||||
)
|
)
|
||||||
|
|
||||||
resource = Resource.create({ResourceAttributes.SERVICE_NAME: "qihu-aiflow"})
|
resource = Resource.create({ResourceAttributes.SERVICE_NAME: "qihu-aiflow"})
|
||||||
|
|
||||||
# --- traces ---
|
# --- traces ---
|
||||||
trace_provider = TracerProvider(resource=resource)
|
trace_provider = TracerProvider(resource=resource)
|
||||||
trace_provider.add_span_processor(
|
trace_provider.add_span_processor(
|
||||||
BatchSpanProcessor(OTLPSpanExporter(endpoint=_ENDPOINT, insecure=True))
|
BatchSpanProcessor(OTLPSpanExporter(endpoint=_ENDPOINT, insecure=True))
|
||||||
)
|
)
|
||||||
trace.set_tracer_provider(trace_provider)
|
trace.set_tracer_provider(trace_provider)
|
||||||
_tracer = trace.get_tracer("qihu-aiflow")
|
_tracer = trace.get_tracer("qihu-aiflow")
|
||||||
|
|
||||||
# --- metrics ---
|
# --- metrics ---
|
||||||
metric_reader = PeriodicExportingMetricReader(
|
metric_reader = PeriodicExportingMetricReader(
|
||||||
OTLPMetricExporter(endpoint=_ENDPOINT, insecure=True)
|
OTLPMetricExporter(endpoint=_ENDPOINT, insecure=True)
|
||||||
)
|
)
|
||||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||||
metrics.set_meter_provider(meter_provider)
|
metrics.set_meter_provider(meter_provider)
|
||||||
_meter = metrics.get_meter("qihu-aiflow")
|
_meter = metrics.get_meter("qihu-aiflow")
|
||||||
|
|
||||||
logger.info(f"[otel] tracer+meter ready -> {_ENDPOINT}")
|
logger.info(f"[otel] tracer+meter ready -> {_ENDPOINT}")
|
||||||
except Exception as e: # pragma: no cover - 降级保护
|
except Exception as e: # pragma: no cover - 降级保护
|
||||||
logger.warning(f"[otel] init failed, degraded to no-op: {e}")
|
logger.warning(f"[otel] init failed, degraded to no-op: {e}")
|
||||||
_tracer = None
|
_tracer = None
|
||||||
_meter = None
|
_meter = None
|
||||||
|
|
||||||
|
|
||||||
def get_tracer():
|
def get_tracer():
|
||||||
"""返回 tracer;首次调用触发 init。未启用/失败返回 None。"""
|
"""返回 tracer;首次调用触发 init。未启用/失败返回 None。"""
|
||||||
if _tracer is None and _ENABLED:
|
if _tracer is None and _ENABLED:
|
||||||
init_otel()
|
init_otel()
|
||||||
return _tracer
|
return _tracer
|
||||||
|
|
||||||
|
|
||||||
def get_meter():
|
def get_meter():
|
||||||
"""返回 meter;首次调用触发 init。未启用/失败返回 None。"""
|
"""返回 meter;首次调用触发 init。未启用/失败返回 None。"""
|
||||||
if _meter is None and _ENABLED:
|
if _meter is None and _ENABLED:
|
||||||
init_otel()
|
init_otel()
|
||||||
return _meter
|
return _meter
|
||||||
|
|
||||||
|
|
||||||
def current_span_cm(name: str, attributes: dict | None = None):
|
def current_span_cm(name: str, attributes: dict | None = None):
|
||||||
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
|
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
|
||||||
tr = get_tracer()
|
tr = get_tracer()
|
||||||
if tr is None:
|
if tr is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return tr.start_as_current_span(name, attributes=attributes or {})
|
return tr.start_as_current_span(name, attributes=attributes or {})
|
||||||
except Exception as e: # pragma: no cover
|
except Exception as e: # pragma: no cover
|
||||||
logger.warning(f"[otel] start_span failed: {e}")
|
logger.warning(f"[otel] start_span failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
|
def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
|
||||||
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
|
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
|
||||||
m = get_meter()
|
m = get_meter()
|
||||||
if m is None:
|
if m is None:
|
||||||
return
|
return
|
||||||
global _duration_hist, _span_counter
|
global _duration_hist, _span_counter
|
||||||
try:
|
try:
|
||||||
if _duration_hist is None:
|
if _duration_hist is None:
|
||||||
_duration_hist = m.create_histogram(
|
_duration_hist = m.create_histogram(
|
||||||
"aiflow_span_duration_ms", "Span duration in milliseconds"
|
"aiflow_span_duration_ms", "Span duration in milliseconds"
|
||||||
)
|
)
|
||||||
_span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
|
_span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
|
||||||
attrs = {"span": name, "status": status}
|
attrs = {"span": name, "status": status}
|
||||||
_duration_hist.record(elapsed_ms, attrs)
|
_duration_hist.record(elapsed_ms, attrs)
|
||||||
_span_counter.add(1, attrs)
|
_span_counter.add(1, attrs)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
+271
-271
@@ -1,271 +1,271 @@
|
|||||||
"""
|
"""
|
||||||
知识库路由层
|
知识库路由层
|
||||||
============
|
============
|
||||||
将检索请求智能路由到本地BM25、向量检索、或外部KB服务(Dify)。
|
将检索请求智能路由到本地BM25、向量检索、或外部KB服务(Dify)。
|
||||||
根据知识库配置自动选择最优检索策略。
|
根据知识库配置自动选择最优检索策略。
|
||||||
|
|
||||||
使用方式:
|
使用方式:
|
||||||
from app.kb_router import kb_router_search
|
from app.kb_router import kb_router_search
|
||||||
results = await kb_router_search(query, kb_id, top_k=5)
|
results = await kb_router_search(query, kb_id, top_k=5)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger("aiflow.kb_router")
|
logger = logging.getLogger("aiflow.kb_router")
|
||||||
|
|
||||||
|
|
||||||
async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
|
async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
|
||||||
"""获取已配置的外部KB服务(仅 dify)"""
|
"""获取已配置的外部KB服务(仅 dify)"""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM external_kb_services"
|
query = "SELECT * FROM external_kb_services"
|
||||||
if enabled_only:
|
if enabled_only:
|
||||||
query += " WHERE is_enabled = 1"
|
query += " WHERE is_enabled = 1"
|
||||||
query += " ORDER BY created_at DESC"
|
query += " ORDER BY created_at DESC"
|
||||||
result = db_session.execute(text(query))
|
result = db_session.execute(text(query))
|
||||||
columns = result.keys()
|
columns = result.keys()
|
||||||
return [dict(zip(columns, row)) for row in result.fetchall()]
|
return [dict(zip(columns, row)) for row in result.fetchall()]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get external services: {e}")
|
logger.error(f"Failed to get external services: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _search_dify(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
async def _search_dify(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
"""检索 Dify 知识库(走 app.dify_kb 客户端)"""
|
"""检索 Dify 知识库(走 app.dify_kb 客户端)"""
|
||||||
try:
|
try:
|
||||||
from app.dify_kb import dify_retrieve
|
from app.dify_kb import dify_retrieve
|
||||||
api_url = service.get("api_url", "")
|
api_url = service.get("api_url", "")
|
||||||
api_key = service.get("api_key", "")
|
api_key = service.get("api_key", "")
|
||||||
dataset_name = None
|
dataset_name = None
|
||||||
cfg = service.get("config")
|
cfg = service.get("config")
|
||||||
if isinstance(cfg, str):
|
if isinstance(cfg, str):
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
cfg = json.loads(cfg)
|
cfg = json.loads(cfg)
|
||||||
except Exception:
|
except Exception:
|
||||||
cfg = None
|
cfg = None
|
||||||
if isinstance(cfg, dict):
|
if isinstance(cfg, dict):
|
||||||
dataset_name = cfg.get("dataset_name")
|
dataset_name = cfg.get("dataset_name")
|
||||||
hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
|
hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
|
||||||
return hits
|
return hits
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Dify search error: {e}")
|
logger.error(f"Dify search error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
"""路由到对应的外部KB服务(当前仅支持 dify)"""
|
"""路由到对应的外部KB服务(当前仅支持 dify)"""
|
||||||
service_type = service.get("service_type", "")
|
service_type = service.get("service_type", "")
|
||||||
if service_type == "dify":
|
if service_type == "dify":
|
||||||
return await _search_dify(service, query, top_k)
|
return await _search_dify(service, query, top_k)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unknown external KB type: {service_type}")
|
logger.warning(f"Unknown external KB type: {service_type}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def _get_web_services(db_session, enabled_only: bool = True) -> List[Dict]:
|
async def _get_web_services(db_session, enabled_only: bool = True) -> List[Dict]:
|
||||||
"""获取全局 AnySearch 联网检索配置(从 site_settings 读取)。"""
|
"""获取全局 AnySearch 联网检索配置(从 site_settings 读取)。"""
|
||||||
from app.anysearch_client import load_anysearch_config
|
from app.anysearch_client import load_anysearch_config
|
||||||
cfg = load_anysearch_config(db_session)
|
cfg = load_anysearch_config(db_session)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
return []
|
return []
|
||||||
if enabled_only and not cfg.get("is_enabled"):
|
if enabled_only and not cfg.get("is_enabled"):
|
||||||
return []
|
return []
|
||||||
api_key = cfg.get("api_key", "")
|
api_key = cfg.get("api_key", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return []
|
return []
|
||||||
return [{
|
return [{
|
||||||
"id": "anysearch_global",
|
"id": "anysearch_global",
|
||||||
"service_type": "anysearch",
|
"service_type": "anysearch",
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"api_url": cfg.get("api_url") or "https://api.anysearch.com/v1/search",
|
"api_url": cfg.get("api_url") or "https://api.anysearch.com/v1/search",
|
||||||
"config": {
|
"config": {
|
||||||
"zone": cfg.get("zone", "cn"),
|
"zone": cfg.get("zone", "cn"),
|
||||||
"freshness": cfg.get("freshness"),
|
"freshness": cfg.get("freshness"),
|
||||||
"top_k": cfg.get("top_k", 5),
|
"top_k": cfg.get("top_k", 5),
|
||||||
},
|
},
|
||||||
"is_enabled": cfg.get("is_enabled", False),
|
"is_enabled": cfg.get("is_enabled", False),
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
"""调用 AnySearch 联网检索(走 app.anysearch_client)。"""
|
"""调用 AnySearch 联网检索(走 app.anysearch_client)。"""
|
||||||
try:
|
try:
|
||||||
from app.anysearch_client import anysearch_search
|
from app.anysearch_client import anysearch_search
|
||||||
api_key = service.get("api_key", "")
|
api_key = service.get("api_key", "")
|
||||||
api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
|
api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
|
||||||
cfg = service.get("config")
|
cfg = service.get("config")
|
||||||
if isinstance(cfg, str):
|
if isinstance(cfg, str):
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
cfg = json.loads(cfg)
|
cfg = json.loads(cfg)
|
||||||
except Exception:
|
except Exception:
|
||||||
cfg = None
|
cfg = None
|
||||||
cfg = cfg or {}
|
cfg = cfg or {}
|
||||||
zone = cfg.get("zone", "cn")
|
zone = cfg.get("zone", "cn")
|
||||||
freshness = cfg.get("freshness")
|
freshness = cfg.get("freshness")
|
||||||
k = int(cfg.get("top_k", top_k))
|
k = int(cfg.get("top_k", top_k))
|
||||||
return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
|
return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AnySearch search error: {e}")
|
logger.error(f"AnySearch search error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def kb_router_search(
|
async def kb_router_search(
|
||||||
query: str,
|
query: str,
|
||||||
kb_id: Optional[str] = None,
|
kb_id: Optional[str] = None,
|
||||||
kb_ids: Optional[List[str]] = None,
|
kb_ids: Optional[List[str]] = None,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
bm25_results: Optional[List[Dict]] = None,
|
bm25_results: Optional[List[Dict]] = None,
|
||||||
db_session=None,
|
db_session=None,
|
||||||
use_vector: bool = True,
|
use_vector: bool = True,
|
||||||
use_external: bool = True,
|
use_external: bool = True,
|
||||||
use_tree: bool = True,
|
use_tree: bool = True,
|
||||||
use_web: bool = False,
|
use_web: bool = False,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
知识库路由层 — 混合检索
|
知识库路由层 — 混合检索
|
||||||
|
|
||||||
检索策略:
|
检索策略:
|
||||||
1. 本地 BM25 检索 (MySQL, 已有)
|
1. 本地 BM25 检索 (MySQL, 已有)
|
||||||
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
|
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
|
||||||
3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
|
3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
|
||||||
4. 外部KB检索 (Dify, 按配置)
|
4. 外部KB检索 (Dify, 按配置)
|
||||||
5. 联网检索 (AnySearch, 可选 use_web)
|
5. 联网检索 (AnySearch, 可选 use_web)
|
||||||
|
|
||||||
融合方式: Reciprocal Rank Fusion (RRF)
|
融合方式: Reciprocal Rank Fusion (RRF)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 查询文本
|
query: 查询文本
|
||||||
kb_id: 知识库ID
|
kb_id: 知识库ID
|
||||||
top_k: 返回结果数
|
top_k: 返回结果数
|
||||||
bm25_results: BM25检索结果
|
bm25_results: BM25检索结果
|
||||||
db_session: 数据库会话(用于获取外部KB配置与分片段检索)
|
db_session: 数据库会话(用于获取外部KB配置与分片段检索)
|
||||||
use_vector: 是否使用向量检索
|
use_vector: 是否使用向量检索
|
||||||
use_external: 是否使用外部KB检索
|
use_external: 是否使用外部KB检索
|
||||||
use_tree: 是否使用 TreeSearch 结构化分片段检索
|
use_tree: 是否使用 TreeSearch 结构化分片段检索
|
||||||
use_web: 是否使用 AnySearch 联网检索
|
use_web: 是否使用 AnySearch 联网检索
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
融合后的检索结果列表
|
融合后的检索结果列表
|
||||||
"""
|
"""
|
||||||
all_results = []
|
all_results = []
|
||||||
|
|
||||||
# 1. BM25 结果
|
# 1. BM25 结果
|
||||||
if bm25_results:
|
if bm25_results:
|
||||||
for rank, item in enumerate(bm25_results):
|
for rank, item in enumerate(bm25_results):
|
||||||
all_results.append({
|
all_results.append({
|
||||||
"content": item.get("content", item.get("text", "")),
|
"content": item.get("content", item.get("text", "")),
|
||||||
"score": item.get("score", 1.0 / (60 + rank + 1)),
|
"score": item.get("score", 1.0 / (60 + rank + 1)),
|
||||||
"source": "local_bm25",
|
"source": "local_bm25",
|
||||||
"rank": rank + 1,
|
"rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. 向量检索
|
# 2. 向量检索
|
||||||
if use_vector:
|
if use_vector:
|
||||||
try:
|
try:
|
||||||
from app.pgvector_search import vector_search
|
from app.pgvector_search import vector_search
|
||||||
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
|
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
|
||||||
vec_results = []
|
vec_results = []
|
||||||
for _kid in kb_targets:
|
for _kid in kb_targets:
|
||||||
try:
|
try:
|
||||||
vec_results.extend(await vector_search(query, _kid, top_k))
|
vec_results.extend(await vector_search(query, _kid, top_k))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Vector search failed for kb={_kid}: {e}")
|
logger.warning(f"Vector search failed for kb={_kid}: {e}")
|
||||||
for rank, item in enumerate(vec_results):
|
for rank, item in enumerate(vec_results):
|
||||||
all_results.append({
|
all_results.append({
|
||||||
"content": item.get("content", ""),
|
"content": item.get("content", ""),
|
||||||
"score": item.get("score", 0.5),
|
"score": item.get("score", 0.5),
|
||||||
"source": "vector_pg",
|
"source": "vector_pg",
|
||||||
"rank": rank + 1,
|
"rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
})
|
})
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug("pgvector_search not available, skipping vector search")
|
logger.debug("pgvector_search not available, skipping vector search")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Vector search failed: {e}")
|
logger.warning(f"Vector search failed: {e}")
|
||||||
|
|
||||||
# 2.5 结构化分片段检索 (TreeSearch, Level 2)
|
# 2.5 结构化分片段检索 (TreeSearch, Level 2)
|
||||||
if use_tree and db_session is not None:
|
if use_tree and db_session is not None:
|
||||||
try:
|
try:
|
||||||
from app.tree_search import tree_search
|
from app.tree_search import tree_search
|
||||||
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
|
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
|
||||||
tree_hits = await tree_search(
|
tree_hits = await tree_search(
|
||||||
db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
|
db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
|
||||||
)
|
)
|
||||||
for rank, item in enumerate(tree_hits):
|
for rank, item in enumerate(tree_hits):
|
||||||
all_results.append({
|
all_results.append({
|
||||||
"content": item.get("content", ""),
|
"content": item.get("content", ""),
|
||||||
"score": item.get("score", 0.5),
|
"score": item.get("score", 0.5),
|
||||||
"source": "tree_chunk",
|
"source": "tree_chunk",
|
||||||
"rank": rank + 1,
|
"rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"TreeSearch failed: {e}")
|
logger.warning(f"TreeSearch failed: {e}")
|
||||||
|
|
||||||
# 3. 外部KB检索 (Dify)
|
# 3. 外部KB检索 (Dify)
|
||||||
if use_external and db_session:
|
if use_external and db_session:
|
||||||
services = await _get_external_services(db_session)
|
services = await _get_external_services(db_session)
|
||||||
for service in services:
|
for service in services:
|
||||||
if service.get("status") == "connected":
|
if service.get("status") == "connected":
|
||||||
ext_results = await _search_external(service, query, top_k)
|
ext_results = await _search_external(service, query, top_k)
|
||||||
for rank, item in enumerate(ext_results):
|
for rank, item in enumerate(ext_results):
|
||||||
all_results.append({
|
all_results.append({
|
||||||
"content": item.get("content", ""),
|
"content": item.get("content", ""),
|
||||||
"score": item.get("score", 0.5),
|
"score": item.get("score", 0.5),
|
||||||
"source": item.get("source", "external_dify"),
|
"source": item.get("source", "external_dify"),
|
||||||
"rank": rank + 1,
|
"rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
# 4. 联网检索 (AnySearch, 可选)
|
# 4. 联网检索 (AnySearch, 可选)
|
||||||
if use_web and db_session is not None:
|
if use_web and db_session is not None:
|
||||||
try:
|
try:
|
||||||
web_services = await _get_web_services(db_session)
|
web_services = await _get_web_services(db_session)
|
||||||
for svc in web_services:
|
for svc in web_services:
|
||||||
web_hits = await _search_anysearch(svc, query, top_k)
|
web_hits = await _search_anysearch(svc, query, top_k)
|
||||||
for rank, item in enumerate(web_hits):
|
for rank, item in enumerate(web_hits):
|
||||||
all_results.append({
|
all_results.append({
|
||||||
"content": item.get("content", ""),
|
"content": item.get("content", ""),
|
||||||
"score": item.get("score", 0.6),
|
"score": item.get("score", 0.6),
|
||||||
"source": item.get("source", "web_anysearch"),
|
"source": item.get("source", "web_anysearch"),
|
||||||
"rank": rank + 1,
|
"rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"AnySearch web search failed: {e}")
|
logger.warning(f"AnySearch web search failed: {e}")
|
||||||
|
|
||||||
if not all_results:
|
if not all_results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# RRF 融合
|
# RRF 融合
|
||||||
rrf_k = 60
|
rrf_k = 60
|
||||||
score_map = {}
|
score_map = {}
|
||||||
for item in all_results:
|
for item in all_results:
|
||||||
content_key = item["content"][:200] # 用内容前200字符作为去重键
|
content_key = item["content"][:200] # 用内容前200字符作为去重键
|
||||||
rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
|
rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
|
||||||
|
|
||||||
if content_key in score_map:
|
if content_key in score_map:
|
||||||
score_map[content_key]["score"] += rrf_score
|
score_map[content_key]["score"] += rrf_score
|
||||||
score_map[content_key]["sources"].append(item["source"])
|
score_map[content_key]["sources"].append(item["source"])
|
||||||
else:
|
else:
|
||||||
score_map[content_key] = {
|
score_map[content_key] = {
|
||||||
"content": item["content"],
|
"content": item["content"],
|
||||||
"score": rrf_score,
|
"score": rrf_score,
|
||||||
"sources": [item["source"]],
|
"sources": [item["source"]],
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 按融合分数排序
|
# 按融合分数排序
|
||||||
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
||||||
|
|
||||||
# 标准化分数到 0-1
|
# 标准化分数到 0-1
|
||||||
if merged:
|
if merged:
|
||||||
max_score = merged[0]["score"]
|
max_score = merged[0]["score"]
|
||||||
for item in merged:
|
for item in merged:
|
||||||
item["score"] = item["score"] / max_score if max_score > 0 else 0
|
item["score"] = item["score"] / max_score if max_score > 0 else 0
|
||||||
item["sources"] = list(set(item["sources"]))
|
item["sources"] = list(set(item["sources"]))
|
||||||
|
|
||||||
return merged[:top_k]
|
return merged[:top_k]
|
||||||
@@ -1,379 +1,379 @@
|
|||||||
"""
|
"""
|
||||||
PGVector 向量检索模块
|
PGVector 向量检索模块
|
||||||
====================
|
====================
|
||||||
使用 PostgreSQL + pgvector 实现语义向量检索,
|
使用 PostgreSQL + pgvector 实现语义向量检索,
|
||||||
与现有 MySQL BM25 检索配合,实现混合检索。
|
与现有 MySQL BM25 检索配合,实现混合检索。
|
||||||
|
|
||||||
使用方式:
|
使用方式:
|
||||||
from app.pgvector_search import vector_search
|
from app.pgvector_search import vector_search
|
||||||
results = await vector_search(query, kb_id, top_k=5)
|
results = await vector_search(query, kb_id, top_k=5)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger("aiflow.pgvector")
|
logger = logging.getLogger("aiflow.pgvector")
|
||||||
|
|
||||||
# PostgreSQL 连接配置
|
# PostgreSQL 连接配置
|
||||||
PG_HOST = os.environ.get("POSTGRES_HOST", "qihu-aiflow-pgvector")
|
PG_HOST = os.environ.get("POSTGRES_HOST", "qihu-aiflow-pgvector")
|
||||||
PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
|
PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
|
||||||
PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
|
PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
|
||||||
PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026")
|
PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026")
|
||||||
PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
|
PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
|
||||||
|
|
||||||
# 嵌入模型配置
|
# 嵌入模型配置
|
||||||
EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
|
EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
|
||||||
EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
|
EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
|
||||||
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5")
|
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5")
|
||||||
|
|
||||||
# ---- 本地嵌入缓存(SQLite 持久化):破解"每次嵌入消耗外部 API 额度"的隐性成本障碍 ----
|
# ---- 本地嵌入缓存(SQLite 持久化):破解"每次嵌入消耗外部 API 额度"的隐性成本障碍 ----
|
||||||
import sqlite3, hashlib, json as _json
|
import sqlite3, hashlib, json as _json
|
||||||
_EMBED_CACHE_DB = os.environ.get("EMBED_CACHE_DB", "/app/backend/python/.embed_cache.db")
|
_EMBED_CACHE_DB = os.environ.get("EMBED_CACHE_DB", "/app/backend/python/.embed_cache.db")
|
||||||
def _embed_cache_get(text, model):
|
def _embed_cache_get(text, model):
|
||||||
try:
|
try:
|
||||||
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
||||||
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
||||||
row = conn.execute("SELECT vec FROM embed_cache WHERE k=?", (key,)).fetchone()
|
row = conn.execute("SELECT vec FROM embed_cache WHERE k=?", (key,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if row:
|
if row:
|
||||||
return _json.loads(row[0])
|
return _json.loads(row[0])
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
def _embed_cache_put(text, model, vec):
|
def _embed_cache_put(text, model, vec):
|
||||||
try:
|
try:
|
||||||
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
||||||
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS embed_cache (k TEXT PRIMARY KEY, vec TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
conn.execute("CREATE TABLE IF NOT EXISTS embed_cache (k TEXT PRIMARY KEY, vec TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||||
conn.execute("INSERT OR REPLACE INTO embed_cache (k, vec) VALUES (?, ?)", (key, _json.dumps(vec)))
|
conn.execute("INSERT OR REPLACE INTO embed_cache (k, vec) VALUES (?, ?)", (key, _json.dumps(vec)))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 连接池
|
# 连接池
|
||||||
_pg_pool = None
|
_pg_pool = None
|
||||||
_pool_lock = asyncio.Lock()
|
_pool_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def get_pg_pool():
|
async def get_pg_pool():
|
||||||
"""获取 PostgreSQL 异步连接池"""
|
"""获取 PostgreSQL 异步连接池"""
|
||||||
global _pg_pool
|
global _pg_pool
|
||||||
if _pg_pool is not None:
|
if _pg_pool is not None:
|
||||||
return _pg_pool
|
return _pg_pool
|
||||||
|
|
||||||
async with _pool_lock:
|
async with _pool_lock:
|
||||||
if _pg_pool is not None:
|
if _pg_pool is not None:
|
||||||
return _pg_pool
|
return _pg_pool
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import asyncpg
|
import asyncpg
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("asyncpg not installed, vector search disabled")
|
logger.warning("asyncpg not installed, vector search disabled")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_pg_pool = await asyncpg.create_pool(
|
_pg_pool = await asyncpg.create_pool(
|
||||||
host=PG_HOST,
|
host=PG_HOST,
|
||||||
port=PG_PORT,
|
port=PG_PORT,
|
||||||
user=PG_USER,
|
user=PG_USER,
|
||||||
password=PG_PASSWORD,
|
password=PG_PASSWORD,
|
||||||
database=PG_DB,
|
database=PG_DB,
|
||||||
min_size=2,
|
min_size=2,
|
||||||
max_size=10,
|
max_size=10,
|
||||||
command_timeout=30,
|
command_timeout=30,
|
||||||
)
|
)
|
||||||
logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
|
logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
|
||||||
|
|
||||||
# 初始化表结构
|
# 初始化表结构
|
||||||
async with _pg_pool.acquire() as conn:
|
async with _pg_pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS document_embeddings (
|
CREATE TABLE IF NOT EXISTS document_embeddings (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
doc_id VARCHAR(64) NOT NULL,
|
doc_id VARCHAR(64) NOT NULL,
|
||||||
kb_id VARCHAR(64) NOT NULL,
|
kb_id VARCHAR(64) NOT NULL,
|
||||||
chunk_id VARCHAR(64),
|
chunk_id VARCHAR(64),
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
embedding vector(1024),
|
embedding vector(1024),
|
||||||
metadata JSONB DEFAULT '{}',
|
metadata JSONB DEFAULT '{}',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_embeddings_kb
|
CREATE INDEX IF NOT EXISTS idx_embeddings_kb
|
||||||
ON document_embeddings(kb_id);
|
ON document_embeddings(kb_id);
|
||||||
""")
|
""")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_embeddings_doc
|
CREATE INDEX IF NOT EXISTS idx_embeddings_doc
|
||||||
ON document_embeddings(doc_id);
|
ON document_embeddings(doc_id);
|
||||||
""")
|
""")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_embeddings_vector
|
CREATE INDEX IF NOT EXISTS idx_embeddings_vector
|
||||||
ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
|
ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
|
||||||
WITH (lists = 100);
|
WITH (lists = 100);
|
||||||
""")
|
""")
|
||||||
logger.info("PGVector tables initialized")
|
logger.info("PGVector tables initialized")
|
||||||
|
|
||||||
return _pg_pool
|
return _pg_pool
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
||||||
_pg_pool = None
|
_pg_pool = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_embedding(text: str) -> Optional[List[float]]:
|
async def get_embedding(text: str) -> Optional[List[float]]:
|
||||||
"""调用嵌入API获取文本向量"""
|
"""调用嵌入API获取文本向量"""
|
||||||
if not EMBEDDING_API_KEY:
|
if not EMBEDDING_API_KEY:
|
||||||
logger.warning("No embedding API key configured")
|
logger.warning("No embedding API key configured")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cached = _embed_cache_get(text, EMBEDDING_MODEL)
|
cached = _embed_cache_get(text, EMBEDDING_MODEL)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
try:
|
try:
|
||||||
# 收敛到共享标准模型层(app.core.llm_client.aembed),与聊天共用代理/超时/failover 语义
|
# 收敛到共享标准模型层(app.core.llm_client.aembed),与聊天共用代理/超时/failover 语义
|
||||||
from app.core.llm_client import aembed
|
from app.core.llm_client import aembed
|
||||||
embedding = await aembed(
|
embedding = await aembed(
|
||||||
EMBEDDING_API_URL, EMBEDDING_API_KEY, text[:8000], EMBEDDING_MODEL, timeout=30
|
EMBEDDING_API_URL, EMBEDDING_API_KEY, text[:8000], EMBEDDING_MODEL, timeout=30
|
||||||
)
|
)
|
||||||
if embedding:
|
if embedding:
|
||||||
_embed_cache_put(text, EMBEDDING_MODEL, embedding)
|
_embed_cache_put(text, EMBEDDING_MODEL, embedding)
|
||||||
return embedding
|
return embedding
|
||||||
logger.error("Embedding API returned empty")
|
logger.error("Embedding API returned empty")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Embedding generation failed: {e}")
|
logger.error(f"Embedding generation failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def vector_search(
|
async def vector_search(
|
||||||
query: str,
|
query: str,
|
||||||
kb_id: Optional[str] = None,
|
kb_id: Optional[str] = None,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
threshold: float = 0.5,
|
threshold: float = 0.5,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
向量语义检索
|
向量语义检索
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 查询文本
|
query: 查询文本
|
||||||
kb_id: 知识库ID (可选,不指定则搜索全部)
|
kb_id: 知识库ID (可选,不指定则搜索全部)
|
||||||
top_k: 返回结果数
|
top_k: 返回结果数
|
||||||
threshold: 相似度阈值 (0-1)
|
threshold: 相似度阈值 (0-1)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
[{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
|
[{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
|
||||||
"""
|
"""
|
||||||
pool = await get_pg_pool()
|
pool = await get_pg_pool()
|
||||||
if pool is None:
|
if pool is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 获取查询向量
|
# 获取查询向量
|
||||||
query_embedding = await get_embedding(query)
|
query_embedding = await get_embedding(query)
|
||||||
if query_embedding is None:
|
if query_embedding is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# 将向量转换为 PostgreSQL 格式
|
# 将向量转换为 PostgreSQL 格式
|
||||||
embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
|
embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
|
||||||
|
|
||||||
if kb_id:
|
if kb_id:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT doc_id, chunk_id, content, metadata,
|
SELECT doc_id, chunk_id, content, metadata,
|
||||||
1 - (embedding <=> $1::vector) as score
|
1 - (embedding <=> $1::vector) as score
|
||||||
FROM document_embeddings
|
FROM document_embeddings
|
||||||
WHERE kb_id = $2
|
WHERE kb_id = $2
|
||||||
ORDER BY embedding <=> $1::vector
|
ORDER BY embedding <=> $1::vector
|
||||||
LIMIT $3;
|
LIMIT $3;
|
||||||
""",
|
""",
|
||||||
embedding_str, kb_id, top_k,
|
embedding_str, kb_id, top_k,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT doc_id, chunk_id, content, metadata,
|
SELECT doc_id, chunk_id, content, metadata,
|
||||||
1 - (embedding <=> $1::vector) as score
|
1 - (embedding <=> $1::vector) as score
|
||||||
FROM document_embeddings
|
FROM document_embeddings
|
||||||
ORDER BY embedding <=> $1::vector
|
ORDER BY embedding <=> $1::vector
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
""",
|
""",
|
||||||
embedding_str, top_k,
|
embedding_str, top_k,
|
||||||
)
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
score = float(row["score"])
|
score = float(row["score"])
|
||||||
if score >= threshold:
|
if score >= threshold:
|
||||||
results.append({
|
results.append({
|
||||||
"doc_id": row["doc_id"],
|
"doc_id": row["doc_id"],
|
||||||
"chunk_id": row["chunk_id"],
|
"chunk_id": row["chunk_id"],
|
||||||
"content": row["content"],
|
"content": row["content"],
|
||||||
"score": score,
|
"score": score,
|
||||||
"metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
|
"metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
|
||||||
})
|
})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Vector search failed: {e}")
|
logger.error(f"Vector search failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def index_document(
|
async def index_document(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
kb_id: str,
|
kb_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
chunk_id: Optional[str] = None,
|
chunk_id: Optional[str] = None,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
将文档内容索引到 PGVector
|
将文档内容索引到 PGVector
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
doc_id: 文档ID
|
doc_id: 文档ID
|
||||||
kb_id: 知识库ID
|
kb_id: 知识库ID
|
||||||
content: 文档内容
|
content: 文档内容
|
||||||
chunk_id: 分块ID (可选)
|
chunk_id: 分块ID (可选)
|
||||||
metadata: 元数据
|
metadata: 元数据
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
是否成功
|
是否成功
|
||||||
"""
|
"""
|
||||||
pool = await get_pg_pool()
|
pool = await get_pg_pool()
|
||||||
if pool is None:
|
if pool is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 分块处理(简单按长度分块)
|
# 分块处理(简单按长度分块)
|
||||||
chunks = []
|
chunks = []
|
||||||
chunk_size = 500
|
chunk_size = 500
|
||||||
overlap = 50
|
overlap = 50
|
||||||
if len(content) <= chunk_size:
|
if len(content) <= chunk_size:
|
||||||
chunks = [(content, chunk_id or f"{doc_id}_0")]
|
chunks = [(content, chunk_id or f"{doc_id}_0")]
|
||||||
else:
|
else:
|
||||||
for i in range(0, len(content), chunk_size - overlap):
|
for i in range(0, len(content), chunk_size - overlap):
|
||||||
chunk = content[i:i + chunk_size]
|
chunk = content[i:i + chunk_size]
|
||||||
if len(chunk.strip()) > 10:
|
if len(chunk.strip()) > 10:
|
||||||
chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
|
chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
|
||||||
|
|
||||||
success_count = 0
|
success_count = 0
|
||||||
for chunk_text, cid in chunks:
|
for chunk_text, cid in chunks:
|
||||||
embedding = await get_embedding(chunk_text)
|
embedding = await get_embedding(chunk_text)
|
||||||
if embedding is None:
|
if embedding is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
||||||
import json
|
import json
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
|
INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
|
||||||
VALUES ($1, $2, $3, $4, $5::vector, $6)
|
VALUES ($1, $2, $3, $4, $5::vector, $6)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
""",
|
""",
|
||||||
doc_id, kb_id, cid, chunk_text, embedding_str,
|
doc_id, kb_id, cid, chunk_text, embedding_str,
|
||||||
json.dumps(metadata or {}),
|
json.dumps(metadata or {}),
|
||||||
)
|
)
|
||||||
success_count += 1
|
success_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to index chunk {cid}: {e}")
|
logger.error(f"Failed to index chunk {cid}: {e}")
|
||||||
|
|
||||||
logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
|
logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
|
||||||
return success_count > 0
|
return success_count > 0
|
||||||
|
|
||||||
|
|
||||||
async def hybrid_search(
|
async def hybrid_search(
|
||||||
query: str,
|
query: str,
|
||||||
kb_id: Optional[str] = None,
|
kb_id: Optional[str] = None,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
bm25_results: Optional[List[Dict]] = None,
|
bm25_results: Optional[List[Dict]] = None,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
混合检索:BM25 + 向量检索融合
|
混合检索:BM25 + 向量检索融合
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 查询文本
|
query: 查询文本
|
||||||
kb_id: 知识库ID
|
kb_id: 知识库ID
|
||||||
top_k: 返回结果数
|
top_k: 返回结果数
|
||||||
bm25_results: BM25 检索结果(来自MySQL)
|
bm25_results: BM25 检索结果(来自MySQL)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
融合后的检索结果
|
融合后的检索结果
|
||||||
"""
|
"""
|
||||||
# 获取向量检索结果
|
# 获取向量检索结果
|
||||||
vec_results = await vector_search(query, kb_id, top_k)
|
vec_results = await vector_search(query, kb_id, top_k)
|
||||||
|
|
||||||
if not vec_results and not bm25_results:
|
if not vec_results and not bm25_results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not vec_results:
|
if not vec_results:
|
||||||
return bm25_results or []
|
return bm25_results or []
|
||||||
|
|
||||||
if not bm25_results:
|
if not bm25_results:
|
||||||
return vec_results
|
return vec_results
|
||||||
|
|
||||||
# 融合:Reciprocal Rank Fusion (RRF)
|
# 融合:Reciprocal Rank Fusion (RRF)
|
||||||
rrf_k = 60
|
rrf_k = 60
|
||||||
score_map = {}
|
score_map = {}
|
||||||
|
|
||||||
for rank, item in enumerate(bm25_results):
|
for rank, item in enumerate(bm25_results):
|
||||||
doc_id = item.get("doc_id", item.get("id", ""))
|
doc_id = item.get("doc_id", item.get("id", ""))
|
||||||
score_map[doc_id] = {
|
score_map[doc_id] = {
|
||||||
"content": item.get("content", item.get("text", "")),
|
"content": item.get("content", item.get("text", "")),
|
||||||
"score": 1.0 / (rrf_k + rank + 1),
|
"score": 1.0 / (rrf_k + rank + 1),
|
||||||
"bm25_rank": rank + 1,
|
"bm25_rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
for rank, item in enumerate(vec_results):
|
for rank, item in enumerate(vec_results):
|
||||||
doc_id = item["doc_id"]
|
doc_id = item["doc_id"]
|
||||||
if doc_id in score_map:
|
if doc_id in score_map:
|
||||||
score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
|
score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
|
||||||
score_map[doc_id]["vec_score"] = item["score"]
|
score_map[doc_id]["vec_score"] = item["score"]
|
||||||
score_map[doc_id]["vec_rank"] = rank + 1
|
score_map[doc_id]["vec_rank"] = rank + 1
|
||||||
else:
|
else:
|
||||||
score_map[doc_id] = {
|
score_map[doc_id] = {
|
||||||
"content": item["content"],
|
"content": item["content"],
|
||||||
"score": 1.0 / (rrf_k + rank + 1),
|
"score": 1.0 / (rrf_k + rank + 1),
|
||||||
"vec_score": item["score"],
|
"vec_score": item["score"],
|
||||||
"vec_rank": rank + 1,
|
"vec_rank": rank + 1,
|
||||||
"metadata": item.get("metadata", {}),
|
"metadata": item.get("metadata", {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 按融合分数排序
|
# 按融合分数排序
|
||||||
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
||||||
return merged[:top_k]
|
return merged[:top_k]
|
||||||
|
|
||||||
|
|
||||||
async def delete_document_embeddings(doc_id: str) -> bool:
|
async def delete_document_embeddings(doc_id: str) -> bool:
|
||||||
"""删除文档的所有向量索引"""
|
"""删除文档的所有向量索引"""
|
||||||
pool = await get_pg_pool()
|
pool = await get_pg_pool()
|
||||||
if pool is None:
|
if pool is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"DELETE FROM document_embeddings WHERE doc_id = $1;",
|
"DELETE FROM document_embeddings WHERE doc_id = $1;",
|
||||||
doc_id,
|
doc_id,
|
||||||
)
|
)
|
||||||
logger.info(f"Deleted embeddings for document {doc_id}")
|
logger.info(f"Deleted embeddings for document {doc_id}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete embeddings: {e}")
|
logger.error(f"Failed to delete embeddings: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def close_pool():
|
async def close_pool():
|
||||||
"""关闭连接池"""
|
"""关闭连接池"""
|
||||||
global _pg_pool
|
global _pg_pool
|
||||||
if _pg_pool is not None:
|
if _pg_pool is not None:
|
||||||
await _pg_pool.close()
|
await _pg_pool.close()
|
||||||
_pg_pool = None
|
_pg_pool = None
|
||||||
logger.info("PostgreSQL connection pool closed")
|
logger.info("PostgreSQL connection pool closed")
|
||||||
+249
-249
@@ -1,250 +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, observability_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(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
|
app.include_router(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
|
||||||
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
||||||
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
||||||
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
|
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/app/api/health")
|
@app.get("/app/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "ok", "service": "Qihu-AIFlow"}
|
return {"status": "ok", "service": "Qihu-AIFlow"}
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
# AIFlow 后端依赖
|
# AIFlow 后端依赖
|
||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn==0.34.0
|
uvicorn==0.34.0
|
||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
pymysql==1.1.1
|
pymysql==1.1.1
|
||||||
cryptography==44.0.0
|
cryptography==44.0.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
python-multipart==0.0.18
|
python-multipart==0.0.18
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
celery==5.4.0
|
celery==5.4.0
|
||||||
qrcode==8.2
|
qrcode==8.2
|
||||||
pillow==12.2.0
|
pillow==12.2.0
|
||||||
xskill==0.5.2
|
xskill==0.5.2
|
||||||
|
|
||||||
# ---- pinned celery ecosystem (known-good, extracted from running container) ----
|
# ---- pinned celery ecosystem (known-good, extracted from running container) ----
|
||||||
# 避免 pip 浮动解析到与 celery 5.4.0 冲突的新版传递依赖(如 click-repl 0.2.0 的 prompt-toolkit 冲突)
|
# 避免 pip 浮动解析到与 celery 5.4.0 冲突的新版传递依赖(如 click-repl 0.2.0 的 prompt-toolkit 冲突)
|
||||||
amqp==5.3.1
|
amqp==5.3.1
|
||||||
kombu==5.6.2
|
kombu==5.6.2
|
||||||
billiard==4.2.4
|
billiard==4.2.4
|
||||||
vine==5.1.0
|
vine==5.1.0
|
||||||
click==8.4.2
|
click==8.4.2
|
||||||
click-didyoumean==0.3.1
|
click-didyoumean==0.3.1
|
||||||
click-plugins==1.1.1.2
|
click-plugins==1.1.1.2
|
||||||
click-repl==0.3.0
|
click-repl==0.3.0
|
||||||
+206
-206
@@ -1,206 +1,206 @@
|
|||||||
# Design System Master File
|
# Design System Master File
|
||||||
|
|
||||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||||
> If that file exists, its rules **override** this Master file.
|
> If that file exists, its rules **override** this Master file.
|
||||||
> If not, strictly follow the rules below.
|
> If not, strictly follow the rules below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Project:** 七虎AI
|
**Project:** 七虎AI
|
||||||
**Generated:** 2026-07-13 09:07:15
|
**Generated:** 2026-07-13 09:07:15
|
||||||
**Category:** AI PaaS Platform / Developer Console
|
**Category:** AI PaaS Platform / Developer Console
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Global Rules
|
## Global Rules
|
||||||
|
|
||||||
### Color Palette
|
### Color Palette
|
||||||
|
|
||||||
| Role | Hex | CSS Variable |
|
| Role | Hex | CSS Variable |
|
||||||
|------|-----|--------------|
|
|------|-----|--------------|
|
||||||
| Primary | `#5B5FED` | `--color-primary` |
|
| Primary | `#5B5FED` | `--color-primary` |
|
||||||
| Secondary | `#3B82F6` | `--color-secondary` |
|
| Secondary | `#3B82F6` | `--color-secondary` |
|
||||||
| CTA/Accent | `#5B5FED` | `--color-cta` |
|
| CTA/Accent | `#5B5FED` | `--color-cta` |
|
||||||
| Background | `#F8F9FB` | `--color-background` |
|
| Background | `#F8F9FB` | `--color-background` |
|
||||||
| Text | `#101214` | `--color-text` |
|
| Text | `#101214` | `--color-text` |
|
||||||
|
|
||||||
**Color Notes:** Indigo #5B5FED is the single brand hue. Success #219B5E / Warning #E8922C / Error #D9383A / Info #3B82F6 are semantic only. Do not introduce extra brand colors.
|
**Color Notes:** Indigo #5B5FED is the single brand hue. Success #219B5E / Warning #E8922C / Error #D9383A / Info #3B82F6 are semantic only. Do not introduce extra brand colors.
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- **Heading Font:** Inter
|
- **Heading Font:** Inter
|
||||||
- **Body Font:** Inter
|
- **Body Font:** Inter
|
||||||
- **Mood:** neutral, precise, technical
|
- **Mood:** neutral, precise, technical
|
||||||
- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600;700;800;900|JetBrains+Mono:wght@400;500;600)
|
- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600;700;800;900|JetBrains+Mono:wght@400;500;600)
|
||||||
|
|
||||||
**CSS Import:**
|
**CSS Import:**
|
||||||
```css
|
```css
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Spacing Variables
|
### Spacing Variables
|
||||||
|
|
||||||
| Token | Value | Usage |
|
| Token | Value | Usage |
|
||||||
|-------|-------|-------|
|
|-------|-------|-------|
|
||||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||||
|
|
||||||
### Shadow Depths
|
### Shadow Depths
|
||||||
|
|
||||||
| Level | Value | Usage |
|
| Level | Value | Usage |
|
||||||
|-------|-------|-------|
|
|-------|-------|-------|
|
||||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Component Specs
|
## Component Specs
|
||||||
|
|
||||||
### Buttons
|
### Buttons
|
||||||
|
|
||||||
```css
|
```css
|
||||||
/* Primary Button */
|
/* Primary Button */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #5B5FED;
|
background: #5B5FED;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 200ms ease;
|
transition: all 200ms ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary Button */
|
/* Secondary Button */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #5B5FED;
|
color: #5B5FED;
|
||||||
border: 2px solid #5B5FED;
|
border: 2px solid #5B5FED;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 200ms ease;
|
transition: all 200ms ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cards
|
### Cards
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.card {
|
.card {
|
||||||
background: #F8F9FB;
|
background: #F8F9FB;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transition: all 200ms ease;
|
transition: all 200ms ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Inputs
|
### Inputs
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.input {
|
.input {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border: 1px solid #E2E8F0;
|
border: 1px solid #E2E8F0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
transition: border-color 200ms ease;
|
transition: border-color 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: #5B5FED;
|
border-color: #5B5FED;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px #5B5FED20;
|
box-shadow: 0 0 0 3px #5B5FED20;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modals
|
### Modals
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Style Guidelines
|
## Style Guidelines
|
||||||
|
|
||||||
**Style:** Warm Tech Minimalism
|
**Style:** Warm Tech Minimalism
|
||||||
|
|
||||||
**Keywords:** clean, precise, data-dense, soft surfaces, indigo accent, generous whitespace
|
**Keywords:** clean, precise, data-dense, soft surfaces, indigo accent, generous whitespace
|
||||||
|
|
||||||
**Best For:** AI PaaS consoles, developer platforms, agent/workflow editors
|
**Best For:** AI PaaS consoles, developer platforms, agent/workflow editors
|
||||||
|
|
||||||
**Key Effects:** Subtle hover lift (translateY -1 to -2px), 150-300ms ease transitions, soft shadow escalation, skeleton shimmer for loading, fade/slide micro-entrances.
|
**Key Effects:** Subtle hover lift (translateY -1 to -2px), 150-300ms ease transitions, soft shadow escalation, skeleton shimmer for loading, fade/slide micro-entrances.
|
||||||
|
|
||||||
### Page Pattern
|
### Page Pattern
|
||||||
|
|
||||||
**Pattern Name:** App Shell + Dashboard Grid
|
**Pattern Name:** App Shell + Dashboard Grid
|
||||||
|
|
||||||
- **Conversion Strategy:** Core value shown immediately via workspace/chat; primary action (新建对话/部署) persistent in header and sidebar.
|
- **Conversion Strategy:** Core value shown immediately via workspace/chat; primary action (新建对话/部署) persistent in header and sidebar.
|
||||||
- **CTA Placement:** Sticky header + sidebar primary action; contextual CTAs inside cards.
|
- **CTA Placement:** Sticky header + sidebar primary action; contextual CTAs inside cards.
|
||||||
- **Section Order:** Sidebar > Workspace(Chat/Agents/Skills/MCP) > Main content grid > Detail drawer
|
- **Section Order:** Sidebar > Workspace(Chat/Agents/Skills/MCP) > Main content grid > Detail drawer
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anti-Patterns (Do NOT Use)
|
## Anti-Patterns (Do NOT Use)
|
||||||
|
|
||||||
- ❌ Ornate decoration
|
- ❌ Ornate decoration
|
||||||
- ❌ Low contrast text
|
- ❌ Low contrast text
|
||||||
- ❌ Missing focus states
|
- ❌ Missing focus states
|
||||||
- ❌ Emojis as icons
|
- ❌ Emojis as icons
|
||||||
- ❌ Layout-shifting scale hovers
|
- ❌ Layout-shifting scale hovers
|
||||||
- ❌ Instant state changes
|
- ❌ Instant state changes
|
||||||
|
|
||||||
### Additional Forbidden Patterns
|
### Additional Forbidden Patterns
|
||||||
|
|
||||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pre-Delivery Checklist
|
## Pre-Delivery Checklist
|
||||||
|
|
||||||
Before delivering any UI code, verify:
|
Before delivering any UI code, verify:
|
||||||
|
|
||||||
- [ ] No emojis as icons (use SVG: Heroicons/Lucide)
|
- [ ] No emojis as icons (use SVG: Heroicons/Lucide)
|
||||||
- [ ] cursor-pointer on all clickable elements
|
- [ ] cursor-pointer on all clickable elements
|
||||||
- [ ] Hover states with smooth transitions (150-300ms)
|
- [ ] Hover states with smooth transitions (150-300ms)
|
||||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||||
- [ ] Focus states visible for keyboard nav
|
- [ ] Focus states visible for keyboard nav
|
||||||
- [ ] prefers-reduced-motion respected
|
- [ ] prefers-reduced-motion respected
|
||||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||||
- [ ] No content hidden behind fixed navbars
|
- [ ] No content hidden behind fixed navbars
|
||||||
- [ ] No horizontal scroll on mobile
|
- [ ] No horizontal scroll on mobile
|
||||||
+177
-177
@@ -1,177 +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 Observability = React.lazy(() => import('@/pages/admin/Observability'));
|
const Observability = React.lazy(() => import('@/pages/admin/Observability'));
|
||||||
const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
|
const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
|
||||||
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
|
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
|
||||||
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
|
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
|
||||||
const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer'));
|
const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer'));
|
||||||
const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools'));
|
const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools'));
|
||||||
const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation'));
|
const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation'));
|
||||||
const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp'));
|
const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp'));
|
||||||
const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
|
const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
|
||||||
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
|
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
|
||||||
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
|
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
|
||||||
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
|
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
|
||||||
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
|
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
|
||||||
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
|
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 App: React.FC = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// 初始化:从localStorage恢复登录状态
|
// 初始化:从localStorage恢复登录状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('aiflow_token');
|
const token = localStorage.getItem('aiflow_token');
|
||||||
const cachedUser = localStorage.getItem('aiflow_user');
|
const cachedUser = localStorage.getItem('aiflow_user');
|
||||||
if (token && cachedUser) {
|
if (token && cachedUser) {
|
||||||
try {
|
try {
|
||||||
setUser(JSON.parse(cachedUser));
|
setUser(JSON.parse(cachedUser));
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('aiflow_token');
|
localStorage.removeItem('aiflow_token');
|
||||||
localStorage.removeItem('aiflow_user');
|
localStorage.removeItem('aiflow_user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = (u: User) => {
|
const login = (u: User) => {
|
||||||
setUser(u);
|
setUser(u);
|
||||||
localStorage.setItem('aiflow_user', JSON.stringify(u));
|
localStorage.setItem('aiflow_user', JSON.stringify(u));
|
||||||
};
|
};
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem('aiflow_token');
|
localStorage.removeItem('aiflow_token');
|
||||||
localStorage.removeItem('aiflow_user');
|
localStorage.removeItem('aiflow_user');
|
||||||
};
|
};
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading-screen">加载中...</div>;
|
return <div className="loading-screen">加载中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
|
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Login */}
|
{/* Login */}
|
||||||
<Route path="/login" element={
|
<Route path="/login" element={
|
||||||
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
|
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Root - Landing for guests, redirect for authenticated users */}
|
{/* Root - Landing for guests, redirect for authenticated users */}
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
|
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Front Routes (authenticated only) */}
|
{/* Front Routes (authenticated only) */}
|
||||||
<Route path="/" element={<FrontLayout />}>
|
<Route path="/" element={<FrontLayout />}>
|
||||||
<Route path="chat" element={<ChatPage />} />
|
<Route path="chat" element={<ChatPage />} />
|
||||||
<Route path="chat/:id" element={<ChatPage />} />
|
<Route path="chat/:id" element={<ChatPage />} />
|
||||||
<Route path="knowledge" element={<KnowledgePage />} />
|
<Route path="knowledge" element={<KnowledgePage />} />
|
||||||
<Route path="market" element={<MarketPage />} />
|
<Route path="market" element={<MarketPage />} />
|
||||||
<Route path="token" element={<TokenDashboardPage />} />
|
<Route path="token" element={<TokenDashboardPage />} />
|
||||||
<Route path="pricing" element={<PricingPage />} />
|
<Route path="pricing" element={<PricingPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="notifications" element={<NotificationPage />} />
|
<Route path="notifications" element={<NotificationPage />} />
|
||||||
<Route path="assets" element={<AssetsPage />} />
|
<Route path="assets" element={<AssetsPage />} />
|
||||||
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
||||||
<Route path="evolution" element={<EvolutionDashboard />} />
|
<Route path="evolution" element={<EvolutionDashboard />} />
|
||||||
<Route path="wechat-bot" element={<WechatBot />} />
|
<Route path="wechat-bot" element={<WechatBot />} />
|
||||||
<Route path="im-bots" element={<ImBotHub />} />
|
<Route path="im-bots" element={<ImBotHub />} />
|
||||||
<Route path="app-services" element={<AppServices />} />
|
<Route path="app-services" element={<AppServices />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin Routes */}
|
{/* Admin Routes */}
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<AdminDashboard />} />
|
<Route index element={<AdminDashboard />} />
|
||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
<Route path="settings" element={<AdminSiteSettings />} />
|
<Route path="settings" element={<AdminSiteSettings />} />
|
||||||
<Route path="sms" element={<AdminSmsConfig />} />
|
<Route path="sms" element={<AdminSmsConfig />} />
|
||||||
<Route path="payment" element={<AdminPayment />} />
|
<Route path="payment" element={<AdminPayment />} />
|
||||||
<Route path="services" element={<AdminServices />} />
|
<Route path="services" element={<AdminServices />} />
|
||||||
<Route path="membership" element={<AdminMembership />} />
|
<Route path="membership" element={<AdminMembership />} />
|
||||||
<Route path="ads" element={<AdminAds />} />
|
<Route path="ads" element={<AdminAds />} />
|
||||||
<Route path="market" element={<AdminMarket />} />
|
<Route path="market" element={<AdminMarket />} />
|
||||||
<Route path="logs" element={<AdminLogs />} />
|
<Route path="logs" element={<AdminLogs />} />
|
||||||
<Route path="skills" element={<AdminSkills />} />
|
<Route path="skills" element={<AdminSkills />} />
|
||||||
<Route path="agents" element={<AdminAgents />} />
|
<Route path="agents" element={<AdminAgents />} />
|
||||||
<Route path="code-exec" element={<AdminCodeExec />} />
|
<Route path="code-exec" element={<AdminCodeExec />} />
|
||||||
<Route path="distributions" element={<DistributionBuilder />} />
|
<Route path="distributions" element={<DistributionBuilder />} />
|
||||||
<Route path="im-bots" element={<ImBots />} />
|
<Route path="im-bots" element={<ImBots />} />
|
||||||
<Route path="privacy" element={<PrivacySettings />} />
|
<Route path="privacy" element={<PrivacySettings />} />
|
||||||
<Route path="promotion" element={<Promotion />} />
|
<Route path="promotion" element={<Promotion />} />
|
||||||
<Route path="achievement" element={<Achievement />} />
|
<Route path="achievement" element={<Achievement />} />
|
||||||
<Route path="tenant" element={<Tenant />} />
|
<Route path="tenant" element={<Tenant />} />
|
||||||
<Route path="billing" element={<Billing />} />
|
<Route path="billing" element={<Billing />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
<Route path="analytics" element={<Analytics />} />
|
||||||
<Route path="observability" element={<Observability />} />
|
<Route path="observability" element={<Observability />} />
|
||||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||||
<Route path="workflow" element={<AdminWorkflow />} />
|
<Route path="workflow" element={<AdminWorkflow />} />
|
||||||
<Route path="storage" element={<AdminStorage />} />
|
<Route path="storage" element={<AdminStorage />} />
|
||||||
<Route path="developer" element={<AdminDeveloper />} />
|
<Route path="developer" element={<AdminDeveloper />} />
|
||||||
<Route path="developer-tools" element={<AdminDeveloperTools />} />
|
<Route path="developer-tools" element={<AdminDeveloperTools />} />
|
||||||
<Route path="automation" element={<AdminAutomation />} />
|
<Route path="automation" element={<AdminAutomation />} />
|
||||||
<Route path="mcp" element={<AdminMcp />} />
|
<Route path="mcp" element={<AdminMcp />} />
|
||||||
<Route path="apps" element={<AdminApps />} />
|
<Route path="apps" element={<AdminApps />} />
|
||||||
<Route path="assets" element={<AssetCenter />} />
|
<Route path="assets" element={<AssetCenter />} />
|
||||||
<Route path="portal" element={<UserPortal />} />
|
<Route path="portal" element={<UserPortal />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */}
|
{/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */}
|
||||||
<Route path="/home" element={<LandingPage />} />
|
<Route path="/home" element={<LandingPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
+2010
-2010
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,437 +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/observability', icon: 'observability', label: '调用可观测' },
|
{ path: '/admin/observability', icon: 'observability', label: '调用可观测' },
|
||||||
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
|
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
|
||||||
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
|
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
|
||||||
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
|
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
|
||||||
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
|
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
|
||||||
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
|
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
|
||||||
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
||||||
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
|
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
|
||||||
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
|
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
|
||||||
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
|
{ 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>,
|
||||||
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>,
|
observability: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>,
|
||||||
|
|
||||||
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
|
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
|
||||||
|
|
||||||
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>,
|
storage: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
|
||||||
|
|
||||||
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
|
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
|
||||||
|
|
||||||
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||||
|
|
||||||
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||||
|
|
||||||
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
|
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
|
||||||
|
|
||||||
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
|
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
|
||||||
|
|
||||||
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
|
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
|
||||||
|
|
||||||
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
|
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
|
||||||
|
|
||||||
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
|
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
|
||||||
|
|
||||||
chevron: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>,
|
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>,
|
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 AdminLayout: React.FC = () => {
|
||||||
|
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// 后台必须登录且为管理员
|
// 后台必须登录且为管理员
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('aiflow_token');
|
const token = localStorage.getItem('aiflow_token');
|
||||||
if (!token || !user || user.role !== 'admin') {
|
if (!token || !user || user.role !== 'admin') {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
}
|
}
|
||||||
}, [user, logout, navigate]);
|
}, [user, logout, navigate]);
|
||||||
|
|
||||||
const navCrumb = (() => {
|
const navCrumb = (() => {
|
||||||
|
|
||||||
for (const g of adminNavGroups) {
|
for (const g of adminNavGroups) {
|
||||||
|
|
||||||
for (const it of g.items) {
|
for (const it of g.items) {
|
||||||
|
|
||||||
const match = it.exact
|
const match = it.exact
|
||||||
|
|
||||||
? location.pathname === it.path
|
? location.pathname === it.path
|
||||||
|
|
||||||
: location.pathname === it.path || location.pathname.startsWith(it.path + '/');
|
: location.pathname === it.path || location.pathname.startsWith(it.path + '/');
|
||||||
|
|
||||||
if (match) return { group: g.title, label: it.label };
|
if (match) return { group: g.title, label: it.label };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { group: '', label: '' };
|
return { group: '', label: '' };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const navGroup = navCrumb.group;
|
const navGroup = navCrumb.group;
|
||||||
|
|
||||||
const navLabel = navCrumb.label;
|
const navLabel = navCrumb.label;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰)
|
// 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰)
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||||
return new Set(adminNavGroups.map((g) => g.title));
|
return new Set(adminNavGroups.map((g) => g.title));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const toggleGroup = (title: string) => {
|
const toggleGroup = (title: string) => {
|
||||||
|
|
||||||
setExpandedGroups(prev => {
|
setExpandedGroups(prev => {
|
||||||
|
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
||||||
if (next.has(title)) next.delete(title);
|
if (next.has(title)) next.delete(title);
|
||||||
|
|
||||||
else next.add(title);
|
else next.add(title);
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|
||||||
logout();
|
logout();
|
||||||
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleVisitFrontend = () => {
|
const handleVisitFrontend = () => {
|
||||||
|
|
||||||
const url = window.location.origin + '/chat';
|
const url = window.location.origin + '/chat';
|
||||||
|
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="admin-layout">
|
<div className="admin-layout">
|
||||||
|
|
||||||
<aside className="admin-sidebar">
|
<aside className="admin-sidebar">
|
||||||
|
|
||||||
<div className="admin-sidebar-header">
|
<div className="admin-sidebar-header">
|
||||||
|
|
||||||
<h1 className="admin-logo">七虎AI</h1>
|
<h1 className="admin-logo">七虎AI</h1>
|
||||||
|
|
||||||
<span className="admin-subtitle">管理控制台</span>
|
<span className="admin-subtitle">管理控制台</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={handleVisitFrontend} className="admin-frontend-link">
|
<button onClick={handleVisitFrontend} className="admin-frontend-link">
|
||||||
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
|
||||||
<span>访问前台</span>
|
<span>访问前台</span>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
|
|
||||||
{adminNavGroups.map((group) => {
|
{adminNavGroups.map((group) => {
|
||||||
|
|
||||||
const isExpanded = expandedGroups.has(group.title);
|
const isExpanded = expandedGroups.has(group.title);
|
||||||
|
|
||||||
const hasActiveChild = group.items.some(item =>
|
const hasActiveChild = group.items.some(item =>
|
||||||
|
|
||||||
item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
|
<div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
||||||
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
|
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
|
||||||
|
|
||||||
onClick={() => toggleGroup(group.title)}
|
onClick={() => toggleGroup(group.title)}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<span className="nav-group-chevron-wrap">
|
<span className="nav-group-chevron-wrap">
|
||||||
|
|
||||||
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
|
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
|
||||||
|
|
||||||
{adminIconPaths.chevron}
|
{adminIconPaths.chevron}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="nav-group-title">{group.title}</span>
|
<span className="nav-group-title">{group.title}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
|
|
||||||
<div className="nav-group-items">
|
<div className="nav-group-items">
|
||||||
|
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
||||||
key={item.path}
|
key={item.path}
|
||||||
|
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
|
||||||
end={item.exact}
|
end={item.exact}
|
||||||
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
|
|
||||||
`nav-item ${isActive ? 'nav-item-active' : ''}`
|
`nav-item ${isActive ? 'nav-item-active' : ''}`
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
|
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
|
||||||
|
|
||||||
<span className="nav-item-label">{item.label}</span>
|
<span className="nav-item-label">{item.label}</span>
|
||||||
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="admin-sidebar-footer">
|
<div className="admin-sidebar-footer">
|
||||||
|
|
||||||
<div className="admin-user-info">
|
<div className="admin-user-info">
|
||||||
|
|
||||||
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
|
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
|
||||||
|
|
||||||
<div className="admin-user-detail">
|
<div className="admin-user-detail">
|
||||||
|
|
||||||
<div className="admin-user-name">{user?.username || 'admin'}</div>
|
<div className="admin-user-name">{user?.username || 'admin'}</div>
|
||||||
|
|
||||||
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
|
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="admin-content">
|
<main className="admin-content">
|
||||||
|
|
||||||
<header className="admin-topbar">
|
<header className="admin-topbar">
|
||||||
|
|
||||||
<nav className="admin-breadcrumb" aria-label="breadcrumb">
|
<nav className="admin-breadcrumb" aria-label="breadcrumb">
|
||||||
|
|
||||||
<span className="crumb-root">管理控制台</span>
|
<span className="crumb-root">管理控制台</span>
|
||||||
|
|
||||||
{navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)}
|
{navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)}
|
||||||
|
|
||||||
{navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)}
|
{navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="admin-topbar-user">
|
<div className="admin-topbar-user">
|
||||||
|
|
||||||
<div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
|
<div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
|
||||||
|
|
||||||
<span className="admin-topbar-username">{user?.username || 'admin'}</span>
|
<span className="admin-topbar-username">{user?.username || 'admin'}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default AdminLayout;
|
export default AdminLayout;
|
||||||
|
|
||||||
+1837
-1837
File diff suppressed because it is too large.
Load diff
@@ -1,207 +1,207 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
interface Overview {
|
interface Overview {
|
||||||
total_users: number; active_users: number; new_users: number;
|
total_users: number; active_users: number; new_users: number;
|
||||||
api_calls: number; avg_response_time: number; error_rate: number;
|
api_calls: number; avg_response_time: number; error_rate: number;
|
||||||
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
|
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
|
||||||
}
|
}
|
||||||
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
|
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
|
||||||
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
|
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
|
||||||
|
|
||||||
const Analytics: React.FC = () => {
|
const Analytics: React.FC = () => {
|
||||||
const [days, setDays] = useState(7);
|
const [days, setDays] = useState(7);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [overview, setOverview] = useState<Overview | null>(null);
|
const [overview, setOverview] = useState<Overview | null>(null);
|
||||||
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
|
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
|
||||||
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
|
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
|
||||||
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
|
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
|
||||||
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
|
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
|
||||||
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
|
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
|
||||||
|
|
||||||
const fetchAll = useCallback(async () => {
|
const fetchAll = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [ov, api, tok, usr, rev, mu] = await Promise.all([
|
const [ov, api, tok, usr, rev, mu] = await Promise.all([
|
||||||
adminAPI.getAnalyticsOverview(days),
|
adminAPI.getAnalyticsOverview(days),
|
||||||
adminAPI.getAnalyticsApiTrends(days),
|
adminAPI.getAnalyticsApiTrends(days),
|
||||||
adminAPI.getAnalyticsTokenTrends(days),
|
adminAPI.getAnalyticsTokenTrends(days),
|
||||||
adminAPI.getAnalyticsUserTrends(days),
|
adminAPI.getAnalyticsUserTrends(days),
|
||||||
adminAPI.getAnalyticsRevenueTrends(days),
|
adminAPI.getAnalyticsRevenueTrends(days),
|
||||||
adminAPI.getAnalyticsModelUsage(days),
|
adminAPI.getAnalyticsModelUsage(days),
|
||||||
]);
|
]);
|
||||||
setOverview((ov as any)?.data || ov);
|
setOverview((ov as any)?.data || ov);
|
||||||
setApiTrend((api as any)?.data || []);
|
setApiTrend((api as any)?.data || []);
|
||||||
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
|
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
|
||||||
setTokenTrend(td);
|
setTokenTrend(td);
|
||||||
setUserTrend((usr as any)?.data || []);
|
setUserTrend((usr as any)?.data || []);
|
||||||
setRevenueTrend((rev as any)?.data || []);
|
setRevenueTrend((rev as any)?.data || []);
|
||||||
setModelUsage((mu as any)?.data || []);
|
setModelUsage((mu as any)?.data || []);
|
||||||
} catch (e) { console.error('fetchAnalytics:', e); }
|
} catch (e) { console.error('fetchAnalytics:', e); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [days]);
|
}, [days]);
|
||||||
|
|
||||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||||
|
|
||||||
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
||||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
||||||
const values = data.map((d: any) => d[key] || 0);
|
const values = data.map((d: any) => d[key] || 0);
|
||||||
const max = Math.max(...values, 1);
|
const max = Math.max(...values, 1);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
|
||||||
{data.map((d: any, i) => {
|
{data.map((d: any, i) => {
|
||||||
const v = d[key] || 0;
|
const v = d[key] || 0;
|
||||||
const h = (v / max) * 140;
|
const h = (v / max) * 140;
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
||||||
<div style={{ fontSize: '10px', color: '#8892B0', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
|
<div style={{ fontSize: '10px', color: '#8892B0', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
|
||||||
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
|
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
|
||||||
<div style={{ fontSize: '10px', color: '#6B7280', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
<div style={{ fontSize: '10px', color: '#6B7280', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
||||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
|
||||||
const values = data.map((d: any) => d[key] || 0);
|
const values = data.map((d: any) => d[key] || 0);
|
||||||
const max = Math.max(...values, 1);
|
const max = Math.max(...values, 1);
|
||||||
const w = 100 / (data.length - 1 || 1);
|
const w = 100 / (data.length - 1 || 1);
|
||||||
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
|
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
|
||||||
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
|
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
|
||||||
{data.map((d: any, i) => (
|
{data.map((d: any, i) => (
|
||||||
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
|
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statCards = overview ? [
|
const statCards = overview ? [
|
||||||
{ label: '总用户数', value: overview.total_users, color: '#6C63FF', icon: 'U' },
|
{ label: '总用户数', value: overview.total_users, color: '#6C63FF', icon: 'U' },
|
||||||
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
||||||
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
||||||
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
||||||
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
|
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
|
||||||
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
|
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
|
||||||
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
|
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
|
||||||
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
|
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', color: '#1A1F36' }}>
|
<div style={{ padding: '24px', color: '#1A1F36' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
{[7, 14, 30, 90].map(d => (
|
{[7, 14, 30, 90].map(d => (
|
||||||
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||||
background: days === d ? '#6C63FF' : '#F0F2F8', color: days === d ? '#fff' : '#5A6480', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
background: days === d ? '#6C63FF' : '#F0F2F8', color: days === d ? '#fff' : '#5A6480', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#6B7280' }}>加载中...</div> : (
|
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#6B7280' }}>加载中...</div> : (
|
||||||
<>
|
<>
|
||||||
{/* Stats grid */}
|
{/* Stats grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
{statCards.map((s, i) => (
|
{statCards.map((s, i) => (
|
||||||
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
|
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
|
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
|
||||||
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{s.label}</div>
|
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts grid */}
|
{/* Charts grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
||||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
||||||
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
||||||
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
|
||||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
||||||
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
||||||
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model usage */}
|
{/* Model usage */}
|
||||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
||||||
{modelUsage.length > 0 ? (
|
{modelUsage.length > 0 ? (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead><tr style={{ borderBottom: '2px solid rgba(0,0,0,0.08)' }}>
|
<thead><tr style={{ borderBottom: '2px solid rgba(0,0,0,0.08)' }}>
|
||||||
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
||||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#8892B0', fontWeight: 600 }}>{h}</th>
|
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#8892B0', fontWeight: 600 }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{modelUsage.map((m, i) => (
|
{modelUsage.map((m, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||||
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
|
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
|
||||||
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
|
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
|
||||||
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
|
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
|
||||||
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#8892B0' }}>{m.avg_tokens.toLocaleString()}</td>
|
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#8892B0' }}>{m.avg_tokens.toLocaleString()}</td>
|
||||||
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
|
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无模型使用数据</div>}
|
) : <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无模型使用数据</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model distribution pie (CSS) */}
|
{/* Model distribution pie (CSS) */}
|
||||||
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
||||||
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
||||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
||||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const entries = Object.entries(tokenTrend.model_distribution);
|
const entries = Object.entries(tokenTrend.model_distribution);
|
||||||
const total = entries.reduce((s, [, v]) => s + v, 1);
|
const total = entries.reduce((s, [, v]) => s + v, 1);
|
||||||
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||||
return entries.map(([model, val], i) => {
|
return entries.map(([model, val], i) => {
|
||||||
const pct = (val / total) * 100;
|
const pct = (val / total) * 100;
|
||||||
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
||||||
});
|
});
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
||||||
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||||
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
||||||
return (
|
return (
|
||||||
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
|
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
|
||||||
<span style={{ fontSize: '13px', color: '#1A1F36' }}>{model}</span>
|
<span style={{ fontSize: '13px', color: '#1A1F36' }}>{model}</span>
|
||||||
<span style={{ fontSize: '13px', color: '#8892B0', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
<span style={{ fontSize: '13px', color: '#8892B0', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Analytics;
|
export default Analytics;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,457 +1,457 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
import { formatBjTime } from '@/utils/datetime';
|
import { formatBjTime } from '@/utils/datetime';
|
||||||
|
|
||||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
|
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
total_users: number;
|
total_users: number;
|
||||||
active_users: number;
|
active_users: number;
|
||||||
total_models: number;
|
total_models: number;
|
||||||
enabled_models: number;
|
enabled_models: number;
|
||||||
total_conversations: number;
|
total_conversations: number;
|
||||||
active_conversations: number;
|
active_conversations: number;
|
||||||
valid_conversations: number;
|
valid_conversations: number;
|
||||||
total_knowledge_bases: number;
|
total_knowledge_bases: number;
|
||||||
total_documents: number;
|
total_documents: number;
|
||||||
today_orders: number;
|
today_orders: number;
|
||||||
today_subscription_orders: number;
|
today_subscription_orders: number;
|
||||||
today_tokens: number;
|
today_tokens: number;
|
||||||
week_tokens: number;
|
week_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
today_conversations: number;
|
today_conversations: number;
|
||||||
server_time: string;
|
server_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DailyStat {
|
interface DailyStat {
|
||||||
date: string;
|
date: string;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickEntries = [
|
const quickEntries = [
|
||||||
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||||
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
||||||
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// SVG Bar Chart Component
|
// SVG Bar Chart Component
|
||||||
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
|
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
|
||||||
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
|
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
|
||||||
|
|
||||||
const maxVal = Math.max(...data.map(d => d.tokens), 1);
|
const maxVal = Math.max(...data.map(d => d.tokens), 1);
|
||||||
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
|
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
|
||||||
const barAreaW = w - padLeft - padRight;
|
const barAreaW = w - padLeft - padRight;
|
||||||
const barAreaH = h - padTop - padBottom;
|
const barAreaH = h - padTop - padBottom;
|
||||||
const barGap = 2;
|
const barGap = 2;
|
||||||
const barW = Math.max(2, (barAreaW / data.length) - barGap);
|
const barW = Math.max(2, (barAreaW / data.length) - barGap);
|
||||||
|
|
||||||
// Y axis ticks
|
// Y axis ticks
|
||||||
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
|
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
|
||||||
|
|
||||||
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
|
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
|
||||||
const formatDate = (d: string) => {
|
const formatDate = (d: string) => {
|
||||||
const parts = d.split('-');
|
const parts = d.split('-');
|
||||||
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
|
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
|
||||||
{/* Grid lines */}
|
{/* Grid lines */}
|
||||||
{yTicks.map((tick, i) => {
|
{yTicks.map((tick, i) => {
|
||||||
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
|
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
|
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
|
||||||
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
|
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Bars */}
|
{/* Bars */}
|
||||||
{data.map((d, i) => {
|
{data.map((d, i) => {
|
||||||
const barH = (d.tokens / maxVal) * barAreaH;
|
const barH = (d.tokens / maxVal) * barAreaH;
|
||||||
const x = padLeft + i * (barAreaW / data.length);
|
const x = padLeft + i * (barAreaW / data.length);
|
||||||
const y = padTop + barAreaH - barH;
|
const y = padTop + barAreaH - barH;
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
|
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
|
||||||
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
|
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
|
||||||
</rect>
|
</rect>
|
||||||
{/* X label - every 7th bar */}
|
{/* X label - every 7th bar */}
|
||||||
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
|
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
|
||||||
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
|
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mini Donut for stat cards
|
// Mini Donut for stat cards
|
||||||
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
|
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
|
||||||
const pct = max > 0 ? Math.min(value / max, 1) : 0;
|
const pct = max > 0 ? Math.min(value / max, 1) : 0;
|
||||||
const r = 16, c = 20, circumference = 2 * Math.PI * r;
|
const r = 16, c = 20, circumference = 2 * Math.PI * r;
|
||||||
const dash = circumference * pct;
|
const dash = circumference * pct;
|
||||||
return (
|
return (
|
||||||
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
|
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
|
||||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="3" />
|
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="3" />
|
||||||
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
|
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
|
||||||
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
|
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
|
||||||
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
|
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
|
||||||
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
|
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
|
||||||
{Math.round(pct * 100)}%
|
{Math.round(pct * 100)}%
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pagBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
const pagBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||||||
border: '1px solid rgba(0,0,0,0.12)',
|
border: '1px solid rgba(0,0,0,0.12)',
|
||||||
background: disabled ? '#F3F4F6' : '#FFFFFF',
|
background: disabled ? '#F3F4F6' : '#FFFFFF',
|
||||||
color: disabled ? '#B0B3BD' : '#374151',
|
color: disabled ? '#B0B3BD' : '#374151',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
});
|
});
|
||||||
|
|
||||||
const LogPagination: React.FC<{ total: number; page: number; pageSize: number; onPage: (p: number) => void }> = ({ total, page, pageSize, onPage }) => {
|
const LogPagination: React.FC<{ total: number; page: number; pageSize: number; onPage: (p: number) => void }> = ({ total, page, pageSize, onPage }) => {
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '12px 16px', fontSize: 12, color: '#6B7280' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '12px 16px', fontSize: 12, color: '#6B7280' }}>
|
||||||
<span>共 {total} 条</span>
|
<span>共 {total} 条</span>
|
||||||
<button disabled={page <= 1} onClick={() => onPage(page - 1)} style={pagBtnStyle(page <= 1)}>上一页</button>
|
<button disabled={page <= 1} onClick={() => onPage(page - 1)} style={pagBtnStyle(page <= 1)}>上一页</button>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', color: '#374151' }}>{page} / {totalPages}</span>
|
<span style={{ fontFamily: 'var(--font-mono)', color: '#374151' }}>{page} / {totalPages}</span>
|
||||||
<button disabled={page >= totalPages} onClick={() => onPage(page + 1)} style={pagBtnStyle(page >= totalPages)}>下一页</button>
|
<button disabled={page >= totalPages} onClick={() => onPage(page + 1)} style={pagBtnStyle(page >= totalPages)}>下一页</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
|
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
|
||||||
total_conversations: 0, active_conversations: 0, valid_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
|
total_conversations: 0, active_conversations: 0, valid_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
|
||||||
today_orders: 0, today_subscription_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
|
today_orders: 0, today_subscription_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
|
||||||
today_conversations: 0, server_time: '',
|
today_conversations: 0, server_time: '',
|
||||||
});
|
});
|
||||||
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
|
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
|
||||||
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
||||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||||
const [opTotal, setOpTotal] = useState(0);
|
const [opTotal, setOpTotal] = useState(0);
|
||||||
const [auditTotal, setAuditTotal] = useState(0);
|
const [auditTotal, setAuditTotal] = useState(0);
|
||||||
const [opPage, setOpPage] = useState(1);
|
const [opPage, setOpPage] = useState(1);
|
||||||
const [auditPage, setAuditPage] = useState(1);
|
const [auditPage, setAuditPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const loadOperationLogs = async (page: number) => {
|
const loadOperationLogs = async (page: number) => {
|
||||||
try {
|
try {
|
||||||
const res: any = await (adminAPI as any).getDashboardOperationLogs(page, PAGE_SIZE);
|
const res: any = await (adminAPI as any).getDashboardOperationLogs(page, PAGE_SIZE);
|
||||||
const data = res?.data || res;
|
const data = res?.data || res;
|
||||||
setOperationLogs(Array.isArray(data?.items) ? data.items : []);
|
setOperationLogs(Array.isArray(data?.items) ? data.items : []);
|
||||||
setOpTotal(data?.total ?? 0);
|
setOpTotal(data?.total ?? 0);
|
||||||
setOpPage(page);
|
setOpPage(page);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('操作日志加载失败:', err);
|
console.error('操作日志加载失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAuditLogs = async (page: number) => {
|
const loadAuditLogs = async (page: number) => {
|
||||||
try {
|
try {
|
||||||
const res: any = await (adminAPI as any).getDashboardAuditLogs(page, PAGE_SIZE);
|
const res: any = await (adminAPI as any).getDashboardAuditLogs(page, PAGE_SIZE);
|
||||||
const data = res?.data || res;
|
const data = res?.data || res;
|
||||||
setAuditLogs(Array.isArray(data?.items) ? data.items : []);
|
setAuditLogs(Array.isArray(data?.items) ? data.items : []);
|
||||||
setAuditTotal(data?.total ?? 0);
|
setAuditTotal(data?.total ?? 0);
|
||||||
setAuditPage(page);
|
setAuditPage(page);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('审计日志加载失败:', err);
|
console.error('审计日志加载失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadCsv = async (url: string, filename: string) => {
|
const downloadCsv = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('aiflow_token');
|
const token = localStorage.getItem('aiflow_token');
|
||||||
const res = await fetch(`/app/api${url}`, {
|
const res = await fetch(`/app/api${url}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('下载失败');
|
if (!res.ok) throw new Error('下载失败');
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const href = URL.createObjectURL(blob);
|
const href = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = href;
|
a.href = href;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(href);
|
URL.revokeObjectURL(href);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('CSV下载失败:', err);
|
console.error('CSV下载失败:', err);
|
||||||
alert('下载失败,请稍后重试');
|
alert('下载失败,请稍后重试');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, dailyRes] = await Promise.all([
|
const [statsRes, dailyRes] = await Promise.all([
|
||||||
adminAPI.getStats() as any,
|
adminAPI.getStats() as any,
|
||||||
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: { data: { data: [] } } }),
|
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: { data: { data: [] } } }),
|
||||||
]);
|
]);
|
||||||
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
|
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
|
||||||
const statsData = statsRes?.data || statsRes;
|
const statsData = statsRes?.data || statsRes;
|
||||||
if (statsData && typeof statsData === 'object') setStats(statsData);
|
if (statsData && typeof statsData === 'object') setStats(statsData);
|
||||||
|
|
||||||
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
|
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
|
||||||
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
|
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
|
||||||
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
|
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
|
||||||
|
|
||||||
await Promise.all([loadOperationLogs(1), loadAuditLogs(1)]);
|
await Promise.all([loadOperationLogs(1), loadAuditLogs(1)]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard数据加载失败:', err);
|
console.error('Dashboard数据加载失败:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatDate = (d: string | null) => formatBjTime(d);
|
const formatDate = (d: string | null) => formatBjTime(d);
|
||||||
|
|
||||||
const formatTokens = (n: number): string => {
|
const formatTokens = (n: number): string => {
|
||||||
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
|
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
|
||||||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||||
return n.toLocaleString('en-US');
|
return n.toLocaleString('en-US');
|
||||||
};
|
};
|
||||||
|
|
||||||
const statCardDefs = [
|
const statCardDefs = [
|
||||||
{ label: '总用户', value: stats.total_users, sub: `近7天活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
|
{ label: '总用户', value: stats.total_users, sub: `近7天活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
|
||||||
icon: <SVGIcon size={22}><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"/></SVGIcon> },
|
icon: <SVGIcon size={22}><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"/></SVGIcon> },
|
||||||
{ label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
|
{ label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
|
||||||
icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||||
{ label: '对话数', value: stats.valid_conversations, sub: `活跃窗口 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
|
{ label: '对话数', value: stats.valid_conversations, sub: `活跃窗口 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
|
||||||
icon: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
|
icon: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
|
||||||
{ label: '知识库文档', value: stats.total_documents, sub: `知识库 ${stats.total_knowledge_bases}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
|
{ label: '知识库文档', value: stats.total_documents, sub: `知识库 ${stats.total_knowledge_bases}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
|
||||||
icon: <SVGIcon size={22}><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"/></SVGIcon> },
|
icon: <SVGIcon size={22}><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"/></SVGIcon> },
|
||||||
{ label: '今日订阅成功', value: stats.today_subscription_orders, sub: `今日订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
|
{ label: '今日订阅成功', value: stats.today_subscription_orders, sub: `今日订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
|
||||||
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
|
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
|
||||||
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
|
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
|
||||||
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
|
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
|
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
|
||||||
<div className="audit-spinner" />
|
<div className="audit-spinner" />
|
||||||
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
|
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||||
<h1 className="admin-page-title">平台驾驶舱</h1>
|
<h1 className="admin-page-title">平台驾驶舱</h1>
|
||||||
|
|
||||||
{/* Stats Cards Row */}
|
{/* Stats Cards Row */}
|
||||||
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
{statCardDefs.map((s, i) => (
|
{statCardDefs.map((s, i) => (
|
||||||
<div key={i} className="admin-stat-card" style={{
|
<div key={i} className="admin-stat-card" style={{
|
||||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
|
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
|
||||||
padding: 'var(--space-lg) var(--space-xl)',
|
padding: 'var(--space-lg) var(--space-xl)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 48, height: 48, borderRadius: 14,
|
width: 48, height: 48, borderRadius: 14,
|
||||||
background: s.bg, color: s.color,
|
background: s.bg, color: s.color,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{s.icon}
|
{s.icon}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="admin-stat-label">{s.label}</div>
|
<div className="admin-stat-label">{s.label}</div>
|
||||||
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
|
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
|
||||||
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
|
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 11, color: s.color, marginTop: 2,
|
fontSize: 11, color: s.color, marginTop: 2,
|
||||||
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
|
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
}}>{s.sub}</div>
|
}}>{s.sub}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row */}
|
{/* Charts Row */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
|
||||||
{/* Token Trend Chart */}
|
{/* Token Trend Chart */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">
|
<h3 className="admin-card-title">
|
||||||
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||||
{' '}Token消耗趋势(近30天)
|
{' '}Token消耗趋势(近30天)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
|
||||||
<BarChart data={dailyStats} />
|
<BarChart data={dailyStats} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Health */}
|
{/* System Health */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">
|
<h3 className="admin-card-title">
|
||||||
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
||||||
{' '}平台概览
|
{' '}平台概览
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 'var(--space-lg)' }}>
|
<div style={{ padding: 'var(--space-lg)' }}>
|
||||||
{[
|
{[
|
||||||
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
|
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
|
||||||
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
|
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
|
||||||
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
|
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
|
||||||
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
|
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(0,0,0,0.06)' : 'none' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(0,0,0,0.06)' : 'none' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
|
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
|
||||||
<span style={{ fontSize: 13, color: '#1A1F36' }}>{item.label}</span>
|
<span style={{ fontSize: 13, color: '#1A1F36' }}>{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
|
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
|
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
|
||||||
服务器时间: {stats.server_time ? formatBjTime(stats.server_time) : '-'}
|
服务器时间: {stats.server_time ? formatBjTime(stats.server_time) : '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div style={{ marginBottom: 'var(--space-xl)' }}>
|
<div style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
|
||||||
{quickEntries.map((entry) => (
|
{quickEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
style={{
|
style={{
|
||||||
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
|
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
|
||||||
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
|
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
|
||||||
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
|
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = '#6C63FF';
|
e.currentTarget.style.borderColor = '#6C63FF';
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
e.currentTarget.style.transform = 'none';
|
e.currentTarget.style.transform = 'none';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
|
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36' }}>{entry.label}</div>
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36' }}>{entry.label}</div>
|
||||||
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
|
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Logs */}
|
{/* Recent Logs */}
|
||||||
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">操作日志</h3>
|
<h3 className="admin-card-title">操作日志</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => downloadCsv('/admin/dashboard/operation-logs/export', 'operation_logs.csv')}
|
onClick={() => downloadCsv('/admin/dashboard/operation-logs/export', 'operation_logs.csv')}
|
||||||
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
|
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
|
||||||
>下载 CSV</button>
|
>下载 CSV</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>用户</th>
|
<th>用户</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
<th>目标</th>
|
<th>目标</th>
|
||||||
<th>详情</th>
|
<th>详情</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{operationLogs.length > 0 ? operationLogs.map(log => (
|
{operationLogs.length > 0 ? operationLogs.map(log => (
|
||||||
<tr key={log.id}>
|
<tr key={log.id}>
|
||||||
<td style={{ fontWeight: 500 }}>{log.username}</td>
|
<td style={{ fontWeight: 500 }}>{log.username}</td>
|
||||||
<td><span className="admin-badge badge-info">{log.action}</span></td>
|
<td><span className="admin-badge badge-info">{log.action}</span></td>
|
||||||
<td>{log.target}</td>
|
<td>{log.target}</td>
|
||||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
|
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
|
||||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)) : (
|
)) : (
|
||||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
|
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<LogPagination total={opTotal} page={opPage} pageSize={PAGE_SIZE} onPage={loadOperationLogs} />
|
<LogPagination total={opTotal} page={opPage} pageSize={PAGE_SIZE} onPage={loadOperationLogs} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Audit Logs */}
|
{/* Recent Audit Logs */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">审计日志</h3>
|
<h3 className="admin-card-title">审计日志</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => downloadCsv('/admin/dashboard/audit-logs/export', 'audit_logs.csv')}
|
onClick={() => downloadCsv('/admin/dashboard/audit-logs/export', 'audit_logs.csv')}
|
||||||
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
|
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
|
||||||
>下载 CSV</button>
|
>下载 CSV</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>操作人</th>
|
<th>操作人</th>
|
||||||
<th>操作类型</th>
|
<th>操作类型</th>
|
||||||
<th>目标类型</th>
|
<th>目标类型</th>
|
||||||
<th>详情</th>
|
<th>详情</th>
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{auditLogs.length > 0 ? auditLogs.map(log => (
|
{auditLogs.length > 0 ? auditLogs.map(log => (
|
||||||
<tr key={log.id}>
|
<tr key={log.id}>
|
||||||
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
|
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
|
||||||
<td><span className="admin-badge badge-warning">{log.action}</span></td>
|
<td><span className="admin-badge badge-warning">{log.action}</span></td>
|
||||||
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
|
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
|
||||||
<td>{log.detail}</td>
|
<td>{log.detail}</td>
|
||||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)) : (
|
)) : (
|
||||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
|
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<LogPagination total={auditTotal} page={auditPage} pageSize={PAGE_SIZE} onPage={loadAuditLogs} />
|
<LogPagination total={auditTotal} page={auditPage} pageSize={PAGE_SIZE} onPage={loadAuditLogs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
File diff suppressed because it is too large.
Load diff
+388
-388
@@ -1,388 +1,388 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
import { mockCoupons } from '@/mock/data';
|
import { mockCoupons } from '@/mock/data';
|
||||||
import type { MarketApp, Coupon } from '@/types';
|
import type { MarketApp, Coupon } from '@/types';
|
||||||
|
|
||||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Phase 4.1: 安检状态徽章
|
// Phase 4.1: 安检状态徽章
|
||||||
function SecurityBadge({ scan }: { scan?: any }) {
|
function SecurityBadge({ scan }: { scan?: any }) {
|
||||||
if (!scan || !scan.status) return <span style={{ color: 'var(--color-text-disabled)' }}>未扫描</span>;
|
if (!scan || !scan.status) return <span style={{ color: 'var(--color-text-disabled)' }}>未扫描</span>;
|
||||||
const map: Record<string, { label: string; bg: string; fg: string }> = {
|
const map: Record<string, { label: string; bg: string; fg: string }> = {
|
||||||
pass: { label: '安检通过', bg: '#16a34a1A', fg: '#16a34a' },
|
pass: { label: '安检通过', bg: '#16a34a1A', fg: '#16a34a' },
|
||||||
warn: { label: '安检注意', bg: '#d977061A', fg: '#d97706' },
|
warn: { label: '安检注意', bg: '#d977061A', fg: '#d97706' },
|
||||||
fail: { label: '安检风险', bg: '#dc26261A', fg: '#dc2626' },
|
fail: { label: '安检风险', bg: '#dc26261A', fg: '#dc2626' },
|
||||||
};
|
};
|
||||||
const m = map[scan.status] || map.warn;
|
const m = map[scan.status] || map.warn;
|
||||||
return (
|
return (
|
||||||
<span style={{ background: m.bg, color: m.fg, padding: '2px 8px', borderRadius: 'var(--radius-full)', fontSize: 'var(--text-xs)', fontWeight: 600 }}>
|
<span style={{ background: m.bg, color: m.fg, padding: '2px 8px', borderRadius: 'var(--radius-full)', fontSize: 'var(--text-xs)', fontWeight: 600 }}>
|
||||||
{m.label}
|
{m.label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapApp = (a: any): MarketApp => ({
|
const mapApp = (a: any): MarketApp => ({
|
||||||
id: a.id, name: a.name, type: a.type, source: a.source,
|
id: a.id, name: a.name, type: a.type, source: a.source,
|
||||||
publisherId: a.publisher_id, publisherName: a.publisher_name || '未知',
|
publisherId: a.publisher_id, publisherName: a.publisher_name || '未知',
|
||||||
description: a.description || '', icon: a.icon || '?',
|
description: a.description || '', icon: a.icon || '?',
|
||||||
price: a.price || 0, isMemberFree: a.is_member_free || false,
|
price: a.price || 0, isMemberFree: a.is_member_free || false,
|
||||||
joinPromotion: a.join_promotion || false, status: a.status || 'published',
|
joinPromotion: a.join_promotion || false, status: a.status || 'published',
|
||||||
ratingAvg: a.rating_avg || 0, reviewCount: a.review_count || 0,
|
ratingAvg: a.rating_avg || 0, reviewCount: a.review_count || 0,
|
||||||
installCount: a.install_count || 0, createdAt: a.created_at || '',
|
installCount: a.install_count || 0, createdAt: a.created_at || '',
|
||||||
version: a.version || '1.0.0', verified: a.verified || false,
|
version: a.version || '1.0.0', verified: a.verified || false,
|
||||||
securityScan: a.security_scan || null, dependencies: a.dependencies || null,
|
securityScan: a.security_scan || null, dependencies: a.dependencies || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Market: React.FC = () => {
|
const Market: React.FC = () => {
|
||||||
const [reviewApps, setReviewApps] = useState<MarketApp[]>([]);
|
const [reviewApps, setReviewApps] = useState<MarketApp[]>([]);
|
||||||
const [officialApps, setOfficialApps] = useState<MarketApp[]>([]);
|
const [officialApps, setOfficialApps] = useState<MarketApp[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'review' | 'coupon' | 'official'>('review');
|
const [activeTab, setActiveTab] = useState<'review' | 'coupon' | 'official'>('review');
|
||||||
const [showCouponModal, setShowCouponModal] = useState(false);
|
const [showCouponModal, setShowCouponModal] = useState(false);
|
||||||
const [coupons] = useState<Coupon[]>(mockCoupons);
|
const [coupons] = useState<Coupon[]>(mockCoupons);
|
||||||
|
|
||||||
const [couponForm, setCouponForm] = useState({
|
const [couponForm, setCouponForm] = useState({
|
||||||
code: '', type: 'fixed' as 'fixed' | 'percent', value: 0, minAmount: 0,
|
code: '', type: 'fixed' as 'fixed' | 'percent', value: 0, minAmount: 0,
|
||||||
validFrom: '', validTo: '',
|
validFrom: '', validTo: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 4.1: 版本历史弹窗
|
// Phase 4.1: 版本历史弹窗
|
||||||
const [versionsApp, setVersionsApp] = useState<MarketApp | null>(null);
|
const [versionsApp, setVersionsApp] = useState<MarketApp | null>(null);
|
||||||
const [versions, setVersions] = useState<any[]>([]);
|
const [versions, setVersions] = useState<any[]>([]);
|
||||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||||
|
|
||||||
const loadReview = () => {
|
const loadReview = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
adminAPI.getPendingApps().then((res: any) => {
|
adminAPI.getPendingApps().then((res: any) => {
|
||||||
if (res && Array.isArray(res)) setReviewApps(res.map(mapApp));
|
if (res && Array.isArray(res)) setReviewApps(res.map(mapApp));
|
||||||
else if (res?.code === 0 && Array.isArray(res.data)) setReviewApps(res.data.map(mapApp));
|
else if (res?.code === 0 && Array.isArray(res.data)) setReviewApps(res.data.map(mapApp));
|
||||||
}).catch(() => {}).finally(() => setLoading(false));
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadOfficial = () => {
|
const loadOfficial = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
adminAPI.getAdminMarketApps('status=published').then((res: any) => {
|
adminAPI.getAdminMarketApps('status=published').then((res: any) => {
|
||||||
const items = (res?.data?.items || res?.items || []).map(mapApp).filter((a: MarketApp) => a.source === 'official');
|
const items = (res?.data?.items || res?.items || []).map(mapApp).filter((a: MarketApp) => a.source === 'official');
|
||||||
setOfficialApps(items);
|
setOfficialApps(items);
|
||||||
}).catch(() => {}).finally(() => setLoading(false));
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'review') loadReview();
|
if (activeTab === 'review') loadReview();
|
||||||
else if (activeTab === 'official') loadOfficial();
|
else if (activeTab === 'official') loadOfficial();
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleApprove = (id: string) => {
|
const handleApprove = (id: string) => {
|
||||||
adminAPI.approveApp(id).then(() => loadReview()).catch(() => alert('审核失败'));
|
adminAPI.approveApp(id).then(() => loadReview()).catch(() => alert('审核失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReject = (id: string) => {
|
const handleReject = (id: string) => {
|
||||||
adminAPI.rejectApp(id).then(() => loadReview()).catch(() => alert('拒绝失败'));
|
adminAPI.rejectApp(id).then(() => loadReview()).catch(() => alert('拒绝失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveOfficial = (id: string) => {
|
const handleRemoveOfficial = (id: string) => {
|
||||||
if (!confirm('确认下架该官方应用?')) return;
|
if (!confirm('确认下架该官方应用?')) return;
|
||||||
adminAPI.rejectApp(id).then(() => loadOfficial()).catch(() => alert('下架失败'));
|
adminAPI.rejectApp(id).then(() => loadOfficial()).catch(() => alert('下架失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRescan = (id: string) => {
|
const handleRescan = (id: string) => {
|
||||||
adminAPI.rescanApp(id).then((res: any) => {
|
adminAPI.rescanApp(id).then((res: any) => {
|
||||||
alert('重新安检完成:' + (res?.status || 'unknown'));
|
alert('重新安检完成:' + (res?.status || 'unknown'));
|
||||||
loadOfficial();
|
loadOfficial();
|
||||||
}).catch(() => alert('安检失败'));
|
}).catch(() => alert('安检失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openVersions = (app: MarketApp) => {
|
const openVersions = (app: MarketApp) => {
|
||||||
setVersionsApp(app);
|
setVersionsApp(app);
|
||||||
setLoadingVersions(true);
|
setLoadingVersions(true);
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
adminAPI.getAppVersions(app.id).then((res: any) => {
|
adminAPI.getAppVersions(app.id).then((res: any) => {
|
||||||
setVersions(res?.items || []);
|
setVersions(res?.items || []);
|
||||||
}).catch(() => {}).finally(() => setLoadingVersions(false));
|
}).catch(() => {}).finally(() => setLoadingVersions(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRollback = (appId: string, versionId: string) => {
|
const handleRollback = (appId: string, versionId: string) => {
|
||||||
if (!confirm('确认回滚到该版本?当前配置将被覆盖。')) return;
|
if (!confirm('确认回滚到该版本?当前配置将被覆盖。')) return;
|
||||||
adminAPI.rollbackApp(appId, versionId).then(() => {
|
adminAPI.rollbackApp(appId, versionId).then(() => {
|
||||||
alert('已回滚');
|
alert('已回滚');
|
||||||
openVersions({ ...versionsApp!, id: appId } as MarketApp);
|
openVersions({ ...versionsApp!, id: appId } as MarketApp);
|
||||||
loadOfficial();
|
loadOfficial();
|
||||||
}).catch(() => alert('回滚失败'));
|
}).catch(() => alert('回滚失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (d: string) => {
|
const formatDate = (d: string) => {
|
||||||
if (!d) return '-';
|
if (!d) return '-';
|
||||||
const dt = new Date(d);
|
const dt = new Date(d);
|
||||||
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'review' as const, label: '待审核应用', count: reviewApps.length },
|
{ key: 'review' as const, label: '待审核应用', count: reviewApps.length },
|
||||||
{ key: 'coupon' as const, label: '优惠券管理', count: coupons.length },
|
{ key: 'coupon' as const, label: '优惠券管理', count: coupons.length },
|
||||||
{ key: 'official' as const, label: '官方应用管理', count: officialApps.length },
|
{ key: 'official' as const, label: '官方应用管理', count: officialApps.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||||
<h1 className="admin-page-title">AI技能审核管理</h1>
|
<h1 className="admin-page-title">AI技能审核管理</h1>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-2xl)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-2xl)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
style={{
|
style={{
|
||||||
padding: 'var(--space-sm) var(--space-lg)',
|
padding: 'var(--space-sm) var(--space-lg)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
fontSize: 'var(--text-sm)', fontWeight: 600,
|
fontSize: 'var(--text-sm)', fontWeight: 600,
|
||||||
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
|
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
|
||||||
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||||
transition: 'all var(--transition-fast)',
|
transition: 'all var(--transition-fast)',
|
||||||
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
|
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.count > 0 && (
|
{tab.count > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
background: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
background: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
||||||
color: '#FFFFFF', padding: '0 6px', borderRadius: 'var(--radius-full)',
|
color: '#FFFFFF', padding: '0 6px', borderRadius: 'var(--radius-full)',
|
||||||
fontSize: 'var(--text-xs)', lineHeight: '18px', minWidth: 18, textAlign: 'center',
|
fontSize: 'var(--text-xs)', lineHeight: '18px', minWidth: 18, textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
{tab.count}
|
{tab.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending Review Tab */}
|
{/* Pending Review Tab */}
|
||||||
{activeTab === 'review' && (
|
{activeTab === 'review' && (
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">待审核应用</h3>
|
<h3 className="admin-card-title">待审核应用</h3>
|
||||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={loadReview}>刷新</button>
|
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={loadReview}>刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>应用名</th>
|
<th>应用名</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>发布者</th>
|
<th>发布者</th>
|
||||||
<th>价格</th>
|
<th>价格</th>
|
||||||
<th>安检</th>
|
<th>安检</th>
|
||||||
<th>发布时间</th>
|
<th>发布时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reviewApps.map(app => (
|
{reviewApps.map(app => (
|
||||||
<tr key={app.id}>
|
<tr key={app.id}>
|
||||||
<td style={{ fontWeight: 600 }}>{app.name}</td>
|
<td style={{ fontWeight: 600 }}>{app.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
|
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
|
||||||
{app.type.toUpperCase()}
|
{app.type.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{app.publisherName}</td>
|
<td>{app.publisherName}</td>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{app.price} 元</td>
|
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{app.price} 元</td>
|
||||||
<td><SecurityBadge scan={app.securityScan} /></td>
|
<td><SecurityBadge scan={app.securityScan} /></td>
|
||||||
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(app.createdAt)}</td>
|
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(app.createdAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||||
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => handleApprove(app.id)}>
|
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => handleApprove(app.id)}>
|
||||||
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
||||||
通过(绿标)
|
通过(绿标)
|
||||||
</button>
|
</button>
|
||||||
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleReject(app.id)}>
|
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleReject(app.id)}>
|
||||||
<SVGIcon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
|
<SVGIcon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
|
||||||
拒绝
|
拒绝
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{reviewApps.length === 0 && !loading && (
|
{reviewApps.length === 0 && !loading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>
|
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>
|
||||||
暂无待审核应用
|
暂无待审核应用
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-3xl)' }}>加载中...</td></tr>
|
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-3xl)' }}>加载中...</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Coupon Management Tab */}
|
{/* Coupon Management Tab */}
|
||||||
{activeTab === 'coupon' && (
|
{activeTab === 'coupon' && (
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">优惠券列表</h3>
|
<h3 className="admin-card-title">优惠券列表</h3>
|
||||||
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => setShowCouponModal(true)}>
|
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => setShowCouponModal(true)}>
|
||||||
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||||
创建优惠券
|
创建优惠券
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>兑换码</th>
|
<th>兑换码</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>面值</th>
|
<th>面值</th>
|
||||||
<th>最低消费</th>
|
<th>最低消费</th>
|
||||||
<th>有效期</th>
|
<th>有效期</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{coupons.map(c => (
|
{coupons.map(c => (
|
||||||
<tr key={c.id}>
|
<tr key={c.id}>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{c.code}</td>
|
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{c.code}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${c.type === 'fixed' ? 'badge-info' : 'badge-warning'}`}>
|
<span className={`admin-badge ${c.type === 'fixed' ? 'badge-info' : 'badge-warning'}`}>
|
||||||
{c.type === 'fixed' ? '固定金额' : '百分比'}
|
{c.type === 'fixed' ? '固定金额' : '百分比'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
|
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
|
||||||
{c.type === 'fixed' ? `${c.value} 元` : `${c.value}%`}
|
{c.type === 'fixed' ? `${c.value} 元` : `${c.value}%`}
|
||||||
</td>
|
</td>
|
||||||
<td>{c.minAmount > 0 ? `${c.minAmount} 元` : '无门槛'}</td>
|
<td>{c.minAmount > 0 ? `${c.minAmount} 元` : '无门槛'}</td>
|
||||||
<td style={{ fontSize: 'var(--text-xs)' }}>{c.validFrom} 至 {c.validTo}</td>
|
<td style={{ fontSize: 'var(--text-xs)' }}>{c.validFrom} 至 {c.validTo}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${c.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
<span className={`admin-badge ${c.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
||||||
{c.status === 'active' ? '有效' : '已过期'}
|
{c.status === 'active' ? '有效' : '已过期'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span style={{ color: 'var(--color-text-disabled)' }}>只读</span></td>
|
<td><span style={{ color: 'var(--color-text-disabled)' }}>只读</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Official App Management Tab */}
|
{/* Official App Management Tab */}
|
||||||
{activeTab === 'official' && (
|
{activeTab === 'official' && (
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">官方应用列表</h3>
|
<h3 className="admin-card-title">官方应用列表</h3>
|
||||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={loadOfficial}>刷新</button>
|
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={loadOfficial}>刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>应用名</th>
|
<th>应用名</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>版本</th>
|
<th>版本</th>
|
||||||
<th>绿标</th>
|
<th>绿标</th>
|
||||||
<th>安检</th>
|
<th>安检</th>
|
||||||
<th>安装量</th>
|
<th>安装量</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{officialApps.map(app => (
|
{officialApps.map(app => (
|
||||||
<tr key={app.id}>
|
<tr key={app.id}>
|
||||||
<td style={{ fontWeight: 600 }}>{app.name}</td>
|
<td style={{ fontWeight: 600 }}>{app.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
|
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
|
||||||
{app.type.toUpperCase()}
|
{app.type.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>v{app.version}</td>
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>v{app.version}</td>
|
||||||
<td>
|
<td>
|
||||||
{app.verified
|
{app.verified
|
||||||
? <span style={{ color: '#16a34a', fontWeight: 600 }}>✓ 已认证</span>
|
? <span style={{ color: '#16a34a', fontWeight: 600 }}>✓ 已认证</span>
|
||||||
: <span style={{ color: 'var(--color-text-disabled)' }}>未认证</span>}
|
: <span style={{ color: 'var(--color-text-disabled)' }}>未认证</span>}
|
||||||
</td>
|
</td>
|
||||||
<td><SecurityBadge scan={app.securityScan} /></td>
|
<td><SecurityBadge scan={app.securityScan} /></td>
|
||||||
<td>{app.installCount.toLocaleString()}</td>
|
<td>{app.installCount.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleRescan(app.id)}>重新安检</button>
|
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleRescan(app.id)}>重新安检</button>
|
||||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => openVersions(app)}>版本历史</button>
|
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => openVersions(app)}>版本历史</button>
|
||||||
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleRemoveOfficial(app.id)}>下架</button>
|
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleRemoveOfficial(app.id)}>下架</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{officialApps.length === 0 && !loading && (
|
{officialApps.length === 0 && !loading && (
|
||||||
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无官方应用</td></tr>
|
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无官方应用</td></tr>
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-3xl)' }}>加载中...</td></tr>
|
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-3xl)' }}>加载中...</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Version History Modal */}
|
{/* Version History Modal */}
|
||||||
{versionsApp && (
|
{versionsApp && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
|
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
animation: 'fadeIn var(--transition-normal)',
|
animation: 'fadeIn var(--transition-normal)',
|
||||||
}} onClick={() => setVersionsApp(null)}>
|
}} onClick={() => setVersionsApp(null)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
|
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
|
||||||
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
||||||
}} onClick={e => e.stopPropagation()}>
|
}} onClick={e => e.stopPropagation()}>
|
||||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-lg)' }}>
|
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-lg)' }}>
|
||||||
版本历史 · {versionsApp.name}
|
版本历史 · {versionsApp.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-2xl)' }}>
|
<p style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-2xl)' }}>
|
||||||
当前版本:v{versionsApp.version}
|
当前版本:v{versionsApp.version}
|
||||||
</p>
|
</p>
|
||||||
{loadingVersions ? (
|
{loadingVersions ? (
|
||||||
<p style={{ color: 'var(--color-text-disabled)' }}>加载中...</p>
|
<p style={{ color: 'var(--color-text-disabled)' }}>加载中...</p>
|
||||||
) : versions.length === 0 ? (
|
) : versions.length === 0 ? (
|
||||||
<p style={{ color: 'var(--color-text-disabled)' }}>暂无历史版本</p>
|
<p style={{ color: 'var(--color-text-disabled)' }}>暂无历史版本</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>版本</th><th>安检</th><th>创建时间</th><th>操作</th></tr>
|
<tr><th>版本</th><th>安检</th><th>创建时间</th><th>操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{versions.map((v: any) => (
|
{versions.map((v: any) => (
|
||||||
<tr key={v.id}>
|
<tr key={v.id}>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)' }}>v{v.version}</td>
|
<td style={{ fontFamily: 'var(--font-mono)' }}>v{v.version}</td>
|
||||||
<td><SecurityBadge scan={v.security_scan} /></td>
|
<td><SecurityBadge scan={v.security_scan} /></td>
|
||||||
<td style={{ fontSize: 'var(--text-xs)' }}>{formatDate(v.created_at)}</td>
|
<td style={{ fontSize: 'var(--text-xs)' }}>{formatDate(v.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleRollback(versionsApp.id, v.id)}>
|
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleRollback(versionsApp.id, v.id)}>
|
||||||
回滚到此版本
|
回滚到此版本
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
<div className="admin-form-actions" style={{ marginTop: 'var(--space-2xl)' }}>
|
<div className="admin-form-actions" style={{ marginTop: 'var(--space-2xl)' }}>
|
||||||
<button className="admin-btn admin-btn-outline" onClick={() => setVersionsApp(null)}>关闭</button>
|
<button className="admin-btn admin-btn-outline" onClick={() => setVersionsApp(null)}>关闭</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Market;
|
export default Market;
|
||||||
+548
-548
File diff suppressed because it is too large.
Load diff
@@ -1,379 +1,379 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const SiteSettings: React.FC = () => {
|
const SiteSettings: React.FC = () => {
|
||||||
const [brand, setBrand] = useState<Record<string, string>>({
|
const [brand, setBrand] = useState<Record<string, string>>({
|
||||||
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
|
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
|
||||||
});
|
});
|
||||||
const [mail, setMail] = useState({
|
const [mail, setMail] = useState({
|
||||||
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
|
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
|
||||||
});
|
});
|
||||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [testResult, setTestResult] = useState<string | null>(null);
|
const [testResult, setTestResult] = useState<string | null>(null);
|
||||||
const [showEmailTest, setShowEmailTest] = useState(false);
|
const [showEmailTest, setShowEmailTest] = useState(false);
|
||||||
const [emailTestTo, setEmailTestTo] = useState('');
|
const [emailTestTo, setEmailTestTo] = useState('');
|
||||||
const [emailTestSubject, setEmailTestSubject] = useState('');
|
const [emailTestSubject, setEmailTestSubject] = useState('');
|
||||||
const [emailTestBody, setEmailTestBody] = useState('');
|
const [emailTestBody, setEmailTestBody] = useState('');
|
||||||
const [emailGenerating, setEmailGenerating] = useState(false);
|
const [emailGenerating, setEmailGenerating] = useState(false);
|
||||||
|
|
||||||
// AI生成测试邮件内容
|
// AI生成测试邮件内容
|
||||||
const handleAIGenerateContent = async () => {
|
const handleAIGenerateContent = async () => {
|
||||||
setEmailGenerating(true);
|
setEmailGenerating(true);
|
||||||
try {
|
try {
|
||||||
const res = await adminAPI.generateEmailContent('测试邮件');
|
const res = await adminAPI.generateEmailContent('测试邮件');
|
||||||
const data = (res as any).data || res;
|
const data = (res as any).data || res;
|
||||||
setEmailTestSubject(data.subject || '');
|
setEmailTestSubject(data.subject || '');
|
||||||
setEmailTestBody(data.body || '');
|
setEmailTestBody(data.body || '');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 降级:使用客户端预设内容
|
// 降级:使用客户端预设内容
|
||||||
const subjects = [
|
const subjects = [
|
||||||
'【测试】AIFlow邮件服务配置验证',
|
'【测试】AIFlow邮件服务配置验证',
|
||||||
'AIFlow平台 - 邮件投递测试',
|
'AIFlow平台 - 邮件投递测试',
|
||||||
'测试邮件:您的SMTP服务已就绪',
|
'测试邮件:您的SMTP服务已就绪',
|
||||||
];
|
];
|
||||||
const bodies = [
|
const bodies = [
|
||||||
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
|
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
|
||||||
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
|
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
|
||||||
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
|
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
|
||||||
];
|
];
|
||||||
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
|
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
|
||||||
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
|
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
|
||||||
} finally {
|
} finally {
|
||||||
setEmailGenerating(false);
|
setEmailGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载真实配置
|
// 加载真实配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const [siteRes, emailRes, smsRes] = await Promise.all([
|
const [siteRes, emailRes, smsRes] = await Promise.all([
|
||||||
adminAPI.getSiteSettings(),
|
adminAPI.getSiteSettings(),
|
||||||
adminAPI.getEmailConfig(),
|
adminAPI.getEmailConfig(),
|
||||||
adminAPI.getSmsConfig(),
|
adminAPI.getSmsConfig(),
|
||||||
]);
|
]);
|
||||||
if (siteRes?.data) {
|
if (siteRes?.data) {
|
||||||
setBrand({
|
setBrand({
|
||||||
logo: siteRes.data.logo || '',
|
logo: siteRes.data.logo || '',
|
||||||
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
|
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
|
||||||
announcement: siteRes.data.announcement || '',
|
announcement: siteRes.data.announcement || '',
|
||||||
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
|
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
|
||||||
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
|
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (emailRes?.data) {
|
if (emailRes?.data) {
|
||||||
setMail({
|
setMail({
|
||||||
smtp_host: emailRes.data.smtp_host || '',
|
smtp_host: emailRes.data.smtp_host || '',
|
||||||
smtp_port: emailRes.data.smtp_port || 587,
|
smtp_port: emailRes.data.smtp_port || 587,
|
||||||
username: emailRes.data.username || '',
|
username: emailRes.data.username || '',
|
||||||
password: emailRes.data.password || '',
|
password: emailRes.data.password || '',
|
||||||
from_address: emailRes.data.from_address || '',
|
from_address: emailRes.data.from_address || '',
|
||||||
use_tls: emailRes.data.use_tls !== false,
|
use_tls: emailRes.data.use_tls !== false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (smsRes?.data) {
|
if (smsRes?.data) {
|
||||||
setSms({
|
setSms({
|
||||||
provider: smsRes.data.provider || '',
|
provider: smsRes.data.provider || '',
|
||||||
api_key: smsRes.data.api_key || '',
|
api_key: smsRes.data.api_key || '',
|
||||||
api_secret: smsRes.data.api_secret || '',
|
api_secret: smsRes.data.api_secret || '',
|
||||||
sign_name: smsRes.data.sign_name || '',
|
sign_name: smsRes.data.sign_name || '',
|
||||||
template_code: smsRes.data.template_code || '',
|
template_code: smsRes.data.template_code || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败:', e);
|
console.error('加载配置失败:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadConfig();
|
loadConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showSaved = (block: string) => {
|
const showSaved = (block: string) => {
|
||||||
setSavedMsg(block + ' 保存成功');
|
setSavedMsg(block + ' 保存成功');
|
||||||
setTimeout(() => setSavedMsg(null), 2000);
|
setTimeout(() => setSavedMsg(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showTestResult = (result: string) => {
|
const showTestResult = (result: string) => {
|
||||||
setTestResult(result);
|
setTestResult(result);
|
||||||
setTimeout(() => setTestResult(null), 5000);
|
setTimeout(() => setTestResult(null), 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存品牌设置
|
// 保存品牌设置
|
||||||
const saveSiteSettings = async () => {
|
const saveSiteSettings = async () => {
|
||||||
try {
|
try {
|
||||||
await adminAPI.updateSiteSettings({
|
await adminAPI.updateSiteSettings({
|
||||||
logo: brand.logo,
|
logo: brand.logo,
|
||||||
site_name: brand.siteName,
|
site_name: brand.siteName,
|
||||||
announcement: brand.announcement,
|
announcement: brand.announcement,
|
||||||
icp_number: brand.icpNumber,
|
icp_number: brand.icpNumber,
|
||||||
contact_phone: brand.contactPhone,
|
contact_phone: brand.contactPhone,
|
||||||
});
|
});
|
||||||
showSaved('品牌设置');
|
showSaved('品牌设置');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert('保存失败: ' + (e.detail || e.message));
|
alert('保存失败: ' + (e.detail || e.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存邮件配置
|
// 保存邮件配置
|
||||||
const saveEmailConfig = async () => {
|
const saveEmailConfig = async () => {
|
||||||
try {
|
try {
|
||||||
await adminAPI.updateEmailConfig({
|
await adminAPI.updateEmailConfig({
|
||||||
smtp_host: mail.smtp_host,
|
smtp_host: mail.smtp_host,
|
||||||
smtp_port: mail.smtp_port,
|
smtp_port: mail.smtp_port,
|
||||||
username: mail.username,
|
username: mail.username,
|
||||||
password: mail.password,
|
password: mail.password,
|
||||||
from_address: mail.from_address,
|
from_address: mail.from_address,
|
||||||
use_tls: mail.use_tls,
|
use_tls: mail.use_tls,
|
||||||
});
|
});
|
||||||
showSaved('邮件配置');
|
showSaved('邮件配置');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert('保存失败: ' + (e.detail || e.message));
|
alert('保存失败: ' + (e.detail || e.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存短信配置
|
// 保存短信配置
|
||||||
// 发送测试邮件 - 先保存配置再发送
|
// 发送测试邮件 - 先保存配置再发送
|
||||||
const sendTestEmail = async () => {
|
const sendTestEmail = async () => {
|
||||||
if (!emailTestTo.trim()) {
|
if (!emailTestTo.trim()) {
|
||||||
showTestResult('请输入收件邮箱地址');
|
showTestResult('请输入收件邮箱地址');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// 测试前先保存邮件配置到数据库,确保使用最新配置
|
// 测试前先保存邮件配置到数据库,确保使用最新配置
|
||||||
await adminAPI.updateEmailConfig({
|
await adminAPI.updateEmailConfig({
|
||||||
smtp_host: mail.smtp_host,
|
smtp_host: mail.smtp_host,
|
||||||
smtp_port: mail.smtp_port,
|
smtp_port: mail.smtp_port,
|
||||||
username: mail.username,
|
username: mail.username,
|
||||||
password: mail.password,
|
password: mail.password,
|
||||||
from_address: mail.from_address,
|
from_address: mail.from_address,
|
||||||
use_tls: mail.use_tls,
|
use_tls: mail.use_tls,
|
||||||
});
|
});
|
||||||
const res = await adminAPI.testEmail({
|
const res = await adminAPI.testEmail({
|
||||||
test_to: emailTestTo.trim(),
|
test_to: emailTestTo.trim(),
|
||||||
test_subject: emailTestSubject || undefined,
|
test_subject: emailTestSubject || undefined,
|
||||||
test_body: emailTestBody || undefined,
|
test_body: emailTestBody || undefined,
|
||||||
});
|
});
|
||||||
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
|
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
|
||||||
setShowEmailTest(false);
|
setShowEmailTest(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
|
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEmailTest = () => {
|
const openEmailTest = () => {
|
||||||
setEmailTestTo('');
|
setEmailTestTo('');
|
||||||
setEmailTestSubject('');
|
setEmailTestSubject('');
|
||||||
setEmailTestBody('');
|
setEmailTestBody('');
|
||||||
setShowEmailTest(true);
|
setShowEmailTest(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ padding: 40, textAlign: 'center' }}>加载中...</div>;
|
return <div style={{ padding: 40, textAlign: 'center' }}>加载中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
|
||||||
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>网站基础设置</h1>
|
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>网站基础设置</h1>
|
||||||
{savedMsg && (
|
{savedMsg && (
|
||||||
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
|
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
|
||||||
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
||||||
{' '}{savedMsg}
|
{' '}{savedMsg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand Settings */}
|
{/* Brand Settings */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">
|
<h3 className="admin-card-title">
|
||||||
<SVGIcon size={18}><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"/></SVGIcon>
|
<SVGIcon size={18}><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"/></SVGIcon>
|
||||||
{' '}品牌设置
|
{' '}品牌设置
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">LOGO URL</label>
|
<label className="admin-form-label">LOGO URL</label>
|
||||||
<input className="admin-form-input" value={brand.logo} onChange={e => setBrand({ ...brand, logo: e.target.value })} />
|
<input className="admin-form-input" value={brand.logo} onChange={e => setBrand({ ...brand, logo: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">网站名称</label>
|
<label className="admin-form-label">网站名称</label>
|
||||||
<input className="admin-form-input" value={brand.siteName} onChange={e => setBrand({ ...brand, siteName: e.target.value })} />
|
<input className="admin-form-input" value={brand.siteName} onChange={e => setBrand({ ...brand, siteName: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">首页公告</label>
|
<label className="admin-form-label">首页公告</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="admin-form-input admin-form-textarea"
|
className="admin-form-input admin-form-textarea"
|
||||||
value={brand.announcement}
|
value={brand.announcement}
|
||||||
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
|
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
|
||||||
style={{ minHeight: 60 }}
|
style={{ minHeight: 60 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">备案号</label>
|
<label className="admin-form-label">备案号</label>
|
||||||
<input className="admin-form-input" value={brand.icpNumber} onChange={e => setBrand({ ...brand, icpNumber: e.target.value })} />
|
<input className="admin-form-input" value={brand.icpNumber} onChange={e => setBrand({ ...brand, icpNumber: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">客服联系方式</label>
|
<label className="admin-form-label">客服联系方式</label>
|
||||||
<input className="admin-form-input" value={brand.contactPhone} onChange={e => setBrand({ ...brand, contactPhone: e.target.value })} />
|
<input className="admin-form-input" value={brand.contactPhone} onChange={e => setBrand({ ...brand, contactPhone: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-actions">
|
<div className="admin-form-actions">
|
||||||
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
|
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
|
||||||
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
||||||
保存设置
|
保存设置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mail Config */}
|
{/* Mail Config */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">
|
<h3 className="admin-card-title">
|
||||||
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
|
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
|
||||||
{' '}邮件配置
|
{' '}邮件配置
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">SMTP服务器</label>
|
<label className="admin-form-label">SMTP服务器</label>
|
||||||
<input className="admin-form-input" value={mail.smtp_host} onChange={e => setMail({ ...mail, smtp_host: e.target.value })} />
|
<input className="admin-form-input" value={mail.smtp_host} onChange={e => setMail({ ...mail, smtp_host: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">端口</label>
|
<label className="admin-form-label">端口</label>
|
||||||
<input className="admin-form-input" type="number" value={mail.smtp_port} onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
|
<input className="admin-form-input" type="number" value={mail.smtp_port} onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">用户名</label>
|
<label className="admin-form-label">用户名</label>
|
||||||
<input className="admin-form-input" value={mail.username} onChange={e => setMail({ ...mail, username: e.target.value })} />
|
<input className="admin-form-input" value={mail.username} onChange={e => setMail({ ...mail, username: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">密码</label>
|
<label className="admin-form-label">密码</label>
|
||||||
<input className="admin-form-input" type="password" value={mail.password} onChange={e => setMail({ ...mail, password: e.target.value })} />
|
<input className="admin-form-input" type="password" value={mail.password} onChange={e => setMail({ ...mail, password: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-group" style={{ maxWidth: '50%' }}>
|
<div className="admin-form-group" style={{ maxWidth: '50%' }}>
|
||||||
<label className="admin-form-label">发件地址</label>
|
<label className="admin-form-label">发件地址</label>
|
||||||
<input className="admin-form-input" value={mail.from_address} onChange={e => setMail({ ...mail, from_address: e.target.value })} />
|
<input className="admin-form-input" value={mail.from_address} onChange={e => setMail({ ...mail, from_address: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-actions">
|
<div className="admin-form-actions">
|
||||||
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
|
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
|
||||||
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
|
||||||
保存设置
|
保存设置
|
||||||
</button>
|
</button>
|
||||||
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
|
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
|
||||||
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
||||||
发送测试邮件
|
发送测试邮件
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SMS Config */}
|
{/* SMS Config */}
|
||||||
{/* Email Test Modal */}
|
{/* Email Test Modal */}
|
||||||
{showEmailTest && (
|
{showEmailTest && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
|
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}} onClick={() => setShowEmailTest(false)}>
|
}} onClick={() => setShowEmailTest(false)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
|
background: '#fff', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
|
||||||
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
||||||
}} onClick={e => e.stopPropagation()}>
|
}} onClick={e => e.stopPropagation()}>
|
||||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
|
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
|
||||||
<SVGIcon size={20}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
|
<SVGIcon size={20}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
|
||||||
{' '}发送测试邮件
|
{' '}发送测试邮件
|
||||||
</h3>
|
</h3>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">收件邮箱 <span style={{ color: 'var(--color-error)' }}>*</span></label>
|
<label className="admin-form-label">收件邮箱 <span style={{ color: 'var(--color-error)' }}>*</span></label>
|
||||||
<input
|
<input
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
type="email"
|
type="email"
|
||||||
value={emailTestTo}
|
value={emailTestTo}
|
||||||
onChange={e => setEmailTestTo(e.target.value)}
|
onChange={e => setEmailTestTo(e.target.value)}
|
||||||
placeholder="例如: admin@example.com"
|
placeholder="例如: admin@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">邮件主题</label>
|
<label className="admin-form-label">邮件主题</label>
|
||||||
<input
|
<input
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
value={emailTestSubject}
|
value={emailTestSubject}
|
||||||
onChange={e => setEmailTestSubject(e.target.value)}
|
onChange={e => setEmailTestSubject(e.target.value)}
|
||||||
placeholder="测试邮件标题"
|
placeholder="测试邮件标题"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">邮件内容</label>
|
<label className="admin-form-label">邮件内容</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="admin-form-input admin-form-textarea"
|
className="admin-form-input admin-form-textarea"
|
||||||
value={emailTestBody}
|
value={emailTestBody}
|
||||||
onChange={e => setEmailTestBody(e.target.value)}
|
onChange={e => setEmailTestBody(e.target.value)}
|
||||||
style={{ minHeight: 120 }}
|
style={{ minHeight: 120 }}
|
||||||
placeholder="测试邮件内容..."
|
placeholder="测试邮件内容..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||||
onClick={handleAIGenerateContent}
|
onClick={handleAIGenerateContent}
|
||||||
disabled={emailGenerating}
|
disabled={emailGenerating}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||||
>
|
>
|
||||||
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||||
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
|
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-actions">
|
<div className="admin-form-actions">
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-primary"
|
className="admin-btn admin-btn-primary"
|
||||||
onClick={sendTestEmail}
|
onClick={sendTestEmail}
|
||||||
disabled={!emailTestTo.trim()}
|
disabled={!emailTestTo.trim()}
|
||||||
>
|
>
|
||||||
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
|
||||||
发送测试邮件
|
发送测试邮件
|
||||||
</button>
|
</button>
|
||||||
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>
|
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Test Result Toast */}
|
{/* Test Result Toast */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
|
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
|
||||||
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
|
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
|
||||||
color: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
color: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
zIndex: 9999, fontSize: 'var(--text-sm)',
|
zIndex: 9999, fontSize: 'var(--text-sm)',
|
||||||
}}>
|
}}>
|
||||||
{testResult}
|
{testResult}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SiteSettings;
|
export default SiteSettings;
|
||||||
+682
-682
File diff suppressed because it is too large.
Load diff
+1781
-1781
File diff suppressed because it is too large.
Load diff
+2038
-2038
File diff suppressed because it is too large.
Load diff
+556
-556
File diff suppressed because it is too large.
Load diff
@@ -1,20 +1,20 @@
|
|||||||
-- 018: 将 market_apps.capability 从旧的「AI 技术能力」取值规范为「业务场景」取值
|
-- 018: 将 market_apps.capability 从旧的「AI 技术能力」取值规范为「业务场景」取值
|
||||||
-- 旧的 text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video 已不再使用,
|
-- 旧的 text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video 已不再使用,
|
||||||
-- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。
|
-- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。
|
||||||
-- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。
|
-- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。
|
||||||
-- 注意:当前业务场景标准 = 前台「应用市场」14 类 (MARKET_SCENES):
|
-- 注意:当前业务场景标准 = 前台「应用市场」14 类 (MARKET_SCENES):
|
||||||
-- design, frontend, mobile, backend, qa, search, docs, marketing,
|
-- design, frontend, mobile, backend, qa, search, docs, marketing,
|
||||||
-- workflow, secops, learning, video, collab, ai
|
-- workflow, secops, learning, video, collab, ai
|
||||||
-- 这些为合法取值,必须保留,不得置空(#707 修复:此前 allowlist 不含 14 类导致重启被清空)。
|
-- 这些为合法取值,必须保留,不得置空(#707 修复:此前 allowlist 不含 14 类导致重启被清空)。
|
||||||
UPDATE market_apps
|
UPDATE market_apps
|
||||||
SET capability = NULL
|
SET capability = NULL
|
||||||
WHERE capability IS NOT NULL
|
WHERE capability IS NOT NULL
|
||||||
AND capability NOT IN (
|
AND capability NOT IN (
|
||||||
'design', 'frontend', 'mobile', 'backend', 'qa', 'search', 'docs', 'marketing',
|
'design', 'frontend', 'mobile', 'backend', 'qa', 'search', 'docs', 'marketing',
|
||||||
'workflow', 'secops', 'learning', 'video', 'collab', 'ai',
|
'workflow', 'secops', 'learning', 'video', 'collab', 'ai',
|
||||||
'freelance_content', 'freelance_client', 'freelance_finance',
|
'freelance_content', 'freelance_client', 'freelance_finance',
|
||||||
'office_doc', 'office_data', 'office_email', 'office_pm',
|
'office_doc', 'office_data', 'office_email', 'office_pm',
|
||||||
'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home',
|
'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home',
|
||||||
'biz_product', 'biz_service', 'biz_ops',
|
'biz_product', 'biz_service', 'biz_ops',
|
||||||
'general_write', 'general_research', 'general_agent'
|
'general_write', 'general_research', 'general_agent'
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
CREATE TABLE IF NOT EXISTS conversation_summaries (
|
CREATE TABLE IF NOT EXISTS conversation_summaries (
|
||||||
conversation_id VARCHAR(64) NOT NULL,
|
conversation_id VARCHAR(64) NOT NULL,
|
||||||
summary_text TEXT NULL,
|
summary_text TEXT NULL,
|
||||||
token_count INT NULL DEFAULT 0,
|
token_count INT NULL DEFAULT 0,
|
||||||
updated_at DATETIME(3) NULL,
|
updated_at DATETIME(3) NULL,
|
||||||
PRIMARY KEY (conversation_id)
|
PRIMARY KEY (conversation_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Reference in new issue
Block a user