feat: 上传文件新增基于URL获取和处理功能,需要配置白名单环境变量

- 引入新的端点来获取URL内容并转换为Markdown格式。
- 在环境变量中添加URL白名单配置以确保安全。
- 增强文件上传模态框以支持URL输入和URL批量处理。
- 更新文件处理逻辑以区分文件和URL。
- 改进UI组件以适应新的URL处理功能。
- 添加用于URL验证和获取的工具函数。
This commit is contained in:
Wenjie Zhang 2026-01-27 15:42:48 +08:00
parent 537b798f9a
commit a4f425dbf9
14 changed files with 875 additions and 35 deletions

View File

@ -25,5 +25,8 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co
# YUXI_SUPER_ADMIN_NAME=
# 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_API_KEY=

View File

@ -17,6 +17,10 @@
- **电子表格**: `.csv`, `.xls`, `.xlsx`
- **JSON 数据**: `.json`
::: tip 图片显示
文档中的图片会自动上传到对象存储并替换为可访问的 URL。但是如果想要在外部正常显示图片需要配置 `HOST_IP` 环境变量,将其设置为您的服务器 IP 地址。
:::
### 图像格式(需要 OCR
- **常见图片**: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.tif`, `.gif`, `.webp`
@ -28,10 +32,20 @@
- 优先处理名为 `full.md` 的文件,否则使用第一个 `.md` 文件
- 支持图片目录的智能识别(`images/`、`../images/` 等)
::: tip 图片显示
文档中的图片会自动上传到对象存储并替换为可访问的 URL。但是如果想要在外部正常显示图片需要配置 `HOST_IP` 环境变量,将其设置为您的服务器 IP 地址。
### URL 网页内容
- **网页链接**: `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)

View File

@ -28,6 +28,7 @@ dependencies = [
"llama-index>=0.14",
"llama-index-readers-file>=0.4.7",
"markdownify>=1.1.0",
"readability-lxml>=0.8.1",
"mcp>=1.20",
"neo4j>=5.28.1",
"networkx>=3.5",

View File

@ -269,9 +269,9 @@ async def add_documents(
"qa_separator": params.get("qa_separator", ""),
}
# 禁止 URL 解析与入库
# 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":
@ -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_meta = file_info.get("meta", {})
# 获取文件路径和文件名
# 获取文件类型、路径和文件名
file_type = file_meta.get("file_type", "file")
file_path = file_meta.get("path", "")
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"Original filename from database: {filename}")
@ -1088,6 +1093,82 @@ async def move_document(
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")
async def upload_file(
file: UploadFile = File(...),

View File

@ -262,14 +262,27 @@ class KnowledgeBase(ABC):
self._add_to_processing_queue(file_id)
try:
from src.knowledge.indexing import process_file_to_markdown
# Determine processing function based on content type
content_type = file_meta.get("processing_params", {}).get("content_type", "file")
# Prepare params
params = file_meta.get("processing_params", {}) or {}
params["db_id"] = db_id
if content_type == "url":
from src.knowledge.indexing import process_url_to_markdown
# Process to Markdown
markdown_content = await process_file_to_markdown(file_path, params=params)
# 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
# Prepare params
params = file_meta.get("processing_params", {}) or {}
params["db_id"] = db_id
# Process file to Markdown
markdown_content = await process_file_to_markdown(file_path, params=params)
# Save Markdown to MinIO
markdown_file_path = await self._save_markdown_to_minio(db_id, file_id, markdown_content)

View File

@ -19,6 +19,7 @@ from langchain_community.document_loaders import (
UnstructuredWordDocumentLoader,
)
from langchain_text_splitters import RecursiveCharacterTextSplitter
from markdownify import markdownify as md_convert
from src.knowledge.utils import calculate_content_hash
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:
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}")

View File

@ -188,6 +188,51 @@ async def prepare_item_metadata(item: str, content_type: str, db_id: str, params
db_id: 数据库ID
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":
# 检测是否是MinIO URL还是本地文件路径
if is_minio_url(item):
@ -243,8 +288,17 @@ async def prepare_item_metadata(item: str, content_type: str, db_id: str, params
# 生成文件ID
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:
raise ValueError("URL 元数据生成已禁用")
raise ValueError(f"Unsupported content_type: {content_type}")
metadata = {
"database_id": db_id,

View 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)}")

View 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),
}

View File

