Skip to content

Commit ca3a203

Browse files
committed
fix: remove knowledge base upload file limit
1 parent fe22f75 commit ca3a203

3 files changed

Lines changed: 166 additions & 43 deletions

File tree

astrbot/dashboard/services/knowledge_base_service.py

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import shutil
45
import traceback
56
import uuid
67
from pathlib import Path
@@ -11,7 +12,7 @@
1112
from astrbot.core import logger
1213
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
1314
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
14-
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
15+
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
1516
from astrbot.dashboard.schemas import KnowledgeBaseRequest
1617
from astrbot.dashboard.utils import generate_tsne_visualization
1718

@@ -116,17 +117,45 @@ def format_failed_doc_error(file_name: str, error: Exception) -> str:
116117
return message
117118
return f"{file_name}: {message}"
118119

120+
@staticmethod
121+
def _cleanup_staging_dir(staging_dir: Path) -> None:
122+
"""Remove a knowledge base upload staging directory.
123+
124+
Args:
125+
staging_dir: Task-specific system temporary directory to remove.
126+
"""
127+
try:
128+
shutil.rmtree(staging_dir)
129+
except FileNotFoundError:
130+
pass
131+
except OSError as exc:
132+
logger.warning(f"Failed to clean upload staging directory: {exc}")
133+
119134
async def background_upload_task(
120135
self,
121136
task_id: str,
122137
kb_helper,
123138
files_to_upload: list[dict[str, Any]],
139+
staging_dir: Path,
124140
chunk_size: int,
125141
chunk_overlap: int,
126142
batch_size: int,
127143
tasks_limit: int,
128144
max_retries: int,
129145
) -> None:
146+
"""Process staged knowledge base files one at a time.
147+
148+
Args:
149+
task_id: Identifier used to report upload progress and results.
150+
kb_helper: Knowledge base helper that parses and stores documents.
151+
files_to_upload: Metadata and temporary paths for staged files.
152+
staging_dir: Task-specific system temporary directory.
153+
chunk_size: Maximum size of each generated document chunk.
154+
chunk_overlap: Number of overlapping characters between chunks.
155+
batch_size: Number of chunks sent in each embedding batch.
156+
tasks_limit: Maximum number of concurrent embedding tasks.
157+
max_retries: Maximum retries for embedding operations.
158+
"""
130159
try:
131160
self.init_task(task_id, status="processing")
132161
self.upload_progress[task_id] = {
@@ -142,7 +171,11 @@ async def background_upload_task(
142171
failed_docs = []
143172

144173
for file_idx, file_info in enumerate(files_to_upload):
174+
file_content = None
145175
try:
176+
temp_file_path = Path(file_info["temp_file_path"])
177+
async with aiofiles.open(temp_file_path, "rb") as file_obj:
178+
file_content = await file_obj.read()
146179
self.update_progress(
147180
task_id,
148181
status="processing",
@@ -157,7 +190,7 @@ async def background_upload_task(
157190
)
158191
doc = await kb_helper.upload_document(
159192
file_name=file_info["file_name"],
160-
file_content=file_info["file_content"],
193+
file_content=file_content,
161194
file_type=file_info["file_type"],
162195
chunk_size=chunk_size,
163196
chunk_overlap=chunk_overlap,
@@ -177,6 +210,10 @@ async def background_upload_task(
177210
),
178211
},
179212
)
213+
finally:
214+
# Release the current file before reading the next one.
215+
file_content = None
216+
Path(file_info["temp_file_path"]).unlink(missing_ok=True)
180217

181218
self.set_task_result(
182219
task_id,
@@ -194,6 +231,8 @@ async def background_upload_task(
194231
logger.error(f"后台上传任务 {task_id} 失败: {exc}")
195232
logger.error(traceback.format_exc())
196233
self.set_task_result(task_id, "failed", error=str(exc))
234+
finally:
235+
self._cleanup_staging_dir(staging_dir)
197236

198237
async def background_import_task(
199238
self,
@@ -520,52 +559,62 @@ async def upload_document(
520559
file_list.extend(files.getlist(key))
521560
if not file_list:
522561
raise KnowledgeBaseServiceError("缺少文件")
523-
if len(file_list) > 10:
524-
raise KnowledgeBaseServiceError("最多只能上传10个文件")
525562

563+
task_id = str(uuid.uuid4())
564+
system_temp_root = Path(get_astrbot_system_tmp_path())
565+
system_temp_root.mkdir(mode=0o700, parents=True, exist_ok=True)
566+
staging_dir = system_temp_root / f"kb_upload_{task_id}"
567+
staging_dir.mkdir(mode=0o700)
526568
files_to_upload = []
527-
for file in file_list:
528-
file_name = Path(str(file.filename or "document").replace("\\", "/")).name
529-
if file_name in {"", ".", ".."}:
530-
file_name = "document"
531-
temp_file_path = (
532-
Path(get_astrbot_temp_path()) / f"kb_upload_{uuid.uuid4()}_{file_name}"
533-
)
534-
await file.save(temp_file_path)
535-
try:
536-
async with aiofiles.open(temp_file_path, "rb") as file_obj:
537-
file_content = await file_obj.read()
569+
try:
570+
for file in file_list:
571+
file_name = Path(
572+
str(file.filename or "document").replace("\\", "/")
573+
).name
574+
if file_name in {"", ".", ".."}:
575+
file_name = "document"
576+
temp_file_path = staging_dir / f"{uuid.uuid4()}_{file_name}"
538577
file_type = (
539578
file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
540579
)
541580
files_to_upload.append(
542581
{
543582
"file_name": file_name,
544-
"file_content": file_content,
583+
"temp_file_path": temp_file_path,
545584
"file_type": file_type,
546585
},
547586
)
548-
finally:
549-
temp_file_path.unlink(missing_ok=True)
587+
await file.save(temp_file_path)
588+
except Exception:
589+
self._cleanup_staging_dir(staging_dir)
590+
raise
550591

551-
kb_helper = await self.get_kb_manager().get_kb(kb_id)
552-
if not kb_helper:
553-
raise KnowledgeBaseServiceError("知识库不存在")
592+
try:
593+
kb_helper = await self.get_kb_manager().get_kb(kb_id)
594+
if not kb_helper:
595+
raise KnowledgeBaseServiceError("知识库不存在")
596+
except Exception:
597+
self._cleanup_staging_dir(staging_dir)
598+
raise
554599

555-
task_id = str(uuid.uuid4())
556-
self.init_task(task_id, status="pending")
557-
asyncio.create_task(
558-
self.background_upload_task(
559-
task_id=task_id,
560-
kb_helper=kb_helper,
561-
files_to_upload=files_to_upload,
562-
chunk_size=chunk_size,
563-
chunk_overlap=chunk_overlap,
564-
batch_size=batch_size,
565-
tasks_limit=tasks_limit,
566-
max_retries=max_retries,
567-
),
568-
)
600+
try:
601+
self.init_task(task_id, status="pending")
602+
asyncio.create_task(
603+
self.background_upload_task(
604+
task_id=task_id,
605+
kb_helper=kb_helper,
606+
files_to_upload=files_to_upload,
607+
staging_dir=staging_dir,
608+
chunk_size=chunk_size,
609+
chunk_overlap=chunk_overlap,
610+
batch_size=batch_size,
611+
tasks_limit=tasks_limit,
612+
max_retries=max_retries,
613+
),
614+
)
615+
except Exception:
616+
self._cleanup_staging_dir(staging_dir)
617+
raise
569618
return {
570619
"task_id": task_id,
571620
"file_count": len(files_to_upload),

dashboard/src/views/knowledge-base/components/DocumentsTab.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@
8686
<p class="mt-4 text-h6">{{ t('upload.dropzone') }}</p>
8787
<p class="text-caption text-medium-emphasis mt-2">{{ t('upload.supportedFormats') }}</p>
8888
<p class="text-caption text-medium-emphasis">{{ t('upload.maxSize') }}</p>
89-
<p class="text-caption text-medium-emphasis">最多可上传 10 个文件</p>
9089
<input ref="fileInput" type="file" multiple hidden accept=".txt,.md,.markdown,.rst,.adoc,.pdf,.docx,.epub,.xls,.xlsx"
9190
@change="handleFileSelect" />
9291
</div>
@@ -391,13 +390,8 @@ const handleFileSelect = (event: Event) => {
391390
target.value = ''
392391
}
393392
394-
// 添加文件(检查数量限制)
393+
// Add files
395394
const addFiles = (files: File[]) => {
396-
const totalFiles = selectedFiles.value.length + files.length
397-
if (totalFiles > 10) {
398-
showSnackbar('最多只能选择 10 个文件', 'warning')
399-
return
400-
}
401395
selectedFiles.value.push(...files)
402396
}
403397

tests/unit/test_knowledge_base_service_contract.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import asyncio
2+
from pathlib import Path
13
from types import SimpleNamespace
24
from unittest.mock import AsyncMock, MagicMock
35

@@ -8,6 +10,7 @@
810
from astrbot.dashboard.api.knowledge_bases import (
911
list_knowledge_bases,
1012
)
13+
from astrbot.dashboard.api.multipart import MultiDict
1114
from astrbot.dashboard.schemas import (
1215
KnowledgeBaseRequest,
1316
)
@@ -250,7 +253,9 @@ async def test_create_kb_raises_when_embedding_provider_is_missing():
250253
kb_manager = MagicMock()
251254
service = make_service(kb_manager)
252255

253-
with pytest.raises(KnowledgeBaseServiceError, match="缺少参数 embedding_provider_id"):
256+
with pytest.raises(
257+
KnowledgeBaseServiceError, match="缺少参数 embedding_provider_id"
258+
):
254259
await service.create_kb({"kb_name": "Test KB"})
255260

256261

@@ -264,3 +269,78 @@ async def test_create_kb_raises_when_embedding_provider_is_invalid():
264269
await service.create_kb(
265270
{"kb_name": "Test KB", "embedding_provider_id": "missing-provider"}
266271
)
272+
273+
274+
@pytest.mark.asyncio
275+
async def test_upload_document_accepts_more_than_ten_files_and_cleans_temporary_files(
276+
tmp_path, monkeypatch
277+
):
278+
"""Upload files without a count limit and remove their temporary copies.
279+
280+
Args:
281+
tmp_path: Temporary directory provided by pytest.
282+
monkeypatch: Pytest fixture used to isolate staging and task scheduling.
283+
"""
284+
kb_helper = SimpleNamespace(
285+
upload_document=AsyncMock(
286+
return_value=SimpleNamespace(model_dump=lambda: {"doc_id": "doc-1"})
287+
)
288+
)
289+
kb_manager = SimpleNamespace(get_kb=AsyncMock(return_value=kb_helper))
290+
service = make_service(kb_manager)
291+
uploads = []
292+
for index in range(11):
293+
content = f"content-{index}".encode()
294+
uploads.append(
295+
(
296+
f"file{index}",
297+
SimpleNamespace(
298+
filename=f"document-{index}.txt",
299+
save=AsyncMock(
300+
side_effect=lambda destination, content=content: Path(
301+
destination
302+
).write_bytes(content)
303+
),
304+
),
305+
)
306+
)
307+
308+
created_tasks = []
309+
create_task = asyncio.create_task
310+
311+
def capture_task(coroutine):
312+
"""Capture a scheduled background task for deterministic waiting.
313+
314+
Args:
315+
coroutine: Upload coroutine passed to ``asyncio.create_task``.
316+
317+
Returns:
318+
The scheduled asyncio task.
319+
"""
320+
task = create_task(coroutine)
321+
created_tasks.append(task)
322+
return task
323+
324+
monkeypatch.setattr(
325+
"astrbot.dashboard.services.knowledge_base_service.get_astrbot_system_tmp_path",
326+
lambda: tmp_path,
327+
)
328+
monkeypatch.setattr(
329+
"astrbot.dashboard.services.knowledge_base_service.asyncio.create_task",
330+
capture_task,
331+
)
332+
333+
result = await service.upload_document(
334+
content_type="multipart/form-data",
335+
form_data=MultiDict([("kb_id", "kb-1")]),
336+
files=MultiDict(uploads),
337+
)
338+
await created_tasks[0]
339+
340+
assert result["file_count"] == 11
341+
assert kb_helper.upload_document.await_count == 11
342+
assert [
343+
call.kwargs["file_content"]
344+
for call in kb_helper.upload_document.await_args_list
345+
] == [f"content-{index}".encode() for index in range(11)]
346+
assert not list(tmp_path.glob("kb_upload_*"))

0 commit comments

Comments
 (0)