refactor: 项目配置重构及 OCR 功能增强

- 更新 .gitignore,忽略本地文件。
- 调整 docker-compose.yml,优化服务配置及端口设置。
- api.Dockerfile 更换为基于 NVIDIA CUDA 的镜像,支持 GPU。
- pyproject.toml 新增依赖 langchain-tavily,并升级 mineru。
- tools_factory.py 重构,采用 langchain_tavily 的 TavilySearch。
- knowledgebase.py 增强 Milvus 连接异常的错误日志。
- _ocr.py 中 OCRPlugin 升级,使用 mineru 新解析方法,并调整健康检查 URL。
- 新增 mineru.py,实现文档解析逻辑。
- logging_config.py 优化日志配置,采用 loguru 管理日志。
- DataBaseInfoView.vue 新增分块参数配置及文件上传相关弹窗和功能。
This commit is contained in:
Wenjie Zhang 2025-06-18 19:07:29 +08:00
parent 8ac7b0a847
commit c6ab72cd5b
10 changed files with 585 additions and 333 deletions

2
.gitignore vendored
View File

@ -20,7 +20,6 @@ logs
*.log.*
*.db
*.lock
!uv.lock
tmp
cache
@ -29,6 +28,7 @@ cache
.idea
*.nogit*
*.private*
*.local*
*.secret*
*.pdf

View File

@ -3,6 +3,9 @@ services:
build:
context: .
dockerfile: docker/api.Dockerfile
# args:
# http_proxy: http://172.19.13.5:7890
# https_proxy: http://172.19.13.5:7890
image: yuxi-api:0.1.0
container_name: api-dev
working_dir: /app
@ -16,7 +19,7 @@ services:
reservations:
devices:
- driver: nvidia
device_ids: ['1']
device_ids: ['0'] # 使用GPU 1
capabilities: [gpu]
ports:
- "5050:5050"
@ -28,8 +31,8 @@ services:
- NEO4J_URI=bolt://graph:7687
- NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j}
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-0123456789}
- MILVUS_URI=http://milvus:19530
- MINERU_OCR_URI=http://mineru-api:5051
- MILVUS_URI=http://127.0.0.1:19530
- MINERU_OCR_URI=http://127.0.0.1:30000
- MODEL_DIR=/models
- RUNNING_IN_DOCKER=true
command: uv run uvicorn server.main:app --host 0.0.0.0 --port 5050 --reload
@ -143,61 +146,37 @@ services:
networks:
- app-network
restart: unless-stopped
mineru-api:
# lastest version: wget https://gcore.jsdelivr.net/gh/opendatalab/MinerU@master/docker/compose.yaml
mineru-sglang:
build:
context: scripts/mineru-api
context: scripts/mineru-sglang
dockerfile: Dockerfile
image: mineru-api:latest
container_name: mineru-api
profiles:
- all
image: mineru-sglang:latest
container_name: mineru-sglang
ports:
- 30000:30000
environment:
MINERU_MODEL_SOURCE: modelscope
entrypoint: mineru-sglang-server
command:
--host 0.0.0.0
--port 30000
ulimits:
memlock: -1
stack: 67108864
ipc: host
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:30000/health || exit 1"]
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['0']
device_ids: ["0"]
capabilities: [gpu]
ports:
- "5051:5051"
networks:
- app-network
restart: unless-stopped
command: python -m uvicorn app:app --host 0.0.0.0 --port 5051 --app-dir /app
# # lastest version: wget https://gcore.jsdelivr.net/gh/opendatalab/MinerU@master/docker/compose.yaml
# mineru-sglang:
# build:
# context: scripts/mineru-sglang
# dockerfile: Dockerfile
# image: mineru-sglang:latest
# container_name: mineru-sglang
# ports:
# - 30000:30000
# environment:
# MINERU_MODEL_SOURCE: modelscope
# entrypoint: mineru-sglang-server
# command:
# --host 0.0.0.0
# --port 30000
# ulimits:
# memlock: -1
# stack: 67108864
# ipc: host
# healthcheck:
# test: ["CMD-SHELL", "curl -f http://localhost:30000/health || exit 1"]
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# device_ids: ["0"]
# capabilities: [gpu]
# networks:
# - app-network
# restart: unless-stopped
networks:
app-network:

View File

@ -1,5 +1,5 @@
# 使用基础镜像
FROM python:3.12
FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04
COPY --from=ghcr.io/astral-sh/uv:0.7.2 /uv /uvx /bin/
# 设置工作目录

View File

