""" 系统设置 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 ocr_concurrency: 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, ocr_concurrency=settings.OCR_CONCURRENCY, 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 ocr_concurrency: 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", "ocr_concurrency": "OCR_CONCURRENCY", "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()