diff --git a/docs/advanced/document-processing.md b/docs/advanced/document-processing.md
index 21463e78..3bbb9890 100644
--- a/docs/advanced/document-processing.md
+++ b/docs/advanced/document-processing.md
@@ -116,38 +116,36 @@ docker compose up -d api
### 文件上传脚本
-使用 `scripts/batch_upload.py upload` 批量上传文件到知识库:
+使用 `scripts/batch_upload.py` 批量上传文件到知识库:
```bash
-# 批量上传文档
-uv run scripts/batch_upload.py upload \
- --db-id your_kb_id \
- --directory path/to/your/data \
- --pattern "*.docx" \
- --base-url http://127.0.0.1:5050/api \
- --username your_username \
- --password your_password \
- --concurrency 4 \
+# 批量上传文档(多种格式)
+uv run scripts/batch_upload.py \
+ --db-id kb_b2730ad6801b149694021106c7eddd38 \
+ --directory data.nogit/农业农村局 \
+ --pattern "*.docx" --pattern "*.txt" --pattern "*.html" \
+ --base-url http://172.19.13.6:5050/api \
+ --username admin \
+ --password admin123 \
+ --batch-size 20 \
+ --wait-for-completion \
+ --poll-interval 5 \
--recursive \
- --record-file scripts/tmp/batch_processed_files.txt
+ --record-file scripts/tmp/batch_processed_files_1029.txt
```
**参数说明**:
- `--db-id`: 目标知识库 ID
- `--directory`: 文件目录路径
-- `--pattern`: 文件匹配模式
-- `--concurrency`: 并发处理数量
+- `--pattern`: 文件匹配模式,可以多次指定以支持多种格式(例如:`--pattern "*.docx" --pattern "*.pdf" --pattern "*.html"`)
+- `--batch-size`: 每批处理的文件数量(默认20)
+- `--wait-for-completion`: 是否等待任务完成再处理下一批(默认开启)
+- `--poll-interval`: 任务状态检查间隔,单位秒(默认5秒)
- `--recursive`: 递归处理子目录
- `--record-file`: 处理记录文件路径
-提示:系统按“内容哈希”进行去重;同一知识库已存在相同内容的文件会被拒绝(409)。
-
-### 脚本功能
-
-- **进度跟踪**: 实时显示处理进度
-- **错误处理**: 自动跳过无法处理的文件
-- **断点续传**: 支持中断后继续处理
-- **日志记录**: 详细记录处理过程
-- **结果统计**: 处理完成后显示统计信息
-
-更多关于“入库参数、导出数据、支持类型”等,请参阅:介绍 → 知识库与知识图谱 → 文档管理。
+**注意事项**:
+- 系统按"内容哈希"进行去重;同一知识库已存在相同内容的文件会被拒绝(409)
+- 建议根据系统性能调整批次大小
+- 大量文件处理时建议开启分批等待功能
+- 先上传后处理的机制更稳定,适合大批量文档导入
diff --git a/docs/changelog/roadmap.md b/docs/changelog/roadmap.md
index 1706b1bc..a7edeff4 100644
--- a/docs/changelog/roadmap.md
+++ b/docs/changelog/roadmap.md
@@ -7,13 +7,12 @@
## Bugs
-- [x] 修复本地知识库的 metadata 和 向量数据库中不一致的情况。
+- [ ] v1 版本的 LangGraph 的工具渲染有问题
+- [ ] upload 接口会阻塞主进程
## Next
-- [x] 修改现有的智能体Demo,并尽量将默认助手的特性兼容到 LangGraph 的 [`create_agent`](https://docs.langchain.com/oss/python/langchain/agents) 中
-- [x] 基于 create_agent 创建 SQL Viewer 智能体
-- [x] 优化 MCP 逻辑,支持 common + special 创建方式
+- [ ] 新建 DeepAgents 智能体
- [ ] 添加对于上传文件的支持
- [ ] 统一图谱数据结构,优化可视化方式 [#298](https://github.com/xerrors/Yuxi-Know/issues/298)
- [ ] 集成智能体评估,首先使用命令行来实现,然后考虑放在 UI 里面展示
@@ -37,3 +36,7 @@
- [x] 支持 MinerU 2.5 的解析方法
- [x] 文件管理:(1)文件选择的时候会跨数据库;(2)文件校验会算上失败的文件;
- [x] Tasker 中获取历史任务的时候,仅获取 top100 个 task。
+- [x] 修改现有的智能体Demo,并尽量将默认助手的特性兼容到 LangGraph 的 [`create_agent`](https://docs.langchain.com/oss/python/langchain/agents) 中
+- [x] 基于 create_agent 创建 SQL Viewer 智能体
+- [x] 优化 MCP 逻辑,支持 common + special 创建方式
+- [x] 修复本地知识库的 metadata 和 向量数据库中不一致的情况。
\ No newline at end of file
diff --git a/scripts/batch_upload.py b/scripts/batch_upload.py
index 4f18b510..a369e42a 100644
--- a/scripts/batch_upload.py
+++ b/scripts/batch_upload.py
@@ -29,6 +29,21 @@ async def login(client: httpx.AsyncClient, base_url: str, username: str, passwor
return None
+async def check_task_status(client: httpx.AsyncClient, base_url: str, task_id: str) -> str | None:
+ """Check the status of a task. Returns status string or None if failed."""
+ try:
+ response = await client.get(f"{base_url}/tasks/{task_id}")
+ response.raise_for_status()
+ task_data = response.json().get("task", {})
+ return task_data.get("status")
+ except httpx.HTTPStatusError as e:
+ console.print(f"[bold yellow]Warning: Failed to check task {task_id}: {e.response.status_code}[/bold yellow]")
+ return None
+ except httpx.RequestError as e:
+ console.print(f"[bold yellow]Warning: Failed to check task {task_id}: {e}[/bold yellow]")
+ return None
+
+
async def upload_file(
client: httpx.AsyncClient,
base_url: str,
@@ -67,7 +82,7 @@ async def process_document(
chunk_overlap: int = 200,
use_qa_split: bool = False,
qa_separator: str = "\n\n\n",
-) -> bool:
+) -> tuple[bool, str | None]:
"""Triggers the processing of an uploaded file in the knowledge base."""
# Prepare processing parameters
params = {
@@ -97,26 +112,26 @@ async def process_document(
f"[bold cyan]Ingestion queued for {server_file_path}{extra}. "
"Track progress in the task center.[/bold cyan]"
)
- return True
+ return True, task_id
# Check if the overall request was successful for synchronous responses
if overall_status != "success":
console.print(
f"[bold yellow]Processing warning for {server_file_path}: {result.get('message')}[/bold yellow]"
)
- return False
+ return False, None
# Check the specific file's processing status in the items array
items = result.get("items", [])
if not items:
console.print(f"[bold red]No processing result for {server_file_path}[/bold red]")
- return False
+ return False, None
# Since we only sent one file, check the first item
item = items[0]
# Check for both 'success' and 'done' status (different APIs might use different status values)
if item.get("status") in ["success", "done"]:
- return True
+ return True, None
else:
# Get more detailed error information
error_msg = item.get("message", "")
@@ -140,59 +155,122 @@ async def process_document(
# Also log the full item for debugging
console.print(f"[dim]Debug - Full item response: {item}[/dim]")
- return False
+ return False, None
except httpx.HTTPStatusError as e:
console.print(
f"[bold red]Failed to process {server_file_path}: {e.response.status_code} - {e.response.text}[/bold red]"
)
- return False
+ return False, None
except httpx.RequestError as e:
console.print(f"[bold red]Failed to process {server_file_path}: {e}[/bold red]")
- return False
+ return False, None
-async def worker(
- semaphore: asyncio.Semaphore,
+async def upload_single_file(
client: httpx.AsyncClient,
base_url: str,
db_id: str,
file_path: pathlib.Path,
- file_hash: str,
progress: Progress,
- upload_task_id: int,
- process_task_id: int,
+ task_id: int,
+) -> str | None:
+ """Upload a single file and return server file path."""
+ server_file_path = await upload_file(client, base_url, db_id, file_path)
+ if server_file_path:
+ progress.update(task_id, advance=1, postfix=f"Uploaded {file_path.name}")
+ else:
+ progress.update(task_id, advance=1, postfix=f"Failed: {file_path.name}")
+ return server_file_path
+
+
+async def add_batch_to_knowledge_base(
+ client: httpx.AsyncClient,
+ base_url: str,
+ db_id: str,
+ server_file_paths: list[str],
enable_ocr: str = "paddlex_ocr",
chunk_size: int = 1000,
chunk_overlap: int = 200,
use_qa_split: bool = False,
qa_separator: str = "\n\n\n",
-):
- """A worker task that uploads and then processes a single file."""
- async with semaphore:
- # 1. Upload file
- server_file_path = await upload_file(client, base_url, db_id, file_path)
- progress.update(upload_task_id, advance=1, postfix=f"Uploaded {file_path.name}")
+) -> tuple[bool, str | None]:
+ """Add a batch of files to knowledge base and return task_id."""
+ if not server_file_paths:
+ return True, None
- if not server_file_path:
- progress.update(process_task_id, advance=1) # Mark as processed to not hang the progress bar
- return file_path, file_hash, "upload_failed"
+ # Prepare processing parameters
+ params = {
+ "chunk_size": chunk_size,
+ "chunk_overlap": chunk_overlap,
+ "enable_ocr": enable_ocr,
+ "use_qa_split": use_qa_split,
+ "qa_separator": qa_separator,
+ "content_type": "file",
+ }
- # 2. Process file
- success = await process_document(
- client,
- base_url,
- db_id,
- server_file_path,
- enable_ocr=enable_ocr,
- chunk_size=chunk_size,
- chunk_overlap=chunk_overlap,
- use_qa_split=use_qa_split,
- qa_separator=qa_separator,
+ try:
+ response = await client.post(
+ f"{base_url}/knowledge/databases/{db_id}/documents",
+ json={"items": server_file_paths, "params": params},
+ timeout=600, # 10 minutes timeout for processing
)
- progress.update(process_task_id, advance=1, postfix=f"Processed {file_path.name}")
+ response.raise_for_status()
+ result = response.json()
- return file_path, file_hash, "success" if success else "processing_failed"
+ overall_status = result.get("status")
+ if overall_status == "queued":
+ task_id = result.get("task_id")
+ extra = f" (task id: {task_id})" if task_id else ""
+ console.print(
+ f"[bold cyan]Batch of {len(server_file_paths)} files queued for processing{extra}. "
+ "Track progress in the task center.[/bold cyan]"
+ )
+ return True, task_id
+ elif overall_status == "success":
+ console.print(f"[bold green]Batch of {len(server_file_paths)} files processed successfully[/bold green]")
+ return True, None
+ else:
+ console.print(f"[bold yellow]Batch processing warning: {result.get('message')}[/bold yellow]")
+ return False, None
+
+ except httpx.HTTPStatusError as e:
+ console.print(f"[bold red]Failed to process batch: {e.response.status_code} - {e.response.text}[/bold red]")
+ return False, None
+ except httpx.RequestError as e:
+ console.print(f"[bold red]Failed to process batch: {e}[/bold red]")
+ return False, None
+
+
+async def wait_for_tasks_completion(
+ client: httpx.AsyncClient,
+ base_url: str,
+ task_ids: list[str],
+ poll_interval: int = 5,
+) -> dict[str, str]:
+ """Wait for all tasks to complete and return their final statuses."""
+ if not task_ids:
+ return {}
+
+ console.print(f"[bold cyan]Waiting for {len(task_ids)} tasks to complete...[/bold cyan]")
+
+ pending_tasks = task_ids.copy()
+ completed_tasks = {}
+
+ while pending_tasks:
+ for task_id in pending_tasks.copy():
+ status = await check_task_status(client, base_url, task_id)
+ if status:
+ if status in ["success", "failed", "cancelled"]:
+ completed_tasks[task_id] = status
+ pending_tasks.remove(task_id)
+ console.print(f"[dim]Task {task_id} completed with status: {status}[/dim]")
+
+ if pending_tasks:
+ await asyncio.sleep(poll_interval)
+
+ console.print(f"[bold green]All {len(task_ids)} tasks completed[/bold green]")
+ return completed_tasks
def get_file_hash(file_path: pathlib.Path) -> str:
@@ -236,11 +314,10 @@ def upload(
directory: pathlib.Path = typer.Option(
..., help="The directory containing files to upload.", exists=True, file_okay=False
),
- pattern: str = typer.Option("*.md", help="The glob pattern for files to upload (e.g., '*.pdf', '**/*.txt')."),
+ pattern: list[str] = typer.Option(["*.md"], help="The glob patterns for files to upload (e.g., '*.pdf', '**/*.txt'). Can be specified multiple times."),
base_url: str = typer.Option("http://127.0.0.1:5050/api", help="The base URL of the API server."),
username: str = typer.Option(..., help="Admin username for login."),
password: str = typer.Option(..., help="Admin password for login."),
- concurrency: int = typer.Option(1, help="The number of concurrent upload/process tasks."),
recursive: bool = typer.Option(False, "--recursive", "-r", help="Search for files recursively in subdirectories."),
record_file: pathlib.Path = typer.Option(
"scripts/tmp/batch_processed_files.txt", help="File to store processed files record."
@@ -252,6 +329,9 @@ def upload(
),
use_qa_split: bool = typer.Option(False, help="Whether to use QA splitting."),
qa_separator: str = typer.Option("\n\n\n", help="Separator for QA splitting."),
+ batch_size: int = typer.Option(20, help="Number of files to process in each batch."),
+ wait_for_completion: bool = typer.Option(True, help="Whether to wait for tasks to complete before next batch."),
+ poll_interval: int = typer.Option(5, help="Polling interval in seconds for checking task status."),
):
"""
Batch upload and process files into a Yuxi-Know knowledge base.
@@ -262,11 +342,19 @@ def upload(
processed_files = load_processed_files(record_file)
console.print(f"Loaded {len(processed_files)} previously processed files from record.")
- # Discover files
+ # Discover files from multiple patterns
glob_method = directory.rglob if recursive else directory.glob
- all_files = list(glob_method(pattern))
+ all_files = []
+ for pat in pattern:
+ files_for_pat = list(glob_method(pat))
+ all_files.extend(files_for_pat)
+
+ # Remove duplicates
+ all_files = list(set(all_files))
+
if not all_files:
- console.print(f"[bold yellow]No files found in '{directory}' matching '{pattern}'. Aborting.[/bold yellow]")
+ patterns_str = "', '".join(pattern)
+ console.print(f"[bold yellow]No files found in '{directory}' matching patterns: '{patterns_str}'. Aborting.[/bold yellow]")
raise typer.Exit()
# 过滤掉macos的隐藏文件
@@ -302,80 +390,106 @@ def upload(
client.headers = {"Authorization": f"Bearer {token}"}
- # Setup concurrency and tasks
- semaphore = asyncio.Semaphore(concurrency)
- tasks = []
+ # Process files in batches: upload 20 -> process 20 -> wait -> repeat
+ total_processed_files = []
+ total_upload_failures = []
+ total_processing_failures = []
+ all_successful_hashes = set()
- with Progress(
- SpinnerColumn(),
- TextColumn("[progress.description]{task.description}"),
- BarColumn(),
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
- TimeElapsedColumn(),
- TextColumn("{task.fields[postfix]}"),
- console=console,
- transient=True,
- ) as progress:
- upload_task_id = progress.add_task("[bold blue]Uploading...", total=len(files_to_upload), postfix="")
- process_task_id = progress.add_task("[bold cyan]Processing...", total=len(files_to_upload), postfix="")
+ # Split all files into batches
+ for batch_num in range(0, len(files_to_upload), batch_size):
+ batch_files = files_to_upload[batch_num:batch_num + batch_size]
+ batch_start = batch_num + 1
+ batch_end = min(batch_num + batch_size, len(files_to_upload))
- for file_path, file_hash in files_to_upload:
- task = asyncio.create_task(
- worker(
- semaphore,
- client,
- base_url,
- db_id,
- file_path,
- file_hash,
- progress,
- upload_task_id,
- process_task_id,
- enable_ocr=enable_ocr,
- chunk_size=chunk_size,
- chunk_overlap=chunk_overlap,
- use_qa_split=use_qa_split,
- qa_separator=qa_separator,
+ console.print(f"\n[bold yellow]=== Batch {batch_start}-{batch_end} of {len(files_to_upload)} ===[/bold yellow]")
+
+ # Step 1: Upload this batch of files sequentially
+ console.print(f"[blue]Step 1: Uploading {len(batch_files)} files...[/blue]")
+
+ successful_uploads = []
+ batch_upload_failures = []
+
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ BarColumn(),
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
+ TimeElapsedColumn(),
+ TextColumn("{task.fields[postfix]}"),
+ console=console,
+ transient=True,
+ ) as progress:
+ upload_task_id = progress.add_task(f"Uploading batch {batch_start}-{batch_end}...", total=len(batch_files), postfix="")
+
+ for file_path, file_hash in batch_files:
+ server_file_path = await upload_single_file(
+ client, base_url, db_id, file_path, progress, upload_task_id
)
+
+ if server_file_path:
+ successful_uploads.append((file_path, file_hash, server_file_path))
+ all_successful_hashes.add(file_hash)
+ else:
+ batch_upload_failures.append(file_path)
+
+ # Step 2: Process this batch if uploads succeeded
+ if successful_uploads:
+ console.print(f"[green]Step 2: Processing {len(successful_uploads)} uploaded files...[/green]")
+
+ # Extract server file paths
+ server_file_paths = [item[2] for item in successful_uploads]
+
+ # Submit batch to knowledge base
+ success, task_id = await add_batch_to_knowledge_base(
+ client,
+ base_url,
+ db_id,
+ server_file_paths,
+ enable_ocr=enable_ocr,
+ chunk_size=chunk_size,
+ chunk_overlap=chunk_overlap,
+ use_qa_split=use_qa_split,
+ qa_separator=qa_separator,
)
- tasks.append(task)
- results = await asyncio.gather(*tasks)
+ if success:
+ total_processed_files.extend([item[0] for item in successful_uploads])
- # Summarize results and update processed files record
- successful_files = []
- upload_failures = []
- processing_failures = []
- newly_processed_hashes = set()
+ # Step 3: Wait for this batch to complete
+ if wait_for_completion and task_id:
+ console.print(f"[cyan]Step 3: Waiting for batch {batch_start}-{batch_end} to complete...[/cyan]")
+ await wait_for_tasks_completion(client, base_url, [task_id], poll_interval)
+ console.print(f"[green]Batch {batch_start}-{batch_end} completed![/green]")
+ else:
+ console.print(f"[green]Batch {batch_start}-{batch_end} submitted successfully![/green]")
+ else:
+ total_processing_failures.extend([item[0] for item in successful_uploads])
+ console.print(f"[red]Batch {batch_start}-{batch_end} processing failed[/red]")
- for file_path, file_hash, status in results:
- if status == "success":
- successful_files.append(file_path)
- newly_processed_hashes.add(file_hash)
- elif status == "upload_failed":
- upload_failures.append(file_path)
- elif status == "processing_failed":
- processing_failures.append(file_path)
- # Don't add to processed files if processing failed
+ # Record batch failures
+ total_upload_failures.extend(batch_upload_failures)
- # Save newly processed files to record
- if newly_processed_hashes:
- all_processed_files = processed_files | newly_processed_hashes
- save_processed_files(record_file, all_processed_files)
- console.print(
- f"[bold green]Updated processed files record with "
- f"{len(newly_processed_hashes)} new entries.[/bold green]"
- )
+ # Update processed files record after each batch
+ if all_successful_hashes:
+ all_processed_files = processed_files | all_successful_hashes
+ save_processed_files(record_file, all_processed_files)
- console.print("[bold green]Batch operation complete.[/bold green]")
- console.print(f" - [green]Successful:[/green] {len(successful_files)}")
- console.print(f" - [red]Upload Failed:[/red] {len(upload_failures)}")
- if upload_failures:
- for f in upload_failures:
+ # Small delay between batches
+ if batch_end < len(files_to_upload):
+ console.print("[dim]Waiting 2 seconds before next batch...[/dim]")
+ await asyncio.sleep(2)
+
+ # Final summary
+ console.print("\n[bold green]=== All Batches Complete ===[/bold green]")
+ console.print(f" - [green]Files successfully processed:[/green] {len(total_processed_files)}")
+ console.print(f" - [red]Upload failures:[/red] {len(total_upload_failures)}")
+ if total_upload_failures:
+ for f in total_upload_failures:
console.print(f" - {f}")
- console.print(f" - [yellow]Processing Failed:[/yellow] {len(processing_failures)}")
- if processing_failures:
- for f in processing_failures:
+ console.print(f" - [yellow]Processing failures:[/yellow] {len(total_processing_failures)}")
+ if total_processing_failures:
+ for f in total_processing_failures:
console.print(f" - {f}")
asyncio.run(run())
@@ -386,11 +500,13 @@ def upload(
uv run scripts/batch_upload.py upload \
--db-id your_kb_id \
--directory path/to/your/data \
- --pattern "*.docx" \
+ --pattern "*.docx" --pattern "*.pdf" --pattern "*.html" \
--base-url http://127.0.0.1:5050/api \
--username your_username \
--password your_password \
- --concurrency 4 \
+ --batch-size 20 \
+ --wait-for-completion \
+ --poll-interval 5 \
--recursive \
--record-file scripts/tmp/batch_processed_files.txt
"""
diff --git a/web/src/components/TaskCenterDrawer.vue b/web/src/components/TaskCenterDrawer.vue
index b72e80c0..01304d04 100644
--- a/web/src/components/TaskCenterDrawer.vue
+++ b/web/src/components/TaskCenterDrawer.vue
@@ -73,18 +73,19 @@