feat: 上传文件新增基于URL获取和处理功能,需要配置白名单环境变量
- 引入新的端点来获取URL内容并转换为Markdown格式。 - 在环境变量中添加URL白名单配置以确保安全。 - 增强文件上传模态框以支持URL输入和URL批量处理。 - 更新文件处理逻辑以区分文件和URL。 - 改进UI组件以适应新的URL处理功能。 - 添加用于URL验证和获取的工具函数。
This commit is contained in:
parent
537b798f9a
commit
a4f425dbf9
@ -25,5 +25,8 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co
|
|||||||
# YUXI_SUPER_ADMIN_NAME=
|
# YUXI_SUPER_ADMIN_NAME=
|
||||||
# YUXI_SUPER_ADMIN_PASSWORD=
|
# YUXI_SUPER_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# # URL Whitelist (comma-separated domains/IPs, empty to disable URL parsing)
|
||||||
|
# YUXI_URL_WHITELIST=github.com,docs.example.com,gitlab.example.com,127.0.0.1
|
||||||
|
|
||||||
# # MinerU
|
# # MinerU
|
||||||
# MINERU_API_KEY=
|
# MINERU_API_KEY=
|
||||||
@ -17,6 +17,10 @@
|
|||||||
- **电子表格**: `.csv`, `.xls`, `.xlsx`
|
- **电子表格**: `.csv`, `.xls`, `.xlsx`
|
||||||
- **JSON 数据**: `.json`
|
- **JSON 数据**: `.json`
|
||||||
|
|
||||||
|
::: tip 图片显示
|
||||||
|
文档中的图片会自动上传到对象存储并替换为可访问的 URL。但是如果想要在外部正常显示图片,需要配置 `HOST_IP` 环境变量,将其设置为您的服务器 IP 地址。
|
||||||
|
:::
|
||||||
|
|
||||||
### 图像格式(需要 OCR)
|
### 图像格式(需要 OCR)
|
||||||
- **常见图片**: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.tif`, `.gif`, `.webp`
|
- **常见图片**: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.tif`, `.gif`, `.webp`
|
||||||
|
|
||||||
@ -28,10 +32,20 @@
|
|||||||
- 优先处理名为 `full.md` 的文件,否则使用第一个 `.md` 文件
|
- 优先处理名为 `full.md` 的文件,否则使用第一个 `.md` 文件
|
||||||
- 支持图片目录的智能识别(`images/`、`../images/` 等)
|
- 支持图片目录的智能识别(`images/`、`../images/` 等)
|
||||||
|
|
||||||
::: tip 图片显示
|
### URL 网页内容
|
||||||
文档中的图片会自动上传到对象存储并替换为可访问的 URL。但是如果想要在外部正常显示图片,需要配置 `HOST_IP` 环境变量,将其设置为您的服务器 IP 地址。
|
- **网页链接**: `http://` 或 `https://`
|
||||||
|
- 自动抓取网页 HTML 内容并转换为 Markdown
|
||||||
|
- **白名单机制**: 出于安全考虑,必须配置环境变量 `YUXI_URL_WHITELIST` 才能使用此功能
|
||||||
|
- **内网保护**: 默认禁止抓取私有 IP 地址(如 127.0.0.1, 192.168.x.x)
|
||||||
|
- **去重机制**: 自动检测 URL 是否已存在,以及内容 Hash 是否重复
|
||||||
|
|
||||||
|
::: tip URL 白名单配置
|
||||||
|
在 `.env` 文件中配置允许抓取的域名列表,用逗号分隔。支持通配符。
|
||||||
|
例如:`YUXI_URL_WHITELIST=github.com,*.wikipedia.org,docs.python.org`
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 快速配置
|
## 快速配置
|
||||||
|
|
||||||
### 1. 基础 OCR (RapidOCR)
|
### 1. 基础 OCR (RapidOCR)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ dependencies = [
|
|||||||
"llama-index>=0.14",
|
"llama-index>=0.14",
|
||||||
"llama-index-readers-file>=0.4.7",
|
"llama-index-readers-file>=0.4.7",
|
||||||
"markdownify>=1.1.0",
|
"markdownify>=1.1.0",
|
||||||
|
"readability-lxml>=0.8.1",
|
||||||
"mcp>=1.20",
|
"mcp>=1.20",
|
||||||
"neo4j>=5.28.1",
|
"neo4j>=5.28.1",
|
||||||
"networkx>=3.5",
|
"networkx>=3.5",
|
||||||
|
|||||||
@ -269,9 +269,9 @@ async def add_documents(
|
|||||||
"qa_separator": params.get("qa_separator", ""),
|
"qa_separator": params.get("qa_separator", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 禁止 URL 解析与入库
|
# URL 解析与入库(需白名单验证)
|
||||||
if content_type == "url":
|
if content_type == "url":
|
||||||
raise HTTPException(status_code=400, detail="URL 文档上传与解析已禁用")
|
raise HTTPException(status_code=400, detail="URL 处理方式已变更,请使用 fetch-url 接口先获取内容")
|
||||||
|
|
||||||
# 安全检查:验证文件路径
|
# 安全检查:验证文件路径
|
||||||
if content_type == "file":
|
if content_type == "file":
|
||||||
@ -640,9 +640,14 @@ async def download_document(db_id: str, doc_id: str, request: Request, current_u
|
|||||||
file_info = await knowledge_base.get_file_basic_info(db_id, doc_id)
|
file_info = await knowledge_base.get_file_basic_info(db_id, doc_id)
|
||||||
file_meta = file_info.get("meta", {})
|
file_meta = file_info.get("meta", {})
|
||||||
|
|
||||||
# 获取文件路径和文件名
|
# 获取文件类型、路径和文件名
|
||||||
|
file_type = file_meta.get("file_type", "file")
|
||||||
file_path = file_meta.get("path", "")
|
file_path = file_meta.get("path", "")
|
||||||
filename = file_meta.get("filename", "file")
|
filename = file_meta.get("filename", "file")
|
||||||
|
|
||||||
|
# URL 类型文件没有原始文件可下载
|
||||||
|
if file_type == "url":
|
||||||
|
raise HTTPException(status_code=400, detail="URL 类型文件不支持下载原始文件")
|
||||||
logger.debug(f"File path from database: {file_path}")
|
logger.debug(f"File path from database: {file_path}")
|
||||||
logger.debug(f"Original filename from database: {filename}")
|
logger.debug(f"Original filename from database: {filename}")
|
||||||
|
|
||||||
@ -1088,6 +1093,82 @@ async def move_document(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@knowledge.post("/files/fetch-url")
|
||||||
|
async def fetch_url(
|
||||||
|
url: str = Body(..., embed=True),
|
||||||
|
db_id: str | None = Body(None, embed=True),
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
抓取 URL 内容并上传到 MinIO
|
||||||
|
"""
|
||||||
|
logger.debug(f"Fetching URL: {url} for db_id: {db_id}")
|
||||||
|
try:
|
||||||
|
from src.knowledge.utils.url_fetcher import fetch_url_content
|
||||||
|
from src.storage.minio import get_minio_client
|
||||||
|
from src.knowledge.utils import calculate_content_hash
|
||||||
|
|
||||||
|
# 1. 下载内容 (包含白名单校验、大小限制、类型检查)
|
||||||
|
content_bytes, final_url = await fetch_url_content(url)
|
||||||
|
|
||||||
|
# 2. 计算 Hash
|
||||||
|
content_hash = await calculate_content_hash(content_bytes)
|
||||||
|
|
||||||
|
# 检查是否已存在相同内容的文件
|
||||||
|
if db_id:
|
||||||
|
file_exists = await knowledge_base.file_existed_in_db(db_id, content_hash)
|
||||||
|
if file_exists:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="数据库中已经存在了相同内容文件",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 上传到 MinIO
|
||||||
|
minio_client = get_minio_client()
|
||||||
|
# 确保存储 bucket 存在
|
||||||
|
bucket_name = "kb-html-archives"
|
||||||
|
await asyncio.to_thread(minio_client.ensure_bucket_exists, bucket_name)
|
||||||
|
|
||||||
|
# 如果没有提供 db_id,使用 default
|
||||||
|
folder = db_id if db_id else "default"
|
||||||
|
object_name = f"{folder}/{content_hash}.html"
|
||||||
|
|
||||||
|
upload_result = await minio_client.aupload_file(
|
||||||
|
bucket_name=bucket_name,
|
||||||
|
object_name=object_name,
|
||||||
|
data=content_bytes,
|
||||||
|
content_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检测同名文件(URL即为文件名)
|
||||||
|
same_name_files = []
|
||||||
|
has_same_name = False
|
||||||
|
if db_id:
|
||||||
|
same_name_files = await knowledge_base.get_same_name_files(db_id, url)
|
||||||
|
has_same_name = len(same_name_files) > 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"file_path": upload_result.url,
|
||||||
|
"minio_url": upload_result.url,
|
||||||
|
"content_hash": content_hash,
|
||||||
|
"filename": url, # 原始 URL 作为文件名
|
||||||
|
"final_url": final_url,
|
||||||
|
"size": len(content_bytes),
|
||||||
|
"has_same_name": has_same_name,
|
||||||
|
"same_name_files": same_name_files,
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"URL fetch validation failed: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch URL {url}: {e}, {traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to fetch URL: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@knowledge.post("/files/upload")
|
@knowledge.post("/files/upload")
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
@ -262,13 +262,26 @@ class KnowledgeBase(ABC):
|
|||||||
self._add_to_processing_queue(file_id)
|
self._add_to_processing_queue(file_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Determine processing function based on content type
|
||||||
|
content_type = file_meta.get("processing_params", {}).get("content_type", "file")
|
||||||
|
|
||||||
|
if content_type == "url":
|
||||||
|
from src.knowledge.indexing import process_url_to_markdown
|
||||||
|
|
||||||
|
# Prepare params
|
||||||
|
params = file_meta.get("processing_params", {}) or {}
|
||||||
|
params["db_id"] = db_id
|
||||||
|
|
||||||
|
# Process URL to Markdown
|
||||||
|
markdown_content = await process_url_to_markdown(file_path, params=params)
|
||||||
|
else:
|
||||||
from src.knowledge.indexing import process_file_to_markdown
|
from src.knowledge.indexing import process_file_to_markdown
|
||||||
|
|
||||||
# Prepare params
|
# Prepare params
|
||||||
params = file_meta.get("processing_params", {}) or {}
|
params = file_meta.get("processing_params", {}) or {}
|
||||||
params["db_id"] = db_id
|
params["db_id"] = db_id
|
||||||
|
|
||||||
# Process to Markdown
|
# Process file to Markdown
|
||||||
markdown_content = await process_file_to_markdown(file_path, params=params)
|
markdown_content = await process_file_to_markdown(file_path, params=params)
|
||||||
|
|
||||||
# Save Markdown to MinIO
|
# Save Markdown to MinIO
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from langchain_community.document_loaders import (
|
|||||||
UnstructuredWordDocumentLoader,
|
UnstructuredWordDocumentLoader,
|
||||||
)
|
)
|
||||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
from markdownify import markdownify as md_convert
|
||||||
|
|
||||||
from src.knowledge.utils import calculate_content_hash
|
from src.knowledge.utils import calculate_content_hash
|
||||||
from src.storage.minio import get_minio_client
|
from src.storage.minio import get_minio_client
|
||||||
@ -712,4 +713,41 @@ def _replace_image_links(markdown_content: str, images: list[dict]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def process_url_to_markdown(url: str, params: dict | None = None) -> str:
|
async def process_url_to_markdown(url: str, params: dict | None = None) -> str:
|
||||||
raise NotImplementedError("URL 解析功能已禁用")
|
"""
|
||||||
|
Fetch a URL and convert its content to Markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to fetch.
|
||||||
|
params: Optional parameters (unused, kept for API compatibility).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Markdown content of the URL.
|
||||||
|
"""
|
||||||
|
logger.info(f"Fetching URL: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
# 使用异步 HTTP 客户端获取页面
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
response.raise_for_status()
|
||||||
|
html_content = response.text
|
||||||
|
|
||||||
|
# 使用 readability 提取正文 HTML
|
||||||
|
from readability import Document
|
||||||
|
|
||||||
|
doc = Document(html_content)
|
||||||
|
body_html = doc.summary()
|
||||||
|
|
||||||
|
# 转换为 Markdown
|
||||||
|
markdown_content = md_convert(body_html, heading_style="atx")
|
||||||
|
|
||||||
|
logger.info(f"Successfully converted URL to Markdown: {url}")
|
||||||
|
return markdown_content
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Failed to fetch URL {url}: {e}")
|
||||||
|
raise ValueError(f"Failed to fetch URL: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to process URL {url}: {e}")
|
||||||
|
raise ValueError(f"Failed to process URL: {e}")
|
||||||
|
|||||||
@ -188,6 +188,51 @@ async def prepare_item_metadata(item: str, content_type: str, db_id: str, params
|
|||||||
db_id: 数据库ID
|
db_id: 数据库ID
|
||||||
params: 处理参数,可选
|
params: 处理参数,可选
|
||||||
"""
|
"""
|
||||||
|
# 检查是否有预处理信息 (针对 URL 转 HTML 文件的情况)
|
||||||
|
if params and "_preprocessed_map" in params and item in params["_preprocessed_map"]:
|
||||||
|
pre_info = params["_preprocessed_map"][item]
|
||||||
|
|
||||||
|
# 使用预处理信息
|
||||||
|
filename = pre_info.get("filename", item) # 通常是原始 URL
|
||||||
|
|
||||||
|
# 截断文件名以适应数据库限制 (512 chars),保留部分后缀信息如果可能
|
||||||
|
if len(filename) > 500:
|
||||||
|
filename_display = filename[:400] + "..." + filename[-90:]
|
||||||
|
else:
|
||||||
|
filename_display = filename
|
||||||
|
|
||||||
|
file_type = "html" # 强制转换为 html 类型,以便后续作为文件处理
|
||||||
|
item_path = pre_info["path"] # MinIO path
|
||||||
|
content_hash = pre_info["content_hash"]
|
||||||
|
|
||||||
|
# 使用 item(url) 生成 ID,保证同一 URL 即使多次添加 ID 也不同(配合 time)
|
||||||
|
# 或者我们应该基于 hash?不,基于 time 更符合上传逻辑
|
||||||
|
file_id = f"file_{hashstr(item + str(time.time()), 6)}"
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"database_id": db_id,
|
||||||
|
"filename": filename_display,
|
||||||
|
"path": item_path,
|
||||||
|
"file_type": file_type,
|
||||||
|
"status": "processing",
|
||||||
|
"created_at": utc_isoformat(),
|
||||||
|
"file_id": file_id,
|
||||||
|
"content_hash": content_hash,
|
||||||
|
"parent_id": params.get("parent_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if params:
|
||||||
|
# 移除内部参数以免污染 metadata
|
||||||
|
safe_params = params.copy()
|
||||||
|
safe_params.pop("_preprocessed_map", None)
|
||||||
|
# 覆盖 content_type 为 file,确保后续解析走文件流程(MinIO 下载 -> HTML 解析)
|
||||||
|
# 而不是再次尝试作为 URL 抓取
|
||||||
|
safe_params["content_type"] = "file"
|
||||||
|
safe_params["original_source"] = item # 保存完整 URL 到 JSON 字段,避免数据库字段长度限制
|
||||||
|
metadata["processing_params"] = safe_params
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
if content_type == "file":
|
if content_type == "file":
|
||||||
# 检测是否是MinIO URL还是本地文件路径
|
# 检测是否是MinIO URL还是本地文件路径
|
||||||
if is_minio_url(item):
|
if is_minio_url(item):
|
||||||
@ -243,8 +288,17 @@ async def prepare_item_metadata(item: str, content_type: str, db_id: str, params
|
|||||||
# 生成文件ID
|
# 生成文件ID
|
||||||
file_id = f"file_{hashstr(str(item_path) + str(time.time()), 6)}"
|
file_id = f"file_{hashstr(str(item_path) + str(time.time()), 6)}"
|
||||||
|
|
||||||
|
elif content_type == "url":
|
||||||
|
# URL 处理
|
||||||
|
filename = item # 使用完整 URL 作为文件名
|
||||||
|
filename_display = item
|
||||||
|
file_type = "url"
|
||||||
|
item_path = item
|
||||||
|
content_hash = None # URL 没有 content_hash
|
||||||
|
file_id = f"url_{hashstr(item + str(time.time()), 6)}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("URL 元数据生成已禁用")
|
raise ValueError(f"Unsupported content_type: {content_type}")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"database_id": db_id,
|
"database_id": db_id,
|
||||||
|
|||||||
131
src/knowledge/utils/url_fetcher.py
Normal file
131
src/knowledge/utils/url_fetcher.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import httpx
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
from src.knowledge.utils.url_validator import validate_url, is_url_parsing_enabled
|
||||||
|
from src.utils import logger
|
||||||
|
|
||||||
|
# 最大允许下载大小 (例如 10MB)
|
||||||
|
MAX_DOWNLOAD_SIZE = 10 * 1024 * 1024
|
||||||
|
# 允许的 Content-Type
|
||||||
|
ALLOWED_CONTENT_TYPES = ["text/html", "application/xhtml+xml"]
|
||||||
|
|
||||||
|
async def is_private_ip(hostname: str) -> bool:
|
||||||
|
"""Check if the hostname resolves to a private IP address."""
|
||||||
|
import asyncio
|
||||||
|
try:
|
||||||
|
# Resolve hostname to IP in a separate thread to avoid blocking the event loop
|
||||||
|
ip_list = await asyncio.to_thread(socket.getaddrinfo, hostname, None)
|
||||||
|
for item in ip_list:
|
||||||
|
ip_addr = item[4][0]
|
||||||
|
ip_obj = ipaddress.ip_address(ip_addr)
|
||||||
|
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to resolve hostname {hostname}: {e}")
|
||||||
|
# If resolution fails, assume it's unsafe or let the connection fail naturally,
|
||||||
|
# but to be safe we can return True to block it if strict mode is preferred.
|
||||||
|
# For now, we return False assuming standard DNS failure handling.
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def fetch_url_content(url: str, max_size: int = MAX_DOWNLOAD_SIZE) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Fetch URL content with security checks (size limit, content type, private IP blocking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to fetch.
|
||||||
|
max_size: Maximum allowed size in bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (content_bytes, final_url)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If validation fails or download error occurs.
|
||||||
|
"""
|
||||||
|
if not is_url_parsing_enabled():
|
||||||
|
raise ValueError("URL parsing feature is disabled")
|
||||||
|
|
||||||
|
# Initial validation
|
||||||
|
is_valid, error_msg = validate_url(url)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(f"Invalid URL: {error_msg}")
|
||||||
|
|
||||||
|
# Parse URL to check IP
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
if await is_private_ip(parsed_url.hostname):
|
||||||
|
raise ValueError("Access to private IP addresses is forbidden")
|
||||||
|
|
||||||
|
current_url = url
|
||||||
|
redirect_count = 0
|
||||||
|
max_redirects = 5
|
||||||
|
|
||||||
|
# We handle redirects manually to check each target URL against whitelist/private IP
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
|
||||||
|
while True:
|
||||||
|
logger.info(f"Fetching URL: {current_url}")
|
||||||
|
|
||||||
|
# Request headers
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stream the response to check headers before downloading body
|
||||||
|
async with client.stream("GET", current_url, headers=headers) as response:
|
||||||
|
# Handle Redirects
|
||||||
|
if response.status_code in (301, 302, 303, 307, 308):
|
||||||
|
if redirect_count >= max_redirects:
|
||||||
|
raise ValueError("Too many redirects")
|
||||||
|
|
||||||
|
redirect_count += 1
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
if not location:
|
||||||
|
raise ValueError("Redirect response missing Location header")
|
||||||
|
|
||||||
|
# Handle relative redirects
|
||||||
|
if location.startswith("/"):
|
||||||
|
parsed_current = urlparse(current_url)
|
||||||
|
current_url = f"{parsed_current.scheme}://{parsed_current.netloc}{location}"
|
||||||
|
elif not location.startswith("http"):
|
||||||
|
# Handle relative path without /? or other weird cases, or assume absolute
|
||||||
|
parsed_current = urlparse(current_url)
|
||||||
|
# simple join
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
current_url = urljoin(current_url, location)
|
||||||
|
else:
|
||||||
|
current_url = location
|
||||||
|
|
||||||
|
# Validate the new URL
|
||||||
|
is_valid, error_msg = validate_url(current_url)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(f"Redirected to invalid URL: {error_msg}")
|
||||||
|
|
||||||
|
parsed_new = urlparse(current_url)
|
||||||
|
if await is_private_ip(parsed_new.hostname):
|
||||||
|
raise ValueError("Redirected to private IP address")
|
||||||
|
|
||||||
|
continue # Start new request
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Check Content-Type
|
||||||
|
content_type = response.headers.get("Content-Type", "").lower()
|
||||||
|
if not any(allowed in content_type for allowed in ALLOWED_CONTENT_TYPES):
|
||||||
|
raise ValueError(f"Unsupported Content-Type: {content_type}. Only HTML is supported.")
|
||||||
|
|
||||||
|
# Download content with size limit
|
||||||
|
content = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
content.extend(chunk)
|
||||||
|
if len(content) > max_size:
|
||||||
|
raise ValueError(f"Content size exceeds limit of {max_size} bytes")
|
||||||
|
|
||||||
|
return bytes(content), current_url
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"HTTP error fetching {url}: {e}")
|
||||||
|
raise ValueError(f"Failed to fetch URL: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching {url}: {e}")
|
||||||
|
raise ValueError(f"Error fetching URL: {str(e)}")
|
||||||
86
src/knowledge/utils/url_validator.py
Normal file
86
src/knowledge/utils/url_validator.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""URL validation utilities for whitelist-based URL parsing."""
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# Environment variable name for URL whitelist
|
||||||
|
YUXI_URL_WHITELIST_ENV = "YUXI_URL_WHITELIST"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_whitelist() -> list[str]:
|
||||||
|
"""Get the URL whitelist from environment variables."""
|
||||||
|
whitelist_str = os.environ.get(YUXI_URL_WHITELIST_ENV, "")
|
||||||
|
if not whitelist_str:
|
||||||
|
return []
|
||||||
|
# Split by comma and clean up whitespace
|
||||||
|
return [item.strip() for item in whitelist_str.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate if a URL is in the whitelist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (is_valid, error_message).
|
||||||
|
If is_valid is True, error_message will be empty.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return False, "URL cannot be empty"
|
||||||
|
|
||||||
|
# Parse the URL
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Invalid URL format: {e}"
|
||||||
|
|
||||||
|
# Check if URL has a valid scheme
|
||||||
|
if not parsed.scheme:
|
||||||
|
return False, "URL must start with http:// or https://"
|
||||||
|
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return False, "URL must use HTTP or HTTPS protocol"
|
||||||
|
|
||||||
|
# Get the hostname
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
return False, "Invalid URL: no hostname found"
|
||||||
|
|
||||||
|
# Get the whitelist
|
||||||
|
whitelist = _get_whitelist()
|
||||||
|
|
||||||
|
# If whitelist is empty, URL parsing is disabled
|
||||||
|
if not whitelist:
|
||||||
|
return False, "URL parsing feature is disabled"
|
||||||
|
|
||||||
|
# Check if hostname is in whitelist
|
||||||
|
for allowed in whitelist:
|
||||||
|
# Handle wildcard patterns like *.example.com
|
||||||
|
if allowed.startswith("*."):
|
||||||
|
domain = allowed[2:]
|
||||||
|
# Check if hostname ends with the domain (or matches exactly)
|
||||||
|
if hostname == domain or hostname.endswith(f".{domain}"):
|
||||||
|
return True, ""
|
||||||
|
else:
|
||||||
|
# Exact match or subdomain match
|
||||||
|
if hostname == allowed or hostname.endswith(f".{allowed}"):
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
return False, f"Domain '{hostname}' is not in the whitelist"
|
||||||
|
|
||||||
|
|
||||||
|
def is_url_parsing_enabled() -> bool:
|
||||||
|
"""Check if URL parsing is enabled (whitelist is configured)."""
|
||||||
|
whitelist = _get_whitelist()
|
||||||
|
return len(whitelist) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_whitelist_info() -> dict:
|
||||||
|
"""Get information about the current whitelist configuration."""
|
||||||
|
whitelist = _get_whitelist()
|
||||||
|
return {
|
||||||
|
"enabled": len(whitelist) > 0,
|
||||||
|
"domains": whitelist,
|
||||||
|
"count": len(whitelist),
|
||||||
|
}
|
||||||
@ -259,6 +259,19 @@ export const queryApi = {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const fileApi = {
|
export const fileApi = {
|
||||||
|
/**
|
||||||
|
* 抓取 URL 内容
|
||||||
|
* @param {string} url - 目标 URL
|
||||||
|
* @param {string} dbId - 知识库 ID
|
||||||
|
* @returns {Promise} - 抓取结果
|
||||||
|
*/
|
||||||
|
fetchUrl: async (url, dbId = null) => {
|
||||||
|
return apiAdminPost('/api/knowledge/files/fetch-url', {
|
||||||
|
url,
|
||||||
|
db_id: dbId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件
|
* 上传文件
|
||||||
* @param {File} file - 文件对象
|
* @param {File} file - 文件对象
|
||||||
|
|||||||
@ -24,6 +24,13 @@
|
|||||||
>
|
>
|
||||||
上传文件夹
|
上传文件夹
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
|
<a-menu-item
|
||||||
|
key="upload-url"
|
||||||
|
@click="showAddFilesModal({ mode: 'url' })"
|
||||||
|
:icon="h(Link, { size: 16 })"
|
||||||
|
>
|
||||||
|
解析 URL
|
||||||
|
</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
@ -342,6 +349,7 @@
|
|||||||
@click="handleDownloadFile(record)"
|
@click="handleDownloadFile(record)"
|
||||||
:disabled="
|
:disabled="
|
||||||
lock ||
|
lock ||
|
||||||
|
record.file_type === 'url' ||
|
||||||
!['done', 'indexed', 'parsed', 'error_indexing'].includes(record.status)
|
!['done', 'indexed', 'parsed', 'error_indexing'].includes(record.status)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@ -441,7 +449,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ChevronDown
|
ChevronDown,
|
||||||
|
Link
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const store = useDatabaseStore()
|
const store = useDatabaseStore()
|
||||||
@ -831,7 +840,10 @@ const buildFileTree = (fileList) => {
|
|||||||
const normalizedName = file.filename.replace(/\\/g, '/')
|
const normalizedName = file.filename.replace(/\\/g, '/')
|
||||||
const parts = normalizedName.split('/')
|
const parts = normalizedName.split('/')
|
||||||
|
|
||||||
if (parts.length === 1) {
|
// 检测是否是 URL(URL 不应该被解析为文件夹层级)
|
||||||
|
const isUrl = file.filename.startsWith('http://') || file.filename.startsWith('https://')
|
||||||
|
|
||||||
|
if (isUrl || parts.length === 1) {
|
||||||
// Root item
|
// Root item
|
||||||
// Check if it's an explicit folder that should merge with an existing implicit one?
|
// Check if it's an explicit folder that should merge with an existing implicit one?
|
||||||
if (item.is_folder) {
|
if (item.is_folder) {
|
||||||
|
|||||||
@ -12,7 +12,11 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
@click="chunkData"
|
@click="chunkData"
|
||||||
:loading="chunkLoading"
|
:loading="chunkLoading"
|
||||||
:disabled="fileList.length === 0"
|
:disabled="
|
||||||
|
uploadMode === 'url'
|
||||||
|
? !urlList.some((i) => i.status === 'success')
|
||||||
|
: fileList.length === 0
|
||||||
|
"
|
||||||
>
|
>
|
||||||
添加到知识库
|
添加到知识库
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -30,13 +34,23 @@
|
|||||||
class="custom-segmented"
|
class="custom-segmented"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="auto-index-toggle">
|
||||||
|
<a-checkbox v-model:checked="autoIndex">上传后自动入库</a-checkbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. 配置面板 -->
|
<!-- 2. 配置面板 -->
|
||||||
<div class="settings-panel">
|
<div
|
||||||
|
class="settings-panel"
|
||||||
|
v-if="folderTreeData.length > 0 || uploadMode !== 'url' || autoIndex"
|
||||||
|
>
|
||||||
<!-- 第一行:存储位置 + OCR 引擎 -->
|
<!-- 第一行:存储位置 + OCR 引擎 -->
|
||||||
<div class="setting-row two-cols">
|
<div
|
||||||
<div class="col-item">
|
class="setting-row"
|
||||||
|
v-if="folderTreeData.length > 0 || uploadMode !== 'url'"
|
||||||
|
:class="{ 'two-cols': uploadMode !== 'url' && folderTreeData.length > 0 }"
|
||||||
|
>
|
||||||
|
<div class="col-item" v-if="folderTreeData.length > 0">
|
||||||
<div class="setting-label">存储位置</div>
|
<div class="setting-label">存储位置</div>
|
||||||
<div class="setting-content flex-row">
|
<div class="setting-content flex-row">
|
||||||
<a-tree-select
|
<a-tree-select
|
||||||
@ -54,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="param-description">选择文件保存的目标文件夹</p>
|
<p class="param-description">选择文件保存的目标文件夹</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-item">
|
<div class="col-item" v-if="uploadMode !== 'url'">
|
||||||
<div class="setting-label">
|
<div class="setting-label">
|
||||||
OCR 引擎
|
OCR 引擎
|
||||||
<a-tooltip title="检查服务状态">
|
<a-tooltip title="检查服务状态">
|
||||||
@ -89,13 +103,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第三行:自动入库配置 -->
|
<!-- 第二行:自动入库配置 (仅在开启时显示) -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" v-if="autoIndex">
|
||||||
<div class="col-item">
|
<div class="col-item">
|
||||||
<div class="setting-label">
|
<div class="setting-label">入库参数配置</div>
|
||||||
<a-checkbox v-model:checked="autoIndex">上传后自动入库</a-checkbox>
|
<div class="setting-content">
|
||||||
</div>
|
|
||||||
<div class="setting-content" v-if="autoIndex">
|
|
||||||
<template v-if="!isGraphBased">
|
<template v-if="!isGraphBased">
|
||||||
<ChunkParamsConfig :temp-chunk-params="indexParams" :show-qa-split="true" />
|
<ChunkParamsConfig :temp-chunk-params="indexParams" :show-qa-split="true" />
|
||||||
</template>
|
</template>
|
||||||
@ -117,7 +129,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文件上传区域 -->
|
<!-- 文件上传区域 -->
|
||||||
<div class="upload-area">
|
<div class="upload-area" v-if="uploadMode !== 'url'">
|
||||||
<a-upload-dragger
|
<a-upload-dragger
|
||||||
class="custom-dragger"
|
class="custom-dragger"
|
||||||
v-model:fileList="fileList"
|
v-model:fileList="fileList"
|
||||||
@ -139,6 +151,59 @@
|
|||||||
</a-upload-dragger>
|
</a-upload-dragger>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- URL 输入区域 -->
|
||||||
|
<div class="url-area" v-if="uploadMode === 'url'">
|
||||||
|
<div class="url-input-wrapper">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="newUrl"
|
||||||
|
placeholder="输入 URL,一行一个 https://site1.com https://site2.com"
|
||||||
|
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||||
|
class="url-input"
|
||||||
|
@keydown.enter.ctrl="handleFetchUrls"
|
||||||
|
/>
|
||||||
|
<div class="url-actions">
|
||||||
|
<span class="url-hint">
|
||||||
|
支持批量粘贴,自动过滤空行。
|
||||||
|
<span class="warning-text">需配置白名单,详见文档说明</span>
|
||||||
|
</span>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleFetchUrls"
|
||||||
|
class="add-url-btn"
|
||||||
|
:loading="fetchingUrls"
|
||||||
|
:disabled="!newUrl.trim()"
|
||||||
|
>
|
||||||
|
加载 URLs
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="url-list" v-if="urlList.length > 0">
|
||||||
|
<div v-for="(item, index) in urlList" :key="index" class="url-item">
|
||||||
|
<div class="url-icon-wrapper">
|
||||||
|
<Link v-if="item.status === 'success'" :size="14" class="url-icon success" />
|
||||||
|
<Info
|
||||||
|
v-else-if="item.status === 'error'"
|
||||||
|
:size="14"
|
||||||
|
class="url-icon error"
|
||||||
|
:title="item.error"
|
||||||
|
/>
|
||||||
|
<RotateCw v-else :size="14" class="url-icon spinning" />
|
||||||
|
</div>
|
||||||
|
<div class="url-content">
|
||||||
|
<span class="url-text" :title="item.url">{{ item.url }}</span>
|
||||||
|
<span v-if="item.status === 'error'" class="url-error-msg">{{ item.error }}</span>
|
||||||
|
</div>
|
||||||
|
<a-button type="text" size="small" class="remove-url-btn" @click="removeUrl(index)">
|
||||||
|
<X :size="14" />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="url-empty-tip" v-else>
|
||||||
|
<Info :size="16" />
|
||||||
|
<span>输入 URL 后点击加载,系统将自动抓取网页内容</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 同名文件提示 -->
|
<!-- 同名文件提示 -->
|
||||||
<div v-if="sameNameFiles.length > 0" class="conflict-files-panel">
|
<div v-if="sameNameFiles.length > 0" class="conflict-files-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@ -185,7 +250,17 @@ import { useDatabaseStore } from '@/stores/database'
|
|||||||
import { ocrApi } from '@/apis/system_api'
|
import { ocrApi } from '@/apis/system_api'
|
||||||
import { fileApi, documentApi } from '@/apis/knowledge_api'
|
import { fileApi, documentApi } from '@/apis/knowledge_api'
|
||||||
import { CheckCircleFilled, ReloadOutlined } from '@ant-design/icons-vue'
|
import { CheckCircleFilled, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
import { FileUp, FolderUp, RotateCw, CircleHelp, Info, Download, Trash2 } from 'lucide-vue-next'
|
import {
|
||||||
|
FileUp,
|
||||||
|
FolderUp,
|
||||||
|
RotateCw,
|
||||||
|
CircleHelp,
|
||||||
|
Info,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
Link,
|
||||||
|
X
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
|
||||||
|
|
||||||
@ -205,6 +280,10 @@ const props = defineProps({
|
|||||||
isFolderMode: {
|
isFolderMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'file'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -238,7 +317,7 @@ watch(
|
|||||||
if (newVal) {
|
if (newVal) {
|
||||||
selectedFolderId.value = props.currentFolderId
|
selectedFolderId.value = props.currentFolderId
|
||||||
isFolderUpload.value = props.isFolderMode
|
isFolderUpload.value = props.isFolderMode
|
||||||
uploadMode.value = props.isFolderMode ? 'folder' : 'file'
|
uploadMode.value = props.mode || (props.isFolderMode ? 'folder' : 'file')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -343,23 +422,125 @@ const uploadModeOptions = computed(() => [
|
|||||||
h(FolderUp, { size: 16, class: 'option-icon' }),
|
h(FolderUp, { size: 16, class: 'option-icon' }),
|
||||||
h('span', { class: 'option-text' }, '上传文件夹')
|
h('span', { class: 'option-text' }, '上传文件夹')
|
||||||
])
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'url',
|
||||||
|
label: h('div', { class: 'segmented-option' }, [
|
||||||
|
h(Link, { size: 16, class: 'option-icon' }),
|
||||||
|
h('span', { class: 'option-text' }, '解析 URL')
|
||||||
|
])
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
watch(uploadMode, (val) => {
|
watch(uploadMode, (val) => {
|
||||||
isFolderUpload.value = val === 'folder'
|
isFolderUpload.value = val === 'folder'
|
||||||
// 切换模式时清空已选文件,避免混淆
|
// 切换模式时清空已选内容,避免混淆
|
||||||
fileList.value = []
|
fileList.value = []
|
||||||
sameNameFiles.value = []
|
sameNameFiles.value = []
|
||||||
|
urlList.value = []
|
||||||
|
newUrl.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 文件列表
|
// 文件列表
|
||||||
const fileList = ref([])
|
const fileList = ref([])
|
||||||
|
|
||||||
|
// URL 列表
|
||||||
|
// Item structure: { url: string, status: 'fetching'|'success'|'error', data: object|null, error: string }
|
||||||
|
const urlList = ref([])
|
||||||
|
const newUrl = ref('')
|
||||||
|
const fetchingUrls = ref(false)
|
||||||
|
|
||||||
// 同名文件列表(用于显示提示)
|
// 同名文件列表(用于显示提示)
|
||||||
const sameNameFiles = ref([])
|
const sameNameFiles = ref([])
|
||||||
|
|
||||||
// URL相关功能已移除
|
// URL 相关功能
|
||||||
|
const isValidUrl = (string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(string)
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchUrls = async () => {
|
||||||
|
const text = newUrl.value
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const lines = text
|
||||||
|
.split(/[\r\n]+/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l)
|
||||||
|
if (lines.length === 0) return
|
||||||
|
|
||||||
|
// 1. 预处理:添加到列表
|
||||||
|
const newItems = []
|
||||||
|
for (const url of lines) {
|
||||||
|
if (!isValidUrl(url)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (urlList.value.some((u) => u.url === url)) continue
|
||||||
|
|
||||||
|
const item = { url, status: 'pending', data: null, error: '' }
|
||||||
|
urlList.value.push(item)
|
||||||
|
newItems.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newItems.length === 0) {
|
||||||
|
if (lines.length > 0) {
|
||||||
|
message.warning('没有检测到有效的新 URL')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newUrl.value = '' // 清空输入框
|
||||||
|
fetchingUrls.value = true
|
||||||
|
|
||||||
|
// 2. 并发处理
|
||||||
|
// 为避免过多并发请求,可以考虑使用 p-limit 或简单的分批,但此处直接并发
|
||||||
|
const processItem = async (item) => {
|
||||||
|
item.status = 'fetching'
|
||||||
|
try {
|
||||||
|
const res = await fileApi.fetchUrl(item.url, databaseId.value)
|
||||||
|
item.status = 'success'
|
||||||
|
item.data = res
|
||||||
|
|
||||||
|
// 处理同名文件冲突提示
|
||||||
|
if (res.has_same_name && res.same_name_files && res.same_name_files.length > 0) {
|
||||||
|
// 合并到现有的同名文件列表中,去重
|
||||||
|
const existingIds = new Set(sameNameFiles.value.map((f) => f.file_id))
|
||||||
|
const newConflicts = res.same_name_files.filter((f) => !existingIds.has(f.file_id))
|
||||||
|
sameNameFiles.value.push(...newConflicts)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch URL:', error)
|
||||||
|
item.status = 'error'
|
||||||
|
|
||||||
|
// 特别处理内容重复 (409)
|
||||||
|
const detail = error.response?.data?.detail || error.message || ''
|
||||||
|
if (detail.includes('same content') || detail.includes('相同内容')) {
|
||||||
|
item.error = '内容已存在于知识库中'
|
||||||
|
} else {
|
||||||
|
item.error = detail || '加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(newItems.map(processItem))
|
||||||
|
fetchingUrls.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUrl = (index) => {
|
||||||
|
urlList.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUrlKeydown = (e) => {
|
||||||
|
// Ctrl + Enter 提交
|
||||||
|
if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleFetchUrls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OCR服务健康状态
|
// OCR服务健康状态
|
||||||
const ocrHealthStatus = ref({
|
const ocrHealthStatus = ref({
|
||||||
@ -790,11 +971,68 @@ const chunkData = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证OCR服务可用性
|
// 验证OCR服务可用性(非 URL 模式下)
|
||||||
if (!validateOcrService()) {
|
if (uploadMode.value !== 'url' && !validateOcrService()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL 模式处理
|
||||||
|
if (uploadMode.value === 'url') {
|
||||||
|
// 过滤出成功的项
|
||||||
|
const successfulItems = urlList.value.filter((item) => item.status === 'success' && item.data)
|
||||||
|
if (successfulItems.length === 0) {
|
||||||
|
message.error('请添加并等待至少一个 URL 解析成功')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
store.state.chunkLoading = true
|
||||||
|
const params = { ...chunkParams.value }
|
||||||
|
if (autoIndex.value) {
|
||||||
|
params.auto_index = true
|
||||||
|
Object.assign(params, indexParams.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 _preprocessed_map 和 items (minio urls)
|
||||||
|
const items = []
|
||||||
|
const preprocessedMap = {}
|
||||||
|
for (const item of successfulItems) {
|
||||||
|
// item.data = { file_path: "http://minio...", content_hash: "...", filename: "...", ... }
|
||||||
|
// 注意:fetch-url 返回的 file_path 其实是 MinIO URL
|
||||||
|
// 我们需要传递 MinIO URL 给 addDocuments
|
||||||
|
const minioUrl = item.data.file_path
|
||||||
|
items.push(minioUrl)
|
||||||
|
preprocessedMap[minioUrl] = {
|
||||||
|
path: minioUrl,
|
||||||
|
content_hash: item.data.content_hash,
|
||||||
|
filename: item.data.filename,
|
||||||
|
file_size: item.data.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params._preprocessed_map = preprocessedMap
|
||||||
|
|
||||||
|
// 调用 addFiles (file mode)
|
||||||
|
await store.addFiles({
|
||||||
|
items: items,
|
||||||
|
contentType: 'file', // 重要:这里改为 file,因为我们已经转成了 minio 上的文件
|
||||||
|
params,
|
||||||
|
parentId: selectedFolderId.value
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('success')
|
||||||
|
handleCancel()
|
||||||
|
urlList.value = []
|
||||||
|
newUrl.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('URL 提交失败:', error)
|
||||||
|
message.error('URL 提交失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
store.state.chunkLoading = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件模式处理
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif']
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif']
|
||||||
|
|
||||||
// 提取已上传的文件信息
|
// 提取已上传的文件信息
|
||||||
@ -880,12 +1118,25 @@ const chunkData = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-index-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
|
||||||
|
:deep(.ant-checkbox-wrapper) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-600);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.help-link-btn {
|
.help-link-btn {
|
||||||
color: var(--gray-600);
|
color: var(--gray-600);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--main-color);
|
color: var(--main-color);
|
||||||
@ -1127,6 +1378,135 @@ const chunkData = async () => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* URL Area */
|
||||||
|
.url-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: var(--color-warning-500);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-url-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--gray-100);
|
||||||
|
border-color: var(--main-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-icon {
|
||||||
|
color: var(--main-500);
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: var(--color-success-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: var(--color-error-500);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: var(--main-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-700);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-error-msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-error-500);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-url-btn {
|
||||||
|
color: var(--gray-400);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-error-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-empty-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px dashed var(--gray-300);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Conflict Files Panel */
|
/* Conflict Files Panel */
|
||||||
.conflict-files-panel {
|
.conflict-files-panel {
|
||||||
border: 1px solid var(--gray-200);
|
border: 1px solid var(--gray-200);
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import {
|
|||||||
FileExcelFilled,
|
FileExcelFilled,
|
||||||
FileImageFilled,
|
FileImageFilled,
|
||||||
FileUnknownFilled,
|
FileUnknownFilled,
|
||||||
FilePptFilled
|
FilePptFilled,
|
||||||
|
LinkOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { formatRelative, parseToShanghai } from '@/utils/time'
|
import { formatRelative, parseToShanghai } from '@/utils/time'
|
||||||
|
|
||||||
@ -15,6 +16,11 @@ import { formatRelative, parseToShanghai } from '@/utils/time'
|
|||||||
export const getFileIcon = (filename) => {
|
export const getFileIcon = (filename) => {
|
||||||
if (!filename) return FileUnknownFilled
|
if (!filename) return FileUnknownFilled
|
||||||
|
|
||||||
|
// Check if it's a URL
|
||||||
|
if (filename.startsWith('http://') || filename.startsWith('https://')) {
|
||||||
|
return LinkOutlined
|
||||||
|
}
|
||||||
|
|
||||||
const extension = filename.toLowerCase().split('.').pop()
|
const extension = filename.toLowerCase().split('.').pop()
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@ -64,6 +70,11 @@ export const getFileIcon = (filename) => {
|
|||||||
export const getFileIconColor = (filename) => {
|
export const getFileIconColor = (filename) => {
|
||||||
if (!filename) return '#8c8c8c'
|
if (!filename) return '#8c8c8c'
|
||||||
|
|
||||||
|
// Check if it's a URL
|
||||||
|
if (filename.startsWith('http://') || filename.startsWith('https://')) {
|
||||||
|
return '#1890ff' // Blue for links
|
||||||
|
}
|
||||||
|
|
||||||
const extension = filename.toLowerCase().split('.').pop()
|
const extension = filename.toLowerCase().split('.').pop()
|
||||||
|
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
:folder-tree="folderTree"
|
:folder-tree="folderTree"
|
||||||
:current-folder-id="currentFolderId"
|
:current-folder-id="currentFolderId"
|
||||||
:is-folder-mode="isFolderUploadMode"
|
:is-folder-mode="isFolderUploadMode"
|
||||||
|
:mode="addFilesMode"
|
||||||
@success="onFileUploadSuccess"
|
@success="onFileUploadSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -314,14 +315,16 @@ const openSearchConfigModal = () => {
|
|||||||
const addFilesModalVisible = ref(false)
|
const addFilesModalVisible = ref(false)
|
||||||
const currentFolderId = ref(null)
|
const currentFolderId = ref(null)
|
||||||
const isFolderUploadMode = ref(false)
|
const isFolderUploadMode = ref(false)
|
||||||
|
const addFilesMode = ref('file')
|
||||||
|
|
||||||
// 标记是否是初次加载
|
// 标记是否是初次加载
|
||||||
const isInitialLoad = ref(true)
|
const isInitialLoad = ref(true)
|
||||||
|
|
||||||
// 显示添加文件弹窗
|
// 显示添加文件弹窗
|
||||||
const showAddFilesModal = (options = {}) => {
|
const showAddFilesModal = (options = {}) => {
|
||||||
const { isFolder = false } = options
|
const { isFolder = false, mode = 'file' } = options
|
||||||
isFolderUploadMode.value = isFolder
|
isFolderUploadMode.value = isFolder
|
||||||
|
addFilesMode.value = mode
|
||||||
addFilesModalVisible.value = true
|
addFilesModalVisible.value = true
|
||||||
currentFolderId.value = null // 重置
|
currentFolderId.value = null // 重置
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user