@ -259,6 +259,19 @@ export const queryApi = {
// =============================================================================
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 - 文件对象

View File

@ -24,6 +24,13 @@
>
上传文件夹
</a-menu-item>
<a-menu-item
key="upload-url"
@click="showAddFilesModal({ mode: 'url' })"
:icon="h(Link, { size: 16 })"
>
解析 URL
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
@ -342,6 +349,7 @@
@click="handleDownloadFile(record)"
:disabled="
lock ||
record.file_type === 'url' ||
!['done', 'indexed', 'parsed', 'error_indexing'].includes(record.status)
"
>
@ -441,7 +449,8 @@ import {
Search,
Filter,
ArrowUpDown,
ChevronDown
ChevronDown,
Link
} from 'lucide-vue-next'
const store = useDatabaseStore()
@ -831,7 +840,10 @@ const buildFileTree = (fileList) => {
const normalizedName = file.filename.replace(/\\/g, '/')
const parts = normalizedName.split('/')
if (parts.length === 1) {
// URLURL
const isUrl = file.filename.startsWith('http://') || file.filename.startsWith('https://')
if (isUrl || parts.length === 1) {
// Root item
// Check if it's an explicit folder that should merge with an existing implicit one?
if (item.is_folder) {

View File

@ -12,7 +12,11 @@
type="primary"
@click="chunkData"
:loading="chunkLoading"
:disabled="fileList.length === 0"
:disabled="
uploadMode === 'url'
? !urlList.some((i) => i.status === 'success')
: fileList.length === 0
"
>
添加到知识库
</a-button>
@ -30,13 +34,23 @@
class="custom-segmented"
/>
</div>
<div class="auto-index-toggle">
<a-checkbox v-model:checked="autoIndex">上传后自动入库</a-checkbox>
</div>
</div>
<!-- 2. 配置面板 -->
<div class="settings-panel">
<div
class="settings-panel"
v-if="folderTreeData.length > 0 || uploadMode !== 'url' || autoIndex"
>
<!-- 第一行存储位置 + OCR 引擎 -->
<div class="setting-row two-cols">
<div class="col-item">
<div
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-content flex-row">
<a-tree-select
@ -54,7 +68,7 @@
</div>
<p class="param-description">选择文件保存的目标文件夹</p>
</div>
<div class="col-item">
<div class="col-item" v-if="uploadMode !== 'url'">
<div class="setting-label">
OCR 引擎
<a-tooltip title="检查服务状态">
@ -89,13 +103,11 @@
</div>
</div>
<!-- 三行自动入库配置 -->
<div class="setting-row">
<!-- 二行自动入库配置 (仅在开启时显示) -->
<div class="setting-row" v-if="autoIndex">
<div class="col-item">
<div class="setting-label">
<a-checkbox v-model:checked="autoIndex">上传后自动入库</a-checkbox>
</div>
<div class="setting-content" v-if="autoIndex">
<div class="setting-label">入库参数配置</div>
<div class="setting-content">
<template v-if="!isGraphBased">
<ChunkParamsConfig :temp-chunk-params="indexParams" :show-qa-split="true" />
</template>
@ -117,7 +129,7 @@
</div>
<!-- 文件上传区域 -->
<div class="upload-area">
<div class="upload-area" v-if="uploadMode !== 'url'">
<a-upload-dragger
class="custom-dragger"
v-model:fileList="fileList"
@ -139,6 +151,59 @@
</a-upload-dragger>
</div>
<!-- URL 输入区域 -->
<div class="url-area" v-if="uploadMode === 'url'">
<div class="url-input-wrapper">
<a-textarea
v-model:value="newUrl"
placeholder="输入 URL一行一个&#10;https://site1.com&#10;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 class="panel-header">
@ -185,7 +250,17 @@ import { useDatabaseStore } from '@/stores/database'
import { ocrApi } from '@/apis/system_api'
import { fileApi, documentApi } from '@/apis/knowledge_api'
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 ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
@ -205,6 +280,10 @@ const props = defineProps({
isFolderMode: {
type: Boolean,
default: false
},
mode: {
type: String,
default: 'file'
}
})
@ -238,7 +317,7 @@ watch(
if (newVal) {
selectedFolderId.value = props.currentFolderId
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('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) => {
isFolderUpload.value = val === 'folder'
//
//
fileList.value = []
sameNameFiles.value = []
urlList.value = []
newUrl.value = ''
})
//
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([])
// 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
const ocrHealthStatus = ref({
@ -790,11 +971,68 @@ const chunkData = async () => {
return
}
// OCR
if (!validateOcrService()) {
// OCR URL
if (uploadMode.value !== 'url' && !validateOcrService()) {
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']
//
@ -880,12 +1118,25 @@ const chunkData = async () => {
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 {
color: var(--gray-600);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
padding: 0;
&:hover {
color: var(--main-color);
@ -1127,6 +1378,135 @@ const chunkData = async () => {
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 {
border: 1px solid var(--gray-200);

View File

@ -7,7 +7,8 @@ import {
FileExcelFilled,
FileImageFilled,
FileUnknownFilled,
FilePptFilled
FilePptFilled,
LinkOutlined
} from '@ant-design/icons-vue'
import { formatRelative, parseToShanghai } from '@/utils/time'
@ -15,6 +16,11 @@ import { formatRelative, parseToShanghai } from '@/utils/time'
export const getFileIcon = (filename) => {
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 iconMap = {
@ -64,6 +70,11 @@ export const getFileIcon = (filename) => {
export const getFileIconColor = (filename) => {
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 colorMap = {

View File

@ -14,6 +14,7 @@
:folder-tree="folderTree"
:current-folder-id="currentFolderId"
:is-folder-mode="isFolderUploadMode"
:mode="addFilesMode"
@success="onFileUploadSuccess"
/>
@ -314,14 +315,16 @@ const openSearchConfigModal = () => {
const addFilesModalVisible = ref(false)
const currentFolderId = ref(null)
const isFolderUploadMode = ref(false)
const addFilesMode = ref('file')
//
const isInitialLoad = ref(true)
//
const showAddFilesModal = (options = {}) => {
const { isFolder = false } = options
const { isFolder = false, mode = 'file' } = options
isFolderUploadMode.value = isFolder
addFilesMode.value = mode
addFilesModalVisible.value = true
currentFolderId.value = null //
}