feat: v1.2.0 - 图片去重与管理、微信机器人优化、搜索设置可配置

主要功能:
- 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate)
- 图片管理 Tab:展示所有图片、手动删除、一键去重
- 搜索结果详情弹窗增加删除按钮(带确认弹窗)
- 图片管理卡片点击查看详情(复用 showOcrDetailModal)
- 搜索限制和 LLM 批量判断数量可通过网站设置
- MiniMax API 调用添加 reasoning_split=True
- 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量
- 版本号升级至 1.2.0
This commit is contained in:
EduBrain Dev
2026-04-13 22:25:08 +08:00
commit b17786b57b
56 changed files with 9300 additions and 0 deletions

223
app/api/v1/settings.py Normal file
View File

@@ -0,0 +1,223 @@
"""
系统设置 API 路由
提供运行时配置的读取和修改接口
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.config import settings
router = APIRouter()
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
@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),
)
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
@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_caseSettings 用 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",
}
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)}"}