feat: 完成全量功能开发,包括前端管理后台与后端服务优化

此提交实现了完整的知识库管理系统:
1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面
2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换
3. 调整默认嵌入模型配置为本地bge-m3模式
4. 优化入库任务去重逻辑与缓存清理机制
5. 完善Docker镜像构建与docker-compose部署配置
6. 修复多项测试用例与兼容性问题
7. 新增运行时配置API,支持动态调整系统参数
This commit is contained in:
2026-07-31 12:05:25 +08:00
parent fdb664e546
commit 2ab8b56a01
52 changed files with 7030 additions and 171 deletions
+179
View File
@@ -0,0 +1,179 @@
"""LLM 客户端抽象层
提供统一的 `generate(prompt, json_mode)` 接口,底层实现可切换:
- OllamaLLMClient:走 Ollama HTTP /api/generate(本地或远程 Ollama 服务)
- OpenAICompatibleLLMClient:走 OpenAI 兼容 /v1/chat/completionsOpenAI / DeepSeek / 智谱 / Qwen API 等)
工厂 `create_llm_client(purpose)` 根据 runtime_settings 选择实现与参数。
所有客户端共享同一接口,业务侧无需感知底层协议差异。
"""
from __future__ import annotations
from typing import Literal, Protocol, runtime_checkable
import httpx
import structlog
from app.config import settings
from app.core.runtime_settings import LlmProviderConfig, get_runtime_settings
from app.services.ollama import OllamaClient
logger = structlog.get_logger()
LlmPurpose = Literal["summarize", "query", "classify"]
@runtime_checkable
class LLMClient(Protocol):
"""LLM 客户端统一接口"""
async def generate(self, prompt: str, json_mode: bool = False) -> str:
"""生成文本
Args:
prompt: 输入提示词
json_mode: True 时约束输出为 JSON(不支持时降级为普通生成)
Returns:
生成的文本内容
"""
...
async def is_available(self) -> bool:
"""检查服务是否可用"""
...
class OllamaLLMClient:
"""Ollama HTTP API 客户端(包装现有 OllamaClient,便于统一接口)"""
def __init__(self, base_url: str, model: str, timeout: float = 120.0) -> None:
self._inner = OllamaClient(base_url=base_url, model=model, timeout=timeout)
async def generate(self, prompt: str, json_mode: bool = False) -> str:
return await self._inner.generate(prompt, json_mode=json_mode)
async def is_available(self) -> bool:
return await self._inner.is_available()
class OpenAICompatibleLLMClient:
"""OpenAI 兼容 chat/completions 客户端
适用于 OpenAI 官方 API、DeepSeek、智谱 ChatGLM、Qwen DashScope、Moonshot 等
所有兼容 OpenAI /v1/chat/completions 协议的服务。
"""
def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 120.0, temperature: float = 0.3) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.timeout = timeout
self.temperature = temperature
async def generate(self, prompt: str, json_mode: bool = False) -> str:
url = f"{self.base_url}/chat/completions"
payload: dict = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"temperature": self.temperature,
"stream": False,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
# OpenAI 标准响应结构:choices[0].message.content
choices = data.get("choices") or []
if not choices:
logger.warning("OpenAI 兼容响应无 choices", model=self.model, raw_keys=list(data.keys()))
return ""
message = choices[0].get("message") or {}
content = message.get("content") or ""
logger.debug("OpenAI 兼容生成完成", model=self.model, output_length=len(content))
return content
async def is_available(self) -> bool:
"""简单探测:调 /models 列表接口(多数 OpenAI 兼容服务支持)"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(
f"{self.base_url}/models",
headers={"Authorization": f"Bearer {self.api_key}"},
)
return resp.status_code == 200
except httpx.HTTPError:
return False
# ---------------------------------------------------------------------------- #
# 工厂
# ---------------------------------------------------------------------------- #
# 进程级客户端缓存(避免每请求新建 httpx client
_client_cache: dict[str, LLMClient] = {}
def _resolve_provider_config(purpose: LlmPurpose) -> LlmProviderConfig:
"""从 runtime_settings 取指定用途的 LLM 配置"""
rt = get_runtime_settings()
return getattr(rt.models, purpose)
def _build_client(purpose: LlmPurpose) -> LLMClient:
"""按 runtime_settings 构造 LLM 客户端"""
cfg = _resolve_provider_config(purpose)
if cfg.provider == "ollama":
base_url = cfg.base_url or settings.ollama_base_url
model = cfg.model or settings.ollama_model
return OllamaLLMClient(base_url=base_url, model=model, timeout=cfg.timeout)
if cfg.provider == "openai_compatible":
base_url = cfg.base_url or settings.openai_base_url
api_key = cfg.api_key or settings.openai_api_key
# openai_compatible 默认模型:若 cfg.model 为空,退化到 OpenAI 通用 chat 模型
model = cfg.model or "gpt-4o-mini"
if not api_key:
logger.warning("OpenAI 兼容 provider 缺少 api_key,调用大概率会失败", purpose=purpose)
return OpenAICompatibleLLMClient(
base_url=base_url, api_key=api_key, model=model, timeout=cfg.timeout, temperature=cfg.temperature
)
raise ValueError(f"未知 LLM provider: {cfg.provider}")
def create_llm_client(purpose: LlmPurpose, *, use_cache: bool = True) -> LLMClient:
"""创建指定用途的 LLM 客户端
Args:
purpose: 用途(summarize / query / classify
use_cache: True 时复用进程级客户端单例(默认);False 每次新建(测试用)
Returns:
LLMClient 实例
"""
if not use_cache:
return _build_client(purpose)
cache_key = f"{purpose}"
if cache_key not in _client_cache:
_client_cache[cache_key] = _build_client(purpose)
return _client_cache[cache_key]
def invalidate_llm_client_cache(purpose: LlmPurpose | None = None) -> None:
"""清除客户端缓存(runtime_settings 更新后调用,确保后续读取新配置)"""
if purpose is None:
_client_cache.clear()
else:
_client_cache.pop(purpose, None)