- BotManager: 后端启动时自动检测配置并拉起机器人(后台线程) - WeComBotService: 新增 start_async() 异步模式,支持嵌入事件循环 - settings API: 增加 bot 配置字段(enabled/bot_id/secret) - settings API: 增加 bot/status 和 bot/restart 端点 - 前端设置页: 增加企业微信机器人配置卡片(启用开关/ID/Secret) - 前端设置页: 增加机器人状态徽章和重启按钮 - Secret 脱敏显示,仅展示前4位
262 lines
9.7 KiB
Python
262 lines
9.7 KiB
Python
"""
|
||
系统设置 API 路由
|
||
提供运行时配置的读取和修改接口
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from app.config import settings
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _mask_secret(value: str | None, visible: int = 4) -> str | None:
|
||
"""对敏感字符串脱敏,保留前 visible 个字符"""
|
||
if not value:
|
||
return None
|
||
if len(value) <= visible:
|
||
return "****"
|
||
return value[:visible] + "****"
|
||
|
||
|
||
class SettingsResponse(BaseModel):
|
||
"""设置响应(隐藏敏感信息的中间位)"""
|
||
embedding_provider: str
|
||
embedding_model: str
|
||
embedding_dimensions: int
|
||
ocr_provider: str
|
||
llm_provider: str
|
||
minimax_base_url: str
|
||
minimax_embedding_model: str
|
||
minimax_chat_model: str
|
||
deepseek_base_url: str
|
||
deepseek_ocr_model: str
|
||
openai_base_url: str | None
|
||
chunk_size: int
|
||
chunk_overlap: int
|
||
search_limit: int
|
||
judge_batch_size: int
|
||
# API Key 只返回是否已配置(不返回实际值)
|
||
has_minimax_key: bool
|
||
has_deepseek_key: bool
|
||
has_openai_key: bool
|
||
has_zhipu_key: bool
|
||
has_dashscope_key: bool
|
||
has_aliyun_ocr: bool
|
||
has_tencent_ocr: bool
|
||
# 企业微信机器人
|
||
wework_bot_enabled: bool
|
||
wework_bot_id: str | None
|
||
wework_bot_secret_masked: str | None
|
||
|
||
|
||
@router.get("", response_model=SettingsResponse, summary="获取当前设置")
|
||
async def get_settings():
|
||
"""获取当前系统设置(API Key 脱敏)"""
|
||
return SettingsResponse(
|
||
embedding_provider=settings.EMBEDDING_PROVIDER.value,
|
||
embedding_model=settings.EMBEDDING_MODEL,
|
||
embedding_dimensions=settings.EMBEDDING_DIMENSIONS,
|
||
ocr_provider=settings.OCR_PROVIDER.value,
|
||
llm_provider=settings.LLM_PROVIDER,
|
||
minimax_base_url=settings.MINIMAX_BASE_URL,
|
||
minimax_embedding_model=settings.MINIMAX_EMBEDDING_MODEL,
|
||
minimax_chat_model=settings.MINIMAX_CHAT_MODEL,
|
||
deepseek_base_url=settings.DEEPSEEK_BASE_URL,
|
||
deepseek_ocr_model=settings.DEEPSEEK_OCR_MODEL,
|
||
openai_base_url=settings.OPENAI_BASE_URL,
|
||
chunk_size=settings.CHUNK_SIZE,
|
||
chunk_overlap=settings.CHUNK_OVERLAP,
|
||
search_limit=settings.SEARCH_LIMIT,
|
||
judge_batch_size=settings.JUDGE_BATCH_SIZE,
|
||
has_minimax_key=bool(settings.MINIMAX_API_KEY),
|
||
has_deepseek_key=bool(settings.DEEPSEEK_API_KEY),
|
||
has_openai_key=bool(settings.OPENAI_API_KEY),
|
||
has_zhipu_key=bool(settings.ZHIPU_API_KEY),
|
||
has_dashscope_key=bool(settings.DASHSCOPE_API_KEY),
|
||
has_aliyun_ocr=bool(settings.ALIYUN_OCR_ACCESS_KEY),
|
||
has_tencent_ocr=bool(settings.TENCENT_OCR_SECRET_ID),
|
||
wework_bot_enabled=settings.WEWORK_BOT_ENABLED,
|
||
wework_bot_id=settings.WEWORK_BOT_ID,
|
||
wework_bot_secret_masked=_mask_secret(settings.WEWORK_BOT_SECRET),
|
||
)
|
||
|
||
|
||
class SettingsUpdate(BaseModel):
|
||
"""设置更新请求(所有字段可选)"""
|
||
embedding_provider: str | None = None
|
||
embedding_model: str | None = None
|
||
embedding_dimensions: int | None = None
|
||
ocr_provider: str | None = None
|
||
llm_provider: str | None = None
|
||
minimax_api_key: str | None = None
|
||
minimax_base_url: str | None = None
|
||
minimax_embedding_model: str | None = None
|
||
minimax_chat_model: str | None = None
|
||
deepseek_api_key: str | None = None
|
||
deepseek_base_url: str | None = None
|
||
deepseek_ocr_model: str | None = None
|
||
openai_api_key: str | None = None
|
||
openai_base_url: str | None = None
|
||
zhipu_api_key: str | None = None
|
||
dashscope_api_key: str | None = None
|
||
aliyun_ocr_access_key: str | None = None
|
||
aliyun_ocr_secret: str | None = None
|
||
tencent_ocr_secret_id: str | None = None
|
||
tencent_ocr_secret_key: str | None = None
|
||
chunk_size: int | None = None
|
||
chunk_overlap: int | None = None
|
||
search_limit: int | None = None
|
||
judge_batch_size: int | None = None
|
||
# 企业微信机器人
|
||
wework_bot_enabled: bool | None = None
|
||
wework_bot_id: str | None = None
|
||
wework_bot_secret: str | None = None
|
||
|
||
|
||
@router.put("", summary="更新设置")
|
||
async def update_settings(data: SettingsUpdate):
|
||
"""
|
||
更新系统设置(运行时生效)。
|
||
修改嵌入模型或 OCR 提供商后,对应的单例服务会自动重置。
|
||
"""
|
||
from app.config import EmbeddingProvider, OCRProvider
|
||
from app.services.embedding_service import EmbeddingService
|
||
from app.services.ocr_service import OCRService
|
||
from app.services.llm_service import LLMService
|
||
|
||
update_data = data.model_dump(exclude_none=True)
|
||
if not update_data:
|
||
raise HTTPException(status_code=400, detail="没有需要更新的字段")
|
||
|
||
provider_changed = False
|
||
|
||
# 映射字段名(API 用 snake_case,Settings 用 UPPER_CASE)
|
||
field_map = {
|
||
"embedding_provider": "EMBEDDING_PROVIDER",
|
||
"embedding_model": "EMBEDDING_MODEL",
|
||
"embedding_dimensions": "EMBEDDING_DIMENSIONS",
|
||
"ocr_provider": "OCR_PROVIDER",
|
||
"llm_provider": "LLM_PROVIDER",
|
||
"minimax_api_key": "MINIMAX_API_KEY",
|
||
"minimax_base_url": "MINIMAX_BASE_URL",
|
||
"minimax_embedding_model": "MINIMAX_EMBEDDING_MODEL",
|
||
"minimax_chat_model": "MINIMAX_CHAT_MODEL",
|
||
"deepseek_api_key": "DEEPSEEK_API_KEY",
|
||
"deepseek_base_url": "DEEPSEEK_BASE_URL",
|
||
"deepseek_ocr_model": "DEEPSEEK_OCR_MODEL",
|
||
"openai_api_key": "OPENAI_API_KEY",
|
||
"openai_base_url": "OPENAI_BASE_URL",
|
||
"zhipu_api_key": "ZHIPU_API_KEY",
|
||
"dashscope_api_key": "DASHSCOPE_API_KEY",
|
||
"aliyun_ocr_access_key": "ALIYUN_OCR_ACCESS_KEY",
|
||
"aliyun_ocr_secret": "ALIYUN_OCR_SECRET",
|
||
"tencent_ocr_secret_id": "TENCENT_OCR_SECRET_ID",
|
||
"tencent_ocr_secret_key": "TENCENT_OCR_SECRET_KEY",
|
||
"chunk_size": "CHUNK_SIZE",
|
||
"chunk_overlap": "CHUNK_OVERLAP",
|
||
"search_limit": "SEARCH_LIMIT",
|
||
"judge_batch_size": "JUDGE_BATCH_SIZE",
|
||
"wework_bot_enabled": "WEWORK_BOT_ENABLED",
|
||
"wework_bot_id": "WEWORK_BOT_ID",
|
||
"wework_bot_secret": "WEWORK_BOT_SECRET",
|
||
}
|
||
|
||
for api_field, config_field in field_map.items():
|
||
if api_field in update_data:
|
||
value = update_data[api_field]
|
||
# 枚举类型需要转换
|
||
if config_field == "EMBEDDING_PROVIDER":
|
||
value = EmbeddingProvider(value)
|
||
provider_changed = True
|
||
elif config_field == "OCR_PROVIDER":
|
||
value = OCRProvider(value)
|
||
provider_changed = True
|
||
setattr(settings, config_field, value)
|
||
|
||
# 如果提供商变了,重置单例
|
||
if provider_changed:
|
||
EmbeddingService.reset()
|
||
OCRService.reset()
|
||
LLMService.reset()
|
||
|
||
return {"message": "设置已更新", "provider_changed": provider_changed}
|
||
|
||
|
||
@router.get("/stats", summary="获取知识库统计")
|
||
async def get_stats():
|
||
"""获取知识库统计信息"""
|
||
from sqlalchemy import text
|
||
from app.database import async_session_factory
|
||
|
||
async with async_session_factory() as session:
|
||
result = await session.execute(text("""
|
||
SELECT
|
||
(SELECT COUNT(*) FROM knowledge_pages) AS page_count,
|
||
(SELECT COUNT(*) FROM knowledge_chunks) AS chunk_count,
|
||
(SELECT COUNT(*) FROM knowledge_chunks WHERE embedding IS NOT NULL) AS embedded_count,
|
||
(SELECT COUNT(*) FROM ocr_images) AS image_count,
|
||
(SELECT COUNT(*) FROM ocr_images WHERE status = 'completed') AS ocr_completed_count
|
||
"""))
|
||
row = result.fetchone()
|
||
|
||
return {
|
||
"page_count": row.page_count,
|
||
"chunk_count": row.chunk_count,
|
||
"embedded_count": row.embedded_count,
|
||
"image_count": row.image_count,
|
||
"ocr_completed_count": row.ocr_completed_count,
|
||
}
|
||
|
||
|
||
@router.post("/test/embedding", summary="测试嵌入模型连接")
|
||
async def test_embedding():
|
||
"""测试当前嵌入模型是否可用"""
|
||
try:
|
||
from app.services.embedding_service import EmbeddingService
|
||
result = await EmbeddingService.embed_single("测试")
|
||
return {"success": True, "dimension": len(result), "message": f"嵌入模型正常,维度: {len(result)}"}
|
||
except Exception as e:
|
||
return {"success": False, "message": f"嵌入模型测试失败: {str(e)}"}
|
||
|
||
|
||
@router.post("/test/llm", summary="测试 LLM 连接")
|
||
async def test_llm():
|
||
"""测试当前 LLM 是否可用"""
|
||
try:
|
||
from app.services.llm_service import LLMService
|
||
result = await LLMService.chat([
|
||
{"role": "user", "content": "请回复\"连接正常\""}
|
||
], max_tokens=20)
|
||
return {"success": True, "message": f"LLM 正常,回复: {result[:100]}"}
|
||
except Exception as e:
|
||
return {"success": False, "message": f"LLM 测试失败: {str(e)}"}
|
||
|
||
|
||
@router.post("/test/ocr", summary="测试 OCR(使用示例文本)")
|
||
async def test_ocr():
|
||
"""测试当前 OCR 提供商是否可用(不实际上传图片,只检查配置)"""
|
||
try:
|
||
from app.services.ocr_service import OCRService
|
||
provider = OCRService.get_instance()
|
||
return {"success": True, "message": f"OCR 提供商 {provider.name} 已就绪"}
|
||
except Exception as e:
|
||
return {"success": False, "message": f"OCR 测试失败: {str(e)}"}
|
||
|
||
|
||
@router.post("/bot/restart", summary="重启企业微信机器人")
|
||
async def restart_bot():
|
||
"""重启企业微信机器人服务"""
|
||
from app.main import bot_manager
|
||
result = await bot_manager.restart()
|
||
return result
|
||
|
||
|
||
@router.get("/bot/status", summary="获取企业微信机器人状态")
|
||
async def get_bot_status():
|
||
"""获取企业微信机器人运行状态"""
|
||
from app.main import bot_manager
|
||
return bot_manager.get_status()
|