@ -14,6 +14,7 @@ dependencies = [
"langchain-deepseek>=0.1.3",
"langchain-huggingface>=0.2.0",
"langchain-openai>=0.3.14",
"langchain-tavily>=0.2.3",
"langchain-together>=0.3.0",
"langgraph>=0.3.34",
"langgraph-checkpoint-sqlite>=2.0.7",
@ -21,6 +22,7 @@ dependencies = [
"langsmith>=0.3.37",
"llama-index>=0.12.33",
"llama-index-readers-file>=0.4.7",
"mineru[all]>=2.0.3",
"neo4j>=5.28.1",
"openai>=1.76.0",
"opencv-python-headless>=4.11.0.86",

View File

@ -3,7 +3,7 @@ import re
from collections.abc import Callable
from typing import Annotated, Any
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_tavily import TavilySearch
from langchain_core.tools import BaseTool, StructuredTool, tool
from pydantic import BaseModel, Field
@ -151,4 +151,4 @@ _TOOLS_REGISTRY = {
}
if config.enable_web_search:
_TOOLS_REGISTRY["WebSearchWithTavily"] = TavilySearchResults(max_results=10)
_TOOLS_REGISTRY["WebSearchWithTavily"] = TavilySearch(max_results=10)

View File

@ -668,11 +668,11 @@ class KnowledgeBase:
logger.info(f"Successfully connected to Milvus at {uri}")
return True
except MilvusException as e:
logger.error(f"Failed to connect to Milvus: {e},请检查 milvus 的容器是否正常运行。")
logger.error("如果已退出,请重新启动 `docker restart milvus-standalone-dev`。")
logger.error(f"Failed to connect to Milvus: {e},请检查 milvus 的容器是否正常运行。{traceback.format_exc()}")
logger.error("如果已退出,请重新启动 `docker restart milvus`。")
return False
except Exception as e: # Catch other potential errors like requests.exceptions.ConnectionError
logger.error(f"An unexpected error occurred while connecting to Milvus at {uri}: {e}")
logger.error(f"An unexpected error occurred while connecting to Milvus at {uri}: {e}, {traceback.format_exc()}")
return False

View File

@ -159,53 +159,26 @@ class OCRPlugin:
:param pdf_path: PDF文件路径
:return: 提取的文本
"""
mineru_ocr_uri = os.getenv("MINERU_OCR_URI", "http://localhost:5051")
mineru_ocr_uri = os.getenv("MINERU_OCR_URI", "http://localhost:30000")
mineru_ocr_uri_health = f"{mineru_ocr_uri}/health"
import requests
import json
from .mineru import parse_doc
health_check_response = requests.get(f"{mineru_ocr_uri}/health", timeout=5)
if health_check_response.status_code != 200 or health_check_response.json().get("status") != "healthy":
logger.error("Mineru OCR service health check failed.")
health_check_response = requests.get(mineru_ocr_uri_health, timeout=5)
if health_check_response.status_code != 200:
logger.error(f"Mineru OCR service health check failed with {mineru_ocr_uri_health}: {health_check_response.json()}")
raise RuntimeError("Mineru OCR service health check failed. Please check the log use `docker logs mineru-api`")
# 读取PDF文件
with open(pdf_path, 'rb') as f:
files = {'file': f}
data = {
'parse_method': 'ocr', # 使用OCR模式
'is_json_md_dump': False, # 不需要保存中间文件
'return_layout': False, # 不需要返回布局信息
'return_info': False, # 不需要返回额外信息
'return_content_list': False, # 不需要返回内容列表
'return_images': False, # 不需要返回图片
}
pdf_path_list = [pdf_path]
output_dir = os.path.join(os.getcwd(), "tmp", "mineru_ocr")
try:
# 发送POST请求到Mineru OCR服务
response = requests.post(
f"{mineru_ocr_uri}/file_parse",
files=files,
data=data
)
response.raise_for_status() # 检查响应状态
pdf_text = parse_doc(pdf_path_list, output_dir,
backend="vlm-sglang-client",
server_url=mineru_ocr_uri)[0]
# 解析响应
result = response.json()
if 'md_content' in result:
return result['md_content']
else:
logger.error("Mineru OCR response does not contain md_content")
return ""
except requests.exceptions.RequestException as e:
logger.error(f"Mineru OCR request failed: {str(e)}")
return ""
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Mineru OCR response: {str(e)}")
return ""
except Exception as e:
logger.error(f"Unexpected error in Mineru OCR processing: {str(e)}")
return ""
logger.debug(f"Mineru OCR result: {pdf_text[:50]}(...) total {len(pdf_text)} characters.")
return pdf_text
def get_state(task_id):
return GOLBAL_STATE.get(task_id, {})

247
src/plugins/mineru.py Normal file
View File

@ -0,0 +1,247 @@
# Copyright (c) Opendatalab. All rights reserved.
import copy
import json
import os
from pathlib import Path
from tqdm import tqdm
from mineru.cli.common import convert_pdf_bytes_to_bytes_by_pypdfium2, prepare_env, read_fn
from mineru.data.data_reader_writer import FileBasedDataWriter
from mineru.utils.draw_bbox import draw_layout_bbox, draw_span_bbox
from mineru.utils.enum_class import MakeMode
from mineru.backend.vlm.vlm_analyze import doc_analyze as vlm_doc_analyze
from mineru.backend.pipeline.pipeline_analyze import doc_analyze as pipeline_doc_analyze
from mineru.backend.pipeline.pipeline_middle_json_mkcontent import union_make as pipeline_union_make
from mineru.backend.pipeline.model_json_to_middle_json import result_to_middle_json as pipeline_result_to_middle_json
from mineru.backend.vlm.vlm_middle_json_mkcontent import union_make as vlm_union_make
from src.utils.logging_config import logger
def do_parse(
output_dir, # Output directory for storing parsing results
pdf_file_names: list[str], # List of PDF file names to be parsed
pdf_bytes_list: list[bytes], # List of PDF bytes to be parsed
p_lang_list: list[str], # List of languages for each PDF, default is 'ch' (Chinese)
backend="pipeline", # The backend for parsing PDF, default is 'pipeline'
parse_method="auto", # The method for parsing PDF, default is 'auto'
p_formula_enable=True, # Enable formula parsing
p_table_enable=True, # Enable table parsing
server_url=None, # Server URL for vlm-sglang-client backend
f_draw_layout_bbox=True, # Whether to draw layout bounding boxes
f_draw_span_bbox=True, # Whether to draw span bounding boxes
f_dump_md=True, # Whether to dump markdown files
f_dump_middle_json=True, # Whether to dump middle JSON files
f_dump_model_output=True, # Whether to dump model output files
f_dump_orig_pdf=True, # Whether to dump original PDF files
f_dump_content_list=True, # Whether to dump content list files
f_make_md_mode=MakeMode.MM_MD, # The mode for making markdown content, default is MM_MD
start_page_id=0, # Start page ID for parsing, default is 0
end_page_id=None, # End page ID for parsing, default is None (parse all pages until the end of the document)
) -> list[str]:
if backend == "pipeline":
for idx, pdf_bytes in enumerate(pdf_bytes_list):
new_pdf_bytes = convert_pdf_bytes_to_bytes_by_pypdfium2(pdf_bytes, start_page_id, end_page_id)
pdf_bytes_list[idx] = new_pdf_bytes
infer_results, all_image_lists, all_pdf_docs, lang_list, ocr_enabled_list = pipeline_doc_analyze(pdf_bytes_list, p_lang_list, parse_method=parse_method, formula_enable=p_formula_enable,table_enable=p_table_enable)
md_results = []
for idx, model_list in enumerate(infer_results):
model_json = copy.deepcopy(model_list)
pdf_file_name = pdf_file_names[idx]
local_image_dir, local_md_dir = prepare_env(output_dir, pdf_file_name, parse_method)
image_writer, md_writer = FileBasedDataWriter(local_image_dir), FileBasedDataWriter(local_md_dir)
images_list = all_image_lists[idx]
pdf_doc = all_pdf_docs[idx]
_lang = lang_list[idx]
_ocr_enable = ocr_enabled_list[idx]
middle_json = pipeline_result_to_middle_json(model_list, images_list, pdf_doc, image_writer, _lang, _ocr_enable, p_formula_enable)
pdf_info = middle_json["pdf_info"]
pdf_bytes = pdf_bytes_list[idx]
if f_draw_layout_bbox:
draw_layout_bbox(pdf_info, pdf_bytes, local_md_dir, f"{pdf_file_name}_layout.pdf")
if f_draw_span_bbox:
draw_span_bbox(pdf_info, pdf_bytes, local_md_dir, f"{pdf_file_name}_span.pdf")
if f_dump_orig_pdf:
md_writer.write(
f"{pdf_file_name}_origin.pdf",
pdf_bytes,
)
if f_dump_md:
image_dir = str(os.path.basename(local_image_dir))
md_content_str = pipeline_union_make(pdf_info, f_make_md_mode, image_dir)
md_writer.write_string(
f"{pdf_file_name}.md",
md_content_str,
)
md_results.append(md_content_str)
if f_dump_content_list:
image_dir = str(os.path.basename(local_image_dir))
content_list = pipeline_union_make(pdf_info, MakeMode.CONTENT_LIST, image_dir)
md_writer.write_string(
f"{pdf_file_name}_content_list.json",
json.dumps(content_list, ensure_ascii=False, indent=4),
)
if f_dump_middle_json:
md_writer.write_string(
f"{pdf_file_name}_middle.json",
json.dumps(middle_json, ensure_ascii=False, indent=4),
)
if f_dump_model_output:
md_writer.write_string(
f"{pdf_file_name}_model.json",
json.dumps(model_json, ensure_ascii=False, indent=4),
)
logger.info(f"local output dir is {local_md_dir}")
return md_results
else:
if backend.startswith("vlm-"):
backend = backend[4:]
f_draw_span_bbox = False
parse_method = "vlm"
md_results = []
for idx, pdf_bytes in enumerate(tqdm(pdf_bytes_list, desc="Parsing documents bytes")):
pdf_file_name = pdf_file_names[idx]
pdf_bytes = convert_pdf_bytes_to_bytes_by_pypdfium2(pdf_bytes, start_page_id, end_page_id)
local_image_dir, local_md_dir = prepare_env(output_dir, pdf_file_name, parse_method)
image_writer, md_writer = FileBasedDataWriter(local_image_dir), FileBasedDataWriter(local_md_dir)
middle_json, infer_result = vlm_doc_analyze(pdf_bytes, image_writer=image_writer, backend=backend, server_url=server_url)
pdf_info = middle_json["pdf_info"]
if f_draw_layout_bbox:
draw_layout_bbox(pdf_info, pdf_bytes, local_md_dir, f"{pdf_file_name}_layout.pdf")
if f_draw_span_bbox:
draw_span_bbox(pdf_info, pdf_bytes, local_md_dir, f"{pdf_file_name}_span.pdf")
if f_dump_orig_pdf:
md_writer.write(
f"{pdf_file_name}_origin.pdf",
pdf_bytes,
)
if f_dump_md:
image_dir = str(os.path.basename(local_image_dir))
md_content_str = vlm_union_make(pdf_info, f_make_md_mode, image_dir)
md_writer.write_string(
f"{pdf_file_name}.md",
md_content_str,
)
md_results.append(md_content_str)
if f_dump_content_list:
image_dir = str(os.path.basename(local_image_dir))
content_list = vlm_union_make(pdf_info, MakeMode.CONTENT_LIST, image_dir)
md_writer.write_string(
f"{pdf_file_name}_content_list.json",
json.dumps(content_list, ensure_ascii=False, indent=4),
)
if f_dump_middle_json:
md_writer.write_string(
f"{pdf_file_name}_middle.json",
json.dumps(middle_json, ensure_ascii=False, indent=4),
)
if f_dump_model_output:
model_output = ("\n" + "-" * 50 + "\n").join(infer_result)
md_writer.write_string(
f"{pdf_file_name}_model_output.txt",
model_output,
)
logger.info(f"local output dir is {local_md_dir}")
return md_results
def parse_doc(
path_list: list[Path],
output_dir,
lang="ch",
backend="pipeline",
method="auto",
server_url=None,
start_page_id=0, # Start page ID for parsing, default is 0
end_page_id=None # End page ID for parsing, default is None (parse all pages until the end of the document)
) -> list[str]:
"""
Parameter description:
path_list: List of document paths to be parsed, can be PDF or image files.
output_dir: Output directory for storing parsing results.
lang: Language option, default is 'ch', optional values include['ch', 'ch_server', 'ch_lite', 'en', 'korean', 'japan', 'chinese_cht', 'ta', 'te', 'ka']
Input the languages in the pdf (if known) to improve OCR accuracy. Optional.
Adapted only for the case where the backend is set to "pipeline"
backend: the backend for parsing pdf:
pipeline: More general.
vlm-transformers: More general.
vlm-sglang-engine: Faster(engine).
vlm-sglang-client: Faster(client).
without method specified, pipeline will be used by default.
method: the method for parsing pdf:
auto: Automatically determine the method based on the file type.
txt: Use text extraction method.
ocr: Use OCR method for image-based PDFs.
Without method specified, 'auto' will be used by default.
Adapted only for the case where the backend is set to "pipeline".
server_url: When the backend is `sglang-client`, you need to specify the server_url, for example:`http://127.0.0.1:30000`
"""
try:
file_name_list = []
pdf_bytes_list = []
lang_list = []
for path in tqdm(path_list, desc="Parsing documents"):
file_name = str(Path(path).stem)
pdf_bytes = read_fn(path)
file_name_list.append(file_name)
pdf_bytes_list.append(pdf_bytes)
lang_list.append(lang)
result = do_parse(
output_dir=output_dir,
pdf_file_names=file_name_list,
pdf_bytes_list=pdf_bytes_list,
p_lang_list=lang_list,
backend=backend,
parse_method=method,
server_url=server_url,
start_page_id=start_page_id,
end_page_id=end_page_id
)
return result if result else [""]
except Exception as e:
logger.exception(e)
return [""]
if __name__ == "__main__":
pdf_files_dir = "/home/zwj/workspace/projects/Yuxi-Know/test/struct_pdf"
output_dir = "/home/zwj/workspace/projects/Yuxi-Know/test/struct_pdf_output"
pdf_suffixes = [".pdf"]
image_suffixes = [".png", ".jpeg", ".jpg"]
doc_path_list = []
for doc_path in Path(pdf_files_dir).glob('*'):
if doc_path.suffix in pdf_suffixes + image_suffixes:
doc_path_list.append(doc_path)
parse_doc(doc_path_list, output_dir, backend="vlm-sglang-client", server_url="http://172.19.13.5:30000") # faster(client).

View File

@ -3,52 +3,43 @@ import os
from datetime import datetime
import pytz
from colorlog import ColoredFormatter
from loguru import logger
DATETIME = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d-%H%M%S')
# DATETIME = "debug" # 为了方便,调试的时候输出到 debug.log 文件
LOG_FILE = f'saves/log/project-{DATETIME}.log'
def setup_logger(name, level=logging.DEBUG, console=True):
def setup_logger(name, level="DEBUG", console=True):
"""使用 loguru 设置日志记录器"""
os.makedirs("saves/log", exist_ok=True)
"""Function to setup logger with the given name and log file."""
logger = logging.getLogger(name)
logger.setLevel(level)
# 清除已有的 Handler防止重复添加
if logger.hasHandlers():
logger.handlers.clear()
# 文件日志(无颜色)
file_handler = logging.FileHandler(LOG_FILE, encoding='utf-8')
file_handler.setLevel(level)
file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# 控制台日志(有颜色)
# 移除默认的 handler
logger.remove()
# 添加文件日志(无颜色)
logger.add(
LOG_FILE,
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} - {level} - {name} - {message}",
encoding="utf-8",
rotation="10 MB", # 文件大小达到 10MB 时轮转
retention="30 days", # 保留30天的日志
compression="zip" # 压缩旧日志文件
)
# 添加控制台日志(有颜色)
if console:
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
color_formatter = ColoredFormatter(
"%(log_color)s%(asctime)s - %(levelname)s - %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'bold_red',
}
logger.add(
lambda msg: print(msg, end=""),
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> - <level>{level}</level> - <cyan>{name}</cyan> - <level>{message}</level>",
colorize=True
)
console_handler.setFormatter(color_formatter)
logger.addHandler(console_handler)
return logger
# Setup the root logger
# 设置根日志记录器
logger = setup_logger('Yuxi')
# If you want to disable logging from external libraries

View File

@ -41,6 +41,113 @@
</a-form>
</a-modal>
<!-- 分块参数配置弹窗 -->
<a-modal v-model:open="chunkConfigModalVisible" title="分块参数配置" width="500px">
<template #footer>
<a-button key="back" @click="chunkConfigModalVisible = false">取消</a-button>
<a-button key="submit" type="primary" @click="handleChunkConfigSubmit">确定</a-button>
</template>
<div class="chunk-config-content">
<div class="params-info">
<p>调整分块参数可以控制文本的切分方式影响检索质量和文档加载效率</p>
</div>
<a-form
:model="tempChunkParams"
name="chunkConfig"
autocomplete="off"
layout="vertical"
>
<a-form-item label="Chunk Size" name="chunk_size">
<a-input-number v-model:value="tempChunkParams.chunk_size" :min="100" :max="10000" style="width: 100%;" />
<p class="param-description">每个文本片段的最大字符数</p>
</a-form-item>
<a-form-item label="Chunk Overlap" name="chunk_overlap">
<a-input-number v-model:value="tempChunkParams.chunk_overlap" :min="0" :max="1000" style="width: 100%;" />
<p class="param-description">相邻文本片段间的重叠字符数</p>
</a-form-item>
</a-form>
</div>
</a-modal>
<!-- 添加文件弹窗 -->
<a-modal v-model:open="addFilesModalVisible" title="添加文件" width="800px">
<template #footer>
<a-button key="back" @click="addFilesModalVisible = false">取消</a-button>
<a-button
key="submit"
type="primary"
@click="chunkData"
:loading="state.chunkLoading"
:disabled="(uploadMode === 'file' && fileList.length === 0) || (uploadMode === 'url' && !urlList.trim())"
>
生成分块
</a-button>
</template>
<div class="add-files-content">
<div class="upload-header">
<div class="source-selector">
<div class="upload-mode-selector" @click="uploadMode = 'file'" :class="{ active: uploadMode === 'file' }">
<FileOutlined /> 上传文件
</div>
<div class="upload-mode-selector" @click="uploadMode = 'url'" :class="{ active: uploadMode === 'url' }">
<LinkOutlined /> 输入网址
</div>
</div>
<div class="config-controls">
<a-button type="dashed" @click="showChunkConfigModal">
<SettingOutlined /> 分块参数 ({{ chunkParams.chunk_size }}/{{ chunkParams.chunk_overlap }})
</a-button>
</div>
</div>
<div class="ocr-config">
<a-form layout="horizontal">
<a-form-item label="使用OCR" name="enable_ocr">
<a-select v-model:value="chunkParams.enable_ocr" :options="enable_ocr_options" style="width: 200px;" />
<span class="param-description">启用OCR功能支持PDF文件的文本提取</span>
</a-form-item>
</a-form>
</div>
<!-- 文件上传区域 -->
<div class="upload" v-if="uploadMode === 'file'">
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"
name="file"
:multiple="true"
:disabled="state.chunkLoading"
:action="'/api/data/upload?db_id=' + databaseId"
:headers="getAuthHeaders()"
@change="handleFileUpload"
@drop="handleDrop"
>
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
<p class="ant-upload-hint">
目前仅支持上传文本文件 .pdf, .txt, .md且同名文件无法重复添加
</p>
</a-upload-dragger>
</div>
<!-- URL 输入区域 -->
<div class="url-input" v-else>
<a-form layout="vertical">
<a-form-item label="网页链接 (每行一个URL)">
<a-textarea
v-model:value="urlList"
placeholder="请输入网页链接,每行一个"
:rows="6"
:disabled="state.chunkLoading"
/>
</a-form-item>
</a-form>
<p class="url-hint">
支持添加网页内容系统会自动抓取网页文本并进行分块请确保URL格式正确且可以公开访问
</p>
</div>
</div>
</a-modal>
<div class="db-main-container">
<a-tabs v-model:activeKey="state.curPage" class="atab-container" type="card">
@ -49,7 +156,7 @@
<div class="db-tab-container">
<div class="actions" style="display: flex; gap: 10px; justify-content: space-between;">
<div class="left-actions" style="display: flex; gap: 10px;">
<a-button type="primary" @click="handleShowAddFilesBlock" :loading="state.refrashing" :icon="h(PlusOutlined)">添加文件</a-button>
<a-button type="primary" @click="showAddFilesModal" :loading="state.refrashing" :icon="h(PlusOutlined)">添加文件</a-button>
<a-button @click="handleRefresh" :loading="state.refrashing">刷新</a-button>
</div>
<div class="batch-actions" style="display: flex; gap: 10px;" v-if="selectedRowKeys.length > 0">
@ -73,93 +180,7 @@
</a-button>
</div>
</div>
<div class="upload-section" v-if="state.showAddFilesBlock">
<div class="upload-sidebar">
<div class="chunking-params">
<div class="params-info">
<p>调整分块参数可以控制文本的切分方式影响检索质量和文档加载效率</p>
</div>
<a-form
:model="chunkParams"
name="basic"
autocomplete="off"
layout="vertical"
>
<a-form-item label="Chunk Size" name="chunk_size">
<a-input-number v-model:value="chunkParams.chunk_size" :min="100" :max="10000" />
<p class="param-description">每个文本片段的最大字符数</p>
</a-form-item>
<a-form-item label="Chunk Overlap" name="chunk_overlap">
<a-input-number v-model:value="chunkParams.chunk_overlap" :min="0" :max="1000" />
<p class="param-description">相邻文本片段间的重叠字符数</p>
</a-form-item>
<a-form-item label="使用OCR" name="enable_ocr">
<a-select v-model:value="chunkParams.enable_ocr" :options="enable_ocr_options" />
<p class="param-description">启用OCR功能支持PDF文件的文本提取</p>
</a-form-item>
</a-form>
</div>
</div>
<div class="upload-main">
<div class="source-selector">
<div class="upload-mode-selector" @click="uploadMode = 'file'" :class="{ active: uploadMode === 'file' }">
<FileOutlined /> 上传文件
</div>
<div class="upload-mode-selector" @click="uploadMode = 'url'" :class="{ active: uploadMode === 'url' }">
<LinkOutlined /> 输入网址
</div>
</div>
<!-- 文件上传区域 -->
<div class="upload" v-if="uploadMode === 'file'">
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"
name="file"
:multiple="true"
:disabled="state.chunkLoading"
:action="'/api/data/upload?db_id=' + databaseId"
:headers="getAuthHeaders()"
@change="handleFileUpload"
@drop="handleDrop"
>
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
<p class="ant-upload-hint">
目前仅支持上传文本文件 .pdf, .txt, .md且同名文件无法重复添加
</p>
</a-upload-dragger>
</div>
<!-- URL 输入区域 -->
<div class="url-input" v-else>
<a-form layout="vertical">
<a-form-item label="网页链接 (每行一个URL)">
<a-textarea
v-model:value="urlList"
placeholder="请输入网页链接,每行一个"
:rows="6"
:disabled="state.chunkLoading"
/>
</a-form-item>
</a-form>
<p class="url-hint">
支持添加网页内容系统会自动抓取网页文本并进行分块请确保URL格式正确且可以公开访问
</p>
</div>
<div class="actions">
<a-button
type="primary"
@click="chunkData"
:loading="state.chunkLoading"
:disabled="(uploadMode === 'file' && fileList.length === 0) || (uploadMode === 'url' && !urlList.trim())"
style="margin: 0px 20px 20px 0;"
>
生成分块
</a-button>
</div>
</div>
</div>
<a-table
:columns="columns"
:data-source="Object.values(database.files || {})"
@ -374,6 +395,7 @@ import {
EditOutlined,
PlusOutlined,
HddOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { h } from 'vue';
@ -403,7 +425,6 @@ const state = reactive({
refreshInterval: null,
curPage: "files",
indexingFile: null,
showAddFilesBlock: false,
batchIndexing: false,
batchDeleting: false,
chunkLoading: false,
@ -546,9 +567,7 @@ const handleRefresh = () => {
})
}
const handleShowAddFilesBlock = () => {
state.showAddFilesBlock = !state.showAddFilesBlock
}
const deleteDatabse = () => {
Modal.confirm({
@ -794,8 +813,7 @@ const chunkFiles = () => {
if (data.status === 'success') {
message.success(data.message || '文件已提交处理,请稍后在列表刷新查看状态');
fileList.value = []; //
// chunkResults.value = []; //
// activeFileKeys.value = []; //
addFilesModalVisible.value = false; //
getDatabaseInfo(); //
} else {
message.error(data.message || '文件处理失败');
@ -833,8 +851,7 @@ const chunkUrls = () => {
if (data.status === 'success') {
message.success(data.message || 'URL已提交处理请稍后在列表刷新查看状态');
urlList.value = ''; // URL
// chunkResults.value = []; //
// activeFileKeys.value = []; //
addFilesModalVisible.value = false; //
getDatabaseInfo(); //
} else {
message.error(data.message || 'URL处理失败');
@ -928,6 +945,16 @@ const rules = {
name: [{ required: true, message: '请输入知识库名称' }]
};
//
const chunkConfigModalVisible = ref(false);
const tempChunkParams = ref({
chunk_size: 1000,
chunk_overlap: 200,
});
//
const addFilesModalVisible = ref(false);
//
const showEditModal = () => {
editForm.name = database.value.name || '';
@ -964,6 +991,28 @@ const updateDatabaseInfo = async () => {
}
};
//
const showChunkConfigModal = () => {
tempChunkParams.value = {
chunk_size: chunkParams.value.chunk_size,
chunk_overlap: chunkParams.value.chunk_overlap,
};
chunkConfigModalVisible.value = true;
};
//
const handleChunkConfigSubmit = () => {
chunkParams.value.chunk_size = tempChunkParams.value.chunk_size;
chunkParams.value.chunk_overlap = tempChunkParams.value.chunk_overlap;
chunkConfigModalVisible.value = false;
message.success('分块参数配置已更新');
};
//
const showAddFilesModal = () => {
addFilesModalVisible.value = true;
};
const handleIndexFile = (fileId) => {
if (!fileId) {
message.error('无效的文件ID');
@ -1319,12 +1368,7 @@ const handleBatchIndex = async () => {
}
}
.upload {
margin-bottom: 20px;
.upload-dragger {
margin: 0px;
}
}
.my-table {
button.ant-btn-link {
@ -1400,98 +1444,21 @@ const handleBatchIndex = async () => {
}
}
.upload-section {
display: flex;
gap: 20px;
.upload-sidebar {
min-width: 280px;
height: fit-content;
padding: 20px;
background-color: var(--main-light-6);
border-radius: 8px;
border: 1px solid var(--main-light-3);
flex: 0 0 280px;
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.chunking-params {
h4 {
margin-top: 0;
margin-bottom: 16px;
color: var(--main-color);
font-size: 18px;
text-align: center;
font-weight: bold;
padding-bottom: 10px;
border-bottom: 1px dashed var(--main-light-3);
}
.params-info {
background-color: var(--main-light-4);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 16px;
p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--gray-700);
}
}
.ant-form-item {
margin-bottom: 16px;
.ant-form-item-label {
padding-bottom: 6px;
label {
color: var(--gray-800);
font-weight: 500;
font-size: 15px;
}
}
}
.ant-input-number {
width: 100%;
border-radius: 6px;
&:hover, &:focus {
border-color: var(--main-color);
}
}
.ant-switch {
background-color: var(--gray-400);
&.ant-switch-checked {
background-color: var(--main-color);
}
}
//
.param-description {
color: var(--gray-600);
font-size: 12px;
margin-top: 4px;
margin-bottom: 0;
}
}
}
.upload-main {
flex: 1;
.add-files-content {
.upload-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 20px;
.source-selector {
display: flex;
gap: 10px;
margin-bottom: 16px;
.upload-mode-selector {
cursor: pointer;
padding: 8px 16px;
padding: 4px 16px;
border-radius: 8px;
background-color: var(--main-light-4);
border: 1px solid var(--main-light-3);
@ -1503,6 +1470,116 @@ const handleBatchIndex = async () => {
}
}
}
.config-controls {
.ant-btn {
border-color: var(--main-light-3);
color: var(--gray-700);
&:hover {
border-color: var(--main-color);
color: var(--main-color);
}
}
}
}
.ocr-config {
margin-bottom: 16px;
padding: 12px 16px;
background-color: var(--main-light-6);
border-radius: 8px;
border: 1px solid var(--main-light-3);
.ant-form-item {
margin-bottom: 0;
.ant-form-item-label {
color: var(--gray-800);
font-weight: 500;
}
}
.param-description {
color: var(--gray-600);
font-size: 12px;
margin-left: 12px;
}
}
.upload {
margin-bottom: 20px;
.upload-dragger {
margin: 0px;
min-height: 200px;
}
}
.url-input {
margin-bottom: 20px;
.ant-textarea {
border-color: var(--main-light-3);
background-color: #fff;
font-family: monospace;
resize: vertical;
}
.ant-textarea:hover,
.ant-textarea:focus {
border-color: var(--main-color);
}
.url-hint {
font-size: 13px;
color: var(--gray-600);
margin-top: 5px;
line-height: 1.5;
}
}
}
.chunk-config-content {
.params-info {
background-color: var(--main-light-4);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 16px;
p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--gray-700);
}
}
.ant-form-item {
margin-bottom: 16px;
.ant-form-item-label {
padding-bottom: 6px;
label {
color: var(--gray-800);
font-weight: 500;
font-size: 15px;
}
}
}
.ant-input-number {
border-radius: 6px;
&:hover, &:focus {
border-color: var(--main-color);
}
}
.param-description {
color: var(--gray-600);
font-size: 12px;
margin-top: 4px;
margin-bottom: 0;
}
}
@ -1560,24 +1637,7 @@ const handleBatchIndex = async () => {
margin-bottom: 20px;
}
.url-input .ant-textarea {
border-color: var(--main-light-3);
background-color: #fff;
font-family: monospace;
resize: vertical;
}
.url-input .ant-textarea:hover,
.url-input .ant-textarea:focus {
border-color: var(--main-color);
}
.url-hint {
font-size: 13px;
color: var(--gray-600);
margin-top: 5px;
line-height: 1.5;
}
.loading-container {
display: flex;