T4: AnySearch 改为全局固定配置 + 修复外部服务弹窗 UI
This commit is contained in:
1 parent
3c9d9518b1
commit
97125d7b65
6 files changed
+634
-441
No files matched your search
@@ -1,118 +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 logging
|
import json
|
||||||
from typing import List, Dict, Optional
|
import logging
|
||||||
|
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_ZONE = "cn"
|
DEFAULT_API_URL = "https://api.anysearch.com/v1/search"
|
||||||
REQUEST_TIMEOUT = 8.0
|
DEFAULT_ZONE = "cn"
|
||||||
|
REQUEST_TIMEOUT = 8.0
|
||||||
|
|
||||||
async def anysearch_search(
|
|
||||||
api_key: str,
|
async def anysearch_search(
|
||||||
api_url: str = DEFAULT_API_URL,
|
api_key: str,
|
||||||
query: str = "",
|
api_url: str = DEFAULT_API_URL,
|
||||||
top_k: int = 5,
|
query: str = "",
|
||||||
zone: str = DEFAULT_ZONE,
|
top_k: int = 5,
|
||||||
freshness: Optional[str] = None,
|
zone: str = DEFAULT_ZONE,
|
||||||
timeout: float = REQUEST_TIMEOUT,
|
freshness: Optional[str] = None,
|
||||||
) -> List[Dict]:
|
timeout: float = REQUEST_TIMEOUT,
|
||||||
"""调用 AnySearch 搜索,返回结构化结果列表。
|
) -> List[Dict]:
|
||||||
|
"""调用 AnySearch 搜索,返回结构化结果列表。
|
||||||
每个结果:
|
|
||||||
{
|
每个结果:
|
||||||
"content": snippet 摘要文本,
|
{
|
||||||
"score": 0.9..0.5 递减,
|
"content": snippet 摘要文本,
|
||||||
"source": "web_anysearch",
|
"score": 0.9..0.5 递减,
|
||||||
"metadata": {"url", "title", "source_domain", "zone"}
|
"source": "web_anysearch",
|
||||||
}
|
"metadata": {"url", "title", "source_domain", "zone"}
|
||||||
失败时返回 [] (不抛异常)。
|
}
|
||||||
"""
|
失败时返回 [] (不抛异常)。
|
||||||
if not api_key:
|
"""
|
||||||
return []
|
if not api_key:
|
||||||
try:
|
return []
|
||||||
import httpx
|
try:
|
||||||
except ImportError:
|
import httpx
|
||||||
logger.warning("httpx not available for AnySearch")
|
except ImportError:
|
||||||
return []
|
logger.warning("httpx not available for AnySearch")
|
||||||
|
return []
|
||||||
payload = {
|
|
||||||
"query": query,
|
payload = {
|
||||||
"zone": zone,
|
"query": query,
|
||||||
"top_k": max(1, min(int(top_k), 20)),
|
"zone": zone,
|
||||||
}
|
"top_k": max(1, min(int(top_k), 20)),
|
||||||
if freshness:
|
}
|
||||||
payload["freshness"] = freshness
|
if freshness:
|
||||||
|
payload["freshness"] = freshness
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Content-Type": "application/json",
|
||||||
}
|
"Authorization": f"Bearer {api_key}",
|
||||||
try:
|
}
|
||||||
# trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com
|
try:
|
||||||
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
# trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com
|
||||||
resp = await client.post(api_url, json=payload, headers=headers)
|
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
||||||
if resp.status_code != 200:
|
resp = await client.post(api_url, json=payload, headers=headers)
|
||||||
logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}")
|
if resp.status_code != 200:
|
||||||
return []
|
logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
data = resp.json()
|
return []
|
||||||
if data.get("code") != 0:
|
data = resp.json()
|
||||||
logger.warning(f"AnySearch biz error: {data.get('message')}")
|
if data.get("code") != 0:
|
||||||
return []
|
logger.warning(f"AnySearch biz error: {data.get('message')}")
|
||||||
results = (data.get("data") or {}).get("results") or []
|
return []
|
||||||
hits = []
|
results = (data.get("data") or {}).get("results") or []
|
||||||
for idx, r in enumerate(results[:top_k]):
|
hits = []
|
||||||
title = r.get("title", "") or ""
|
for idx, r in enumerate(results[:top_k]):
|
||||||
url = r.get("url", "") or ""
|
title = r.get("title", "") or ""
|
||||||
snippet = r.get("snippet") or r.get("content") or ""
|
url = r.get("url", "") or ""
|
||||||
if not snippet and not title:
|
snippet = r.get("snippet") or r.get("content") or ""
|
||||||
continue
|
if not snippet and not title:
|
||||||
try:
|
continue
|
||||||
from urllib.parse import urlparse
|
try:
|
||||||
domain = urlparse(url).netloc
|
from urllib.parse import urlparse
|
||||||
except Exception:
|
domain = urlparse(url).netloc
|
||||||
domain = ""
|
except Exception:
|
||||||
hits.append({
|
domain = ""
|
||||||
"content": snippet,
|
hits.append({
|
||||||
"score": max(0.4, 0.9 - idx * 0.08),
|
"content": snippet,
|
||||||
"source": "web_anysearch",
|
"score": max(0.4, 0.9 - idx * 0.08),
|
||||||
"metadata": {
|
"source": "web_anysearch",
|
||||||
"url": url,
|
"metadata": {
|
||||||
"title": title,
|
"url": url,
|
||||||
"source_domain": domain,
|
"title": title,
|
||||||
"zone": zone,
|
"source_domain": domain,
|
||||||
},
|
"zone": zone,
|
||||||
})
|
},
|
||||||
return hits
|
})
|
||||||
except Exception as e:
|
return hits
|
||||||
logger.warning(f"AnySearch search failed: {e}")
|
except Exception as e:
|
||||||
return []
|
logger.warning(f"AnySearch search failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict:
|
|
||||||
"""连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。"""
|
async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict:
|
||||||
if not api_key:
|
"""连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。"""
|
||||||
return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False}
|
if not api_key:
|
||||||
try:
|
return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False}
|
||||||
import httpx
|
try:
|
||||||
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
import httpx
|
||||||
resp = await client.post(
|
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
|
||||||
api_url,
|
resp = await client.post(
|
||||||
json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1},
|
api_url,
|
||||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1},
|
||||||
)
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
||||||
if resp.status_code == 200:
|
)
|
||||||
return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True}
|
if resp.status_code == 200:
|
||||||
return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False}
|
return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True}
|
||||||
except Exception as e:
|
return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False}
|
||||||
return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False}
|
except Exception as e:
|
||||||
|
return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False}
|
||||||
|
|
||||||
|
|
||||||
|
def load_anysearch_config(db_session) -> Dict:
|
||||||
|
"""从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。"""
|
||||||
|
if db_session is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
from app.models import SiteSetting
|
||||||
|
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
||||||
|
if not row or not row.value:
|
||||||
|
return {}
|
||||||
|
cfg = json.loads(row.value)
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
return {}
|
||||||
|
return cfg
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"load_anysearch_config failed: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_anysearch_config(
|
||||||
|
db_session,
|
||||||
|
api_key: str,
|
||||||
|
is_enabled: bool = True,
|
||||||
|
api_url: str = DEFAULT_API_URL,
|
||||||
|
zone: str = DEFAULT_ZONE,
|
||||||
|
freshness: Optional[str] = None,
|
||||||
|
top_k: int = 5,
|
||||||
|
) -> bool:
|
||||||
|
"""保存/更新全局 AnySearch 配置到 site_settings。"""
|
||||||
|
if db_session is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from app.models import SiteSetting
|
||||||
|
from datetime import datetime
|
||||||
|
cfg = {
|
||||||
|
"api_key": api_key.strip(),
|
||||||
|
"is_enabled": bool(is_enabled),
|
||||||
|
"api_url": api_url.strip() or DEFAULT_API_URL,
|
||||||
|
"zone": zone or DEFAULT_ZONE,
|
||||||
|
"freshness": freshness,
|
||||||
|
"top_k": max(1, min(int(top_k), 20)),
|
||||||
|
}
|
||||||
|
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
||||||
|
if row:
|
||||||
|
row.value = json.dumps(cfg, ensure_ascii=False)
|
||||||
|
row.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
from app.models import gen_uuid
|
||||||
|
row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False))
|
||||||
|
db_session.add(row)
|
||||||
|
db_session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"save_anysearch_config failed: {e}")
|
||||||
|
try:
|
||||||
|
db_session.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
@@ -32,12 +32,17 @@ logger = logging.getLogger("aiflow")
|
|||||||
|
|
||||||
class ExternalServiceConfig(BaseModel):
|
class ExternalServiceConfig(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
service_type: str = Field(..., description="dify / anysearch")
|
service_type: str = Field(..., description="dify")
|
||||||
api_url: str = Field(min_length=1, max_length=500)
|
api_url: str = Field(min_length=1, max_length=500)
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
config: Optional[Dict[str, Any]] = None
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnySearchConfig(BaseModel):
|
||||||
|
api_key: str = Field(min_length=1, max_length=500)
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ExternalServiceUpdate(BaseModel):
|
class ExternalServiceUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
api_url: Optional[str] = None
|
api_url: Optional[str] = None
|
||||||
@@ -739,14 +744,11 @@ def _get_external_services_table(db: Session):
|
|||||||
|
|
||||||
|
|
||||||
async def _check_service_connection(service_type: str, api_url: str, api_key: str) -> dict:
|
async def _check_service_connection(service_type: str, api_url: str, api_key: str) -> dict:
|
||||||
"""测试外部知识库服务连接状态(支持 Dify / AnySearch)"""
|
"""测试外部知识库服务连接状态(支持 Dify)"""
|
||||||
try:
|
try:
|
||||||
if service_type == "dify":
|
if service_type == "dify":
|
||||||
from ...dify_kb import dify_test
|
from ...dify_kb import dify_test
|
||||||
return dify_test(api_url, api_key)
|
return dify_test(api_url, api_key)
|
||||||
if service_type == "anysearch":
|
|
||||||
from ...anysearch_client import anysearch_health
|
|
||||||
return await anysearch_health(api_key or "", api_url or "https://api.anysearch.com/v1/search")
|
|
||||||
else:
|
else:
|
||||||
# 未知类型(如历史遗留的 fastgpt/ragflow),按通用方式探测可达性
|
# 未知类型(如历史遗留的 fastgpt/ragflow),按通用方式探测可达性
|
||||||
headers = {}
|
headers = {}
|
||||||
@@ -784,7 +786,6 @@ async def list_external_services(
|
|||||||
"items": services,
|
"items": services,
|
||||||
"supported_types": [
|
"supported_types": [
|
||||||
{"type": "dify", "name": "Dify", "description": "LLM 应用开发平台,内置知识库管理(RAG)"},
|
{"type": "dify", "name": "Dify", "description": "LLM 应用开发平台,内置知识库管理(RAG)"},
|
||||||
{"type": "anysearch", "name": "AnySearch 联网检索", "description": "实时联网网页/领域搜索,为 AI 聊天注入最新网络信息"},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,8 +798,8 @@ async def create_external_service(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""创建新的外部知识库服务连接配置"""
|
"""创建新的外部知识库服务连接配置"""
|
||||||
# 验证服务类型(当前仅支持 Dify)
|
# 验证服务类型(当前仅支持 Dify;AnySearch 已改为全局固定配置)
|
||||||
valid_types = ["dify", "anysearch"]
|
valid_types = ["dify"]
|
||||||
if req.service_type not in valid_types:
|
if req.service_type not in valid_types:
|
||||||
raise HTTPException(status_code=400, detail=f"不支持的服务类型: {req.service_type}")
|
raise HTTPException(status_code=400, detail=f"不支持的服务类型: {req.service_type}")
|
||||||
|
|
||||||
@@ -881,6 +882,54 @@ async def create_external_service(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anysearch/config", summary="获取 AnySearch 全局配置")
|
||||||
|
async def get_anysearch_config(
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""读取全局 AnySearch 联网检索配置(site_settings 表)。"""
|
||||||
|
from ...anysearch_client import load_anysearch_config, DEFAULT_API_URL
|
||||||
|
cfg = load_anysearch_config(db)
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"api_key": cfg.get("api_key", ""),
|
||||||
|
"is_enabled": cfg.get("is_enabled", False),
|
||||||
|
"api_url": cfg.get("api_url") or DEFAULT_API_URL,
|
||||||
|
"zone": cfg.get("zone", "cn"),
|
||||||
|
"freshness": cfg.get("freshness"),
|
||||||
|
"top_k": cfg.get("top_k", 5),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/anysearch/config", summary="保存 AnySearch 全局配置")
|
||||||
|
async def save_anysearch_config_endpoint(
|
||||||
|
req: AnySearchConfig,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""保存全局 AnySearch 联网检索配置,并测试连通性。"""
|
||||||
|
from ...anysearch_client import save_anysearch_config, anysearch_health, DEFAULT_API_URL
|
||||||
|
ok = save_anysearch_config(db, api_key=req.api_key, is_enabled=req.is_enabled, api_url=DEFAULT_API_URL)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=500, detail="保存 AnySearch 配置失败")
|
||||||
|
health = await anysearch_health(req.api_key or "", DEFAULT_API_URL)
|
||||||
|
LogService.log_audit(
|
||||||
|
admin_id=admin_user.id,
|
||||||
|
admin_name=admin_user.username,
|
||||||
|
action="update_anysearch_config",
|
||||||
|
target_type="site_setting",
|
||||||
|
target_id="anysearch_config",
|
||||||
|
detail=f"更新 AnySearch 全局配置: enabled={req.is_enabled}, connected={health.get('connected')}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "AnySearch 配置已保存",
|
||||||
|
"data": {"connection": health},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/external/services/{service_id}", summary="更新外部知识库服务配置")
|
@router.put("/external/services/{service_id}", summary="更新外部知识库服务配置")
|
||||||
async def update_external_service(
|
async def update_external_service(
|
||||||
service_id: str,
|
service_id: str,
|
||||||
|
|||||||
@@ -1496,27 +1496,18 @@ async def web_search_endpoint(
|
|||||||
):
|
):
|
||||||
"""联网检索(AnySearch):返回实时网页结果,供前端 / 智能体引用。"""
|
"""联网检索(AnySearch):返回实时网页结果,供前端 / 智能体引用。"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import text
|
from ..anysearch_client import anysearch_search, load_anysearch_config
|
||||||
import json as _json
|
cfg = load_anysearch_config(db)
|
||||||
from ..anysearch_client import anysearch_search
|
if not cfg.get("is_enabled") or not cfg.get("api_key"):
|
||||||
row = db.execute(text(
|
|
||||||
"SELECT api_key, api_url, config FROM external_kb_services "
|
|
||||||
"WHERE service_type='anysearch' AND is_enabled=1 LIMIT 1"
|
|
||||||
)).fetchone()
|
|
||||||
if not row:
|
|
||||||
return {"code": 0, "data": {"results": [], "enabled": False, "message": "未配置 AnySearch 联网检索服务"}}
|
return {"code": 0, "data": {"results": [], "enabled": False, "message": "未配置 AnySearch 联网检索服务"}}
|
||||||
api_key = row[0] or ""
|
hits = await anysearch_search(
|
||||||
api_url = row[1] or "https://api.anysearch.com/v1/search"
|
cfg["api_key"],
|
||||||
cfg = row[2]
|
cfg.get("api_url") or "https://api.anysearch.com/v1/search",
|
||||||
if isinstance(cfg, str):
|
req.query,
|
||||||
try:
|
top_k=req.top_k,
|
||||||
cfg = _json.loads(cfg)
|
zone=cfg.get("zone", "cn"),
|
||||||
except Exception:
|
freshness=cfg.get("freshness"),
|
||||||
cfg = {}
|
)
|
||||||
cfg = cfg or {}
|
|
||||||
zone = cfg.get("zone", "cn")
|
|
||||||
freshness = cfg.get("freshness")
|
|
||||||
hits = await anysearch_search(api_key, api_url, req.query, top_k=req.top_k, zone=zone, freshness=freshness)
|
|
||||||
return {"code": 0, "data": {"results": hits, "enabled": True}}
|
return {"code": 0, "data": {"results": hits, "enabled": True}}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"web-search endpoint failed: {e}")
|
logger.warning(f"web-search endpoint failed: {e}")
|
||||||
|
|||||||
+271
-262
@@ -1,262 +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 联网检索服务配置。"""
|
"""获取全局 AnySearch 联网检索配置(从 site_settings 读取)。"""
|
||||||
from sqlalchemy import text
|
from app.anysearch_client import load_anysearch_config
|
||||||
try:
|
cfg = load_anysearch_config(db_session)
|
||||||
query = "SELECT * FROM external_kb_services WHERE service_type='anysearch'"
|
if not cfg:
|
||||||
if enabled_only:
|
return []
|
||||||
query += " AND is_enabled = 1"
|
if enabled_only and not cfg.get("is_enabled"):
|
||||||
query += " ORDER BY created_at DESC"
|
return []
|
||||||
result = db_session.execute(text(query))
|
api_key = cfg.get("api_key", "")
|
||||||
columns = result.keys()
|
if not api_key:
|
||||||
return [dict(zip(columns, row)) for row in result.fetchall()]
|
return []
|
||||||
except Exception as e:
|
return [{
|
||||||
logger.error(f"Failed to get AnySearch services: {e}")
|
"id": "anysearch_global",
|
||||||
return []
|
"service_type": "anysearch",
|
||||||
|
"api_key": api_key,
|
||||||
|
"api_url": cfg.get("api_url") or "https://api.anysearch.com/v1/search",
|
||||||
async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
"config": {
|
||||||
"""调用 AnySearch 联网检索(走 app.anysearch_client)。"""
|
"zone": cfg.get("zone", "cn"),
|
||||||
try:
|
"freshness": cfg.get("freshness"),
|
||||||
from app.anysearch_client import anysearch_search
|
"top_k": cfg.get("top_k", 5),
|
||||||
api_key = service.get("api_key", "")
|
},
|
||||||
api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
|
"is_enabled": cfg.get("is_enabled", False),
|
||||||
cfg = service.get("config")
|
}]
|
||||||
if isinstance(cfg, str):
|
|
||||||
try:
|
|
||||||
import json
|
async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
cfg = json.loads(cfg)
|
"""调用 AnySearch 联网检索(走 app.anysearch_client)。"""
|
||||||
except Exception:
|
try:
|
||||||
cfg = None
|
from app.anysearch_client import anysearch_search
|
||||||
cfg = cfg or {}
|
api_key = service.get("api_key", "")
|
||||||
zone = cfg.get("zone", "cn")
|
api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
|
||||||
freshness = cfg.get("freshness")
|
cfg = service.get("config")
|
||||||
k = int(cfg.get("top_k", top_k))
|
if isinstance(cfg, str):
|
||||||
return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
|
try:
|
||||||
except Exception as e:
|
import json
|
||||||
logger.error(f"AnySearch search error: {e}")
|
cfg = json.loads(cfg)
|
||||||
return []
|
except Exception:
|
||||||
|
cfg = None
|
||||||
|
cfg = cfg or {}
|
||||||
async def kb_router_search(
|
zone = cfg.get("zone", "cn")
|
||||||
query: str,
|
freshness = cfg.get("freshness")
|
||||||
kb_id: Optional[str] = None,
|
k = int(cfg.get("top_k", top_k))
|
||||||
kb_ids: Optional[List[str]] = None,
|
return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
|
||||||
top_k: int = 5,
|
except Exception as e:
|
||||||
bm25_results: Optional[List[Dict]] = None,
|
logger.error(f"AnySearch search error: {e}")
|
||||||
db_session=None,
|
return []
|
||||||
use_vector: bool = True,
|
|
||||||
use_external: bool = True,
|
|
||||||
use_tree: bool = True,
|
async def kb_router_search(
|
||||||
use_web: bool = False,
|
query: str,
|
||||||
) -> List[Dict]:
|
kb_id: Optional[str] = None,
|
||||||
"""
|
kb_ids: Optional[List[str]] = None,
|
||||||
知识库路由层 — 混合检索
|
top_k: int = 5,
|
||||||
|
bm25_results: Optional[List[Dict]] = None,
|
||||||
检索策略:
|
db_session=None,
|
||||||
1. 本地 BM25 检索 (MySQL, 已有)
|
use_vector: bool = True,
|
||||||
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
|
use_external: bool = True,
|
||||||
3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
|
use_tree: bool = True,
|
||||||
4. 外部KB检索 (Dify, 按配置)
|
use_web: bool = False,
|
||||||
5. 联网检索 (AnySearch, 可选 use_web)
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
融合方式: Reciprocal Rank Fusion (RRF)
|
知识库路由层 — 混合检索
|
||||||
|
|
||||||
Args:
|
检索策略:
|
||||||
query: 查询文本
|
1. 本地 BM25 检索 (MySQL, 已有)
|
||||||
kb_id: 知识库ID
|
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
|
||||||
top_k: 返回结果数
|
3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
|
||||||
bm25_results: BM25检索结果
|
4. 外部KB检索 (Dify, 按配置)
|
||||||
db_session: 数据库会话(用于获取外部KB配置与分片段检索)
|
5. 联网检索 (AnySearch, 可选 use_web)
|
||||||
use_vector: 是否使用向量检索
|
|
||||||
use_external: 是否使用外部KB检索
|
融合方式: Reciprocal Rank Fusion (RRF)
|
||||||
use_tree: 是否使用 TreeSearch 结构化分片段检索
|
|
||||||
use_web: 是否使用 AnySearch 联网检索
|
Args:
|
||||||
|
query: 查询文本
|
||||||
Returns:
|
kb_id: 知识库ID
|
||||||
融合后的检索结果列表
|
top_k: 返回结果数
|
||||||
"""
|
bm25_results: BM25检索结果
|
||||||
all_results = []
|
db_session: 数据库会话(用于获取外部KB配置与分片段检索)
|
||||||
|
use_vector: 是否使用向量检索
|
||||||
# 1. BM25 结果
|
use_external: 是否使用外部KB检索
|
||||||
if bm25_results:
|
use_tree: 是否使用 TreeSearch 结构化分片段检索
|
||||||
for rank, item in enumerate(bm25_results):
|
use_web: 是否使用 AnySearch 联网检索
|
||||||
all_results.append({
|
|
||||||
"content": item.get("content", item.get("text", "")),
|
Returns:
|
||||||
"score": item.get("score", 1.0 / (60 + rank + 1)),
|
融合后的检索结果列表
|
||||||
"source": "local_bm25",
|
"""
|
||||||
"rank": rank + 1,
|
all_results = []
|
||||||
"metadata": item.get("metadata", {}),
|
|
||||||
})
|
# 1. BM25 结果
|
||||||
|
if bm25_results:
|
||||||
# 2. 向量检索
|
for rank, item in enumerate(bm25_results):
|
||||||
if use_vector:
|
all_results.append({
|
||||||
try:
|
"content": item.get("content", item.get("text", "")),
|
||||||
from app.pgvector_search import vector_search
|
"score": item.get("score", 1.0 / (60 + rank + 1)),
|
||||||
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
|
"source": "local_bm25",
|
||||||
vec_results = []
|
"rank": rank + 1,
|
||||||
for _kid in kb_targets:
|
"metadata": item.get("metadata", {}),
|
||||||
try:
|
})
|
||||||
vec_results.extend(await vector_search(query, _kid, top_k))
|
|
||||||
except Exception as e:
|
# 2. 向量检索
|
||||||
logger.warning(f"Vector search failed for kb={_kid}: {e}")
|
if use_vector:
|
||||||
for rank, item in enumerate(vec_results):
|
try:
|
||||||
all_results.append({
|
from app.pgvector_search import vector_search
|
||||||
"content": item.get("content", ""),
|
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
|
||||||
"score": item.get("score", 0.5),
|
vec_results = []
|
||||||
"source": "vector_pg",
|
for _kid in kb_targets:
|
||||||
"rank": rank + 1,
|
try:
|
||||||
"metadata": item.get("metadata", {}),
|
vec_results.extend(await vector_search(query, _kid, top_k))
|
||||||
})
|
except Exception as e:
|
||||||
except ImportError:
|
logger.warning(f"Vector search failed for kb={_kid}: {e}")
|
||||||
logger.debug("pgvector_search not available, skipping vector search")
|
for rank, item in enumerate(vec_results):
|
||||||
except Exception as e:
|
all_results.append({
|
||||||
logger.warning(f"Vector search failed: {e}")
|
"content": item.get("content", ""),
|
||||||
|
"score": item.get("score", 0.5),
|
||||||
# 2.5 结构化分片段检索 (TreeSearch, Level 2)
|
"source": "vector_pg",
|
||||||
if use_tree and db_session is not None:
|
"rank": rank + 1,
|
||||||
try:
|
"metadata": item.get("metadata", {}),
|
||||||
from app.tree_search import tree_search
|
})
|
||||||
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
|
except ImportError:
|
||||||
tree_hits = await tree_search(
|
logger.debug("pgvector_search not available, skipping vector search")
|
||||||
db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
|
except Exception as e:
|
||||||
)
|
logger.warning(f"Vector search failed: {e}")
|
||||||
for rank, item in enumerate(tree_hits):
|
|
||||||
all_results.append({
|
# 2.5 结构化分片段检索 (TreeSearch, Level 2)
|
||||||
"content": item.get("content", ""),
|
if use_tree and db_session is not None:
|
||||||
"score": item.get("score", 0.5),
|
try:
|
||||||
"source": "tree_chunk",
|
from app.tree_search import tree_search
|
||||||
"rank": rank + 1,
|
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
|
||||||
"metadata": item.get("metadata", {}),
|
tree_hits = await tree_search(
|
||||||
})
|
db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
|
||||||
except Exception as e:
|
)
|
||||||
logger.warning(f"TreeSearch failed: {e}")
|
for rank, item in enumerate(tree_hits):
|
||||||
|
all_results.append({
|
||||||
# 3. 外部KB检索 (Dify)
|
"content": item.get("content", ""),
|
||||||
if use_external and db_session:
|
"score": item.get("score", 0.5),
|
||||||
services = await _get_external_services(db_session)
|
"source": "tree_chunk",
|
||||||
for service in services:
|
"rank": rank + 1,
|
||||||
if service.get("status") == "connected":
|
"metadata": item.get("metadata", {}),
|
||||||
ext_results = await _search_external(service, query, top_k)
|
})
|
||||||
for rank, item in enumerate(ext_results):
|
except Exception as e:
|
||||||
all_results.append({
|
logger.warning(f"TreeSearch failed: {e}")
|
||||||
"content": item.get("content", ""),
|
|
||||||
"score": item.get("score", 0.5),
|
# 3. 外部KB检索 (Dify)
|
||||||
"source": item.get("source", "external_dify"),
|
if use_external and db_session:
|
||||||
"rank": rank + 1,
|
services = await _get_external_services(db_session)
|
||||||
"metadata": item.get("metadata", {}),
|
for service in services:
|
||||||
})
|
if service.get("status") == "connected":
|
||||||
|
ext_results = await _search_external(service, query, top_k)
|
||||||
# 4. 联网检索 (AnySearch, 可选)
|
for rank, item in enumerate(ext_results):
|
||||||
if use_web and db_session is not None:
|
all_results.append({
|
||||||
try:
|
"content": item.get("content", ""),
|
||||||
web_services = await _get_web_services(db_session)
|
"score": item.get("score", 0.5),
|
||||||
for svc in web_services:
|
"source": item.get("source", "external_dify"),
|
||||||
web_hits = await _search_anysearch(svc, query, top_k)
|
"rank": rank + 1,
|
||||||
for rank, item in enumerate(web_hits):
|
"metadata": item.get("metadata", {}),
|
||||||
all_results.append({
|
})
|
||||||
"content": item.get("content", ""),
|
|
||||||
"score": item.get("score", 0.6),
|
# 4. 联网检索 (AnySearch, 可选)
|
||||||
"source": item.get("source", "web_anysearch"),
|
if use_web and db_session is not None:
|
||||||
"rank": rank + 1,
|
try:
|
||||||
"metadata": item.get("metadata", {}),
|
web_services = await _get_web_services(db_session)
|
||||||
})
|
for svc in web_services:
|
||||||
except Exception as e:
|
web_hits = await _search_anysearch(svc, query, top_k)
|
||||||
logger.warning(f"AnySearch web search failed: {e}")
|
for rank, item in enumerate(web_hits):
|
||||||
|
all_results.append({
|
||||||
if not all_results:
|
"content": item.get("content", ""),
|
||||||
return []
|
"score": item.get("score", 0.6),
|
||||||
|
"source": item.get("source", "web_anysearch"),
|
||||||
# RRF 融合
|
"rank": rank + 1,
|
||||||
rrf_k = 60
|
"metadata": item.get("metadata", {}),
|
||||||
score_map = {}
|
})
|
||||||
for item in all_results:
|
except Exception as e:
|
||||||
content_key = item["content"][:200] # 用内容前200字符作为去重键
|
logger.warning(f"AnySearch web search failed: {e}")
|
||||||
rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
|
|
||||||
|
if not all_results:
|
||||||
if content_key in score_map:
|
return []
|
||||||
score_map[content_key]["score"] += rrf_score
|
|
||||||
score_map[content_key]["sources"].append(item["source"])
|
# RRF 融合
|
||||||
else:
|
rrf_k = 60
|
||||||
score_map[content_key] = {
|
score_map = {}
|
||||||
"content": item["content"],
|
for item in all_results:
|
||||||
"score": rrf_score,
|
content_key = item["content"][:200] # 用内容前200字符作为去重键
|
||||||
"sources": [item["source"]],
|
rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
|
||||||
"metadata": item.get("metadata", {}),
|
|
||||||
}
|
if content_key in score_map:
|
||||||
|
score_map[content_key]["score"] += rrf_score
|
||||||
# 按融合分数排序
|
score_map[content_key]["sources"].append(item["source"])
|
||||||
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
else:
|
||||||
|
score_map[content_key] = {
|
||||||
# 标准化分数到 0-1
|
"content": item["content"],
|
||||||
if merged:
|
"score": rrf_score,
|
||||||
max_score = merged[0]["score"]
|
"sources": [item["source"]],
|
||||||
for item in merged:
|
"metadata": item.get("metadata", {}),
|
||||||
item["score"] = item["score"] / max_score if max_score > 0 else 0
|
}
|
||||||
item["sources"] = list(set(item["sources"]))
|
|
||||||
|
# 按融合分数排序
|
||||||
return merged[:top_k]
|
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
||||||
|
|
||||||
|
# 标准化分数到 0-1
|
||||||
|
if merged:
|
||||||
|
max_score = merged[0]["score"]
|
||||||
|
for item in merged:
|
||||||
|
item["score"] = item["score"] / max_score if max_score > 0 else 0
|
||||||
|
item["sources"] = list(set(item["sources"]))
|
||||||
|
|
||||||
|
return merged[:top_k]
|
||||||
@@ -959,6 +959,10 @@ export const adminAPI = {
|
|||||||
|
|
||||||
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
|
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
|
||||||
|
|
||||||
|
getAdminAnySearchConfig: () => get<any>('/admin/knowledge/anysearch/config'),
|
||||||
|
|
||||||
|
saveAdminAnySearchConfig: (data: { api_key: string; is_enabled: boolean }) => post<any>('/admin/knowledge/anysearch/config', data),
|
||||||
|
|
||||||
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
||||||
|
|
||||||
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ const Knowledge: React.FC = () => {
|
|||||||
const [ingesting, setIngesting] = useState(false);
|
const [ingesting, setIngesting] = useState(false);
|
||||||
const [ingestResult, setIngestResult] = useState<any>(null);
|
const [ingestResult, setIngestResult] = useState<any>(null);
|
||||||
|
|
||||||
|
// AnySearch global config
|
||||||
|
const [anysearchForm, setAnysearchForm] = useState<{ api_key: string; is_enabled: boolean }>({ api_key: '', is_enabled: false });
|
||||||
|
const [anysearchLoading, setAnysearchLoading] = useState(false);
|
||||||
|
const [anysearchMsg, setAnysearchMsg] = useState('');
|
||||||
|
|
||||||
// KB Modal
|
// KB Modal
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
|
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
|
||||||
@@ -123,6 +128,33 @@ const Knowledge: React.FC = () => {
|
|||||||
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
|
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAnySearchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getAdminAnySearchConfig();
|
||||||
|
if (res.code === 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
setAnysearchForm({ api_key: d.api_key || '', is_enabled: !!d.is_enabled });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAnySearch = async () => {
|
||||||
|
setAnysearchLoading(true); setAnysearchMsg('');
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.saveAdminAnySearchConfig(anysearchForm);
|
||||||
|
if (res.code === 0) {
|
||||||
|
const conn = res.data?.connection || {};
|
||||||
|
setAnysearchMsg(conn.connected ? '✅ 已保存并连接成功' : `⚠️ 已保存但连接失败:${conn.detail || '未知错误'}`);
|
||||||
|
} else {
|
||||||
|
setAnysearchMsg(`❌ 保存失败:${res.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setAnysearchMsg(`❌ 保存失败:${e?.message || '网络错误'}`);
|
||||||
|
} finally {
|
||||||
|
setAnysearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateKB = async () => {
|
const handleCreateKB = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await adminAPI.createAdminKnowledge(kbForm);
|
const res = await adminAPI.createAdminKnowledge(kbForm);
|
||||||
@@ -334,7 +366,7 @@ const Knowledge: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
|
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
|
||||||
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
|
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
|
||||||
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button>
|
<button onClick={() => { setShowExtModal(true); fetchAnySearchConfig(); }} style={btnStyle('#8B5CF6')}>外部服务配置</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
@@ -523,54 +555,62 @@ const Knowledge: React.FC = () => {
|
|||||||
{/* External Service Modal */}
|
{/* External Service Modal */}
|
||||||
{showExtModal && (
|
{showExtModal && (
|
||||||
<div style={overlayStyle} onClick={() => setShowExtModal(false)}>
|
<div style={overlayStyle} onClick={() => setShowExtModal(false)}>
|
||||||
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...modalStyle, width: 720, maxWidth: '92vw' }} onClick={e => e.stopPropagation()}>
|
||||||
<h3>外部知识库服务配置</h3>
|
<h3 style={{ margin: '0 0 20px 0', fontSize: 18 }}>外部知识库服务配置</h3>
|
||||||
{extServices.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
{/* Section 1: Dify 外部服务列表 */}
|
||||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<thead><tr><th>名称</th><th>类型</th><th>URL</th><th>状态</th><th>操作</th></tr></thead>
|
<h4 style={{ margin: '0 0 12px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>已接入外部服务</h4>
|
||||||
<tbody>
|
{extServices.length > 0 ? (
|
||||||
{extServices.map(svc => (
|
<div style={{ border: '1px solid #E5E7EB', borderRadius: 8, overflow: 'hidden' }}>
|
||||||
<tr key={svc.id}>
|
<table className="admin-table" style={{ marginBottom: 0, width: '100%' }}>
|
||||||
<td>{svc.name}</td>
|
<thead><tr><th style={{ textAlign: 'left' }}>名称</th><th style={{ textAlign: 'left' }}>类型</th><th style={{ textAlign: 'left' }}>URL</th><th style={{ textAlign: 'left' }}>状态</th><th style={{ textAlign: 'center', width: 120 }}>操作</th></tr></thead>
|
||||||
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>Dify</span></td>
|
<tbody>
|
||||||
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td>
|
{extServices.map(svc => (
|
||||||
<td>{statusBadge(svc.status)}</td>
|
<tr key={svc.id}>
|
||||||
<td>
|
<td>{svc.name}</td>
|
||||||
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
|
<td><span style={{ fontSize: 12, padding: '2px 8px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 4 }}>Dify</span></td>
|
||||||
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
<td style={{ fontSize: 12, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{svc.api_url}</td>
|
||||||
</td>
|
<td>{statusBadge(svc.status)}</td>
|
||||||
</tr>
|
<td style={{ textAlign: 'center' }}>
|
||||||
))}
|
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
|
||||||
</tbody>
|
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 6 }}>删除</button>
|
||||||
</table>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
)}
|
))}
|
||||||
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}>
|
</tbody>
|
||||||
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>配置 Dify 知识库</h4>
|
</table>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: 16, background: '#F9FAFB', borderRadius: 8, color: '#6B7280', fontSize: 13 }}>暂无外部服务,可在下方添加 Dify 知识库。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Dify 配置表单 */}
|
||||||
|
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 20, marginBottom: 24 }}>
|
||||||
|
<h4 style={{ margin: '0 0 16px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>配置 Dify 知识库</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 Dify" style={fullInput} /></div>
|
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 Dify" style={fullInput} /></div>
|
||||||
<div><label style={labelStyle}>知识库名称(Dataset)</label><input value={extForm.dataset_name} onChange={e => setExtForm({ ...extForm, dataset_name: e.target.value })} placeholder="如: qihuai" style={fullInput} /></div>
|
<div><label style={labelStyle}>知识库名称(Dataset)</label><input value={extForm.dataset_name} onChange={e => setExtForm({ ...extForm, dataset_name: e.target.value })} placeholder="如: qihuai" style={fullInput} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://dify.qihuyun.com/v1" style={fullInput} /></div>
|
<div style={{ marginTop: 16 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://dify.qihuyun.com/v1" style={fullInput} /></div>
|
||||||
<div style={{ marginTop: 12 }}><label style={labelStyle}>API Key(dataset 密钥)</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="dataset-xxxx" style={fullInput} /></div>
|
<div style={{ marginTop: 16 }}><label style={labelStyle}>API Key(dataset 密钥)</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="dataset-xxxx" style={fullInput} /></div>
|
||||||
|
|
||||||
{extMsg && (
|
{extMsg && (
|
||||||
<div style={{ marginTop: 12, fontSize: 13, color: extMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '6px 12px', background: extMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
|
<div style={{ marginTop: 16, fontSize: 13, color: extMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '8px 12px', background: extMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
|
||||||
{extMsg}
|
{extMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12, marginTop: 16, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center' }}>
|
||||||
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>保存并测试连接</button>
|
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>保存并测试连接</button>
|
||||||
<button onClick={handleIngestPlatform} disabled={ingesting} style={btnStyle(ingesting ? '#D1D5DB' : '#219B5E')}>
|
<button onClick={handleIngestPlatform} disabled={ingesting} style={btnStyle(ingesting ? '#D1D5DB' : '#219B5E')}>
|
||||||
{ingesting ? '入库中...' : '入库平台内容'}
|
{ingesting ? '入库中...' : '入库平台内容'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ingestResult && (
|
{ingestResult && (
|
||||||
<div style={{ marginTop: 12, fontSize: 13, padding: 12, background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: 8 }}>
|
<div style={{ marginTop: 16, fontSize: 13, padding: 12, background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: 8 }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>入库结果</div>
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>入库结果</div>
|
||||||
<div>AI 聊天回答:<b>{ingestResult.chat_answers}</b> 条</div>
|
<div>AI 聊天回答:<b>{ingestResult.chat_answers}</b> 条</div>
|
||||||
<div>用户上传文档:<b>{ingestResult.user_docs}</b> 条</div>
|
<div>用户上传文档:<b>{ingestResult.user_docs}</b> 条</div>
|
||||||
@@ -578,6 +618,45 @@ const Knowledge: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: AnySearch 全局固定配置 */}
|
||||||
|
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 20, marginBottom: 8 }}>
|
||||||
|
<h4 style={{ margin: '0 0 16px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>AnySearch 联网检索(全局配置)</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 16, alignItems: 'end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={anysearchForm.api_key}
|
||||||
|
onChange={e => setAnysearchForm({ ...anysearchForm, api_key: e.target.value })}
|
||||||
|
placeholder="as_sk_xxxx"
|
||||||
|
style={fullInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', fontWeight: 500, fontSize: 14, color: '#374151', whiteSpace: 'nowrap' }}>
|
||||||
|
<input type="checkbox" checked={anysearchForm.is_enabled} onChange={e => setAnysearchForm({ ...anysearchForm, is_enabled: e.target.checked })} />
|
||||||
|
启用联网检索
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anysearchMsg && (
|
||||||
|
<div style={{ marginTop: 16, fontSize: 13, color: anysearchMsg.startsWith('✅') ? '#16A34A' : (anysearchMsg.startsWith('⚠️') ? '#D97706' : '#DC2626'), padding: '8px 12px', background: anysearchMsg.startsWith('✅') ? '#DCFCE7' : (anysearchMsg.startsWith('⚠️') ? '#FEF3C7' : '#FEE2E2'), borderRadius: 6 }}>
|
||||||
|
{anysearchMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center' }}>
|
||||||
|
<button onClick={handleSaveAnySearch} disabled={anysearchLoading} style={btnStyle(anysearchLoading ? '#D1D5DB' : '#8B5CF6')}>
|
||||||
|
{anysearchLoading ? '保存中...' : '保存并测试连接'}
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 12, color: '#6B7280' }}>配置对平台所有用户生效,AI 聊天将自动注入实时联网结果。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ borderTop: '1px solid #E5E7EB', marginTop: 24, paddingTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user