fix(kb): support concurrent document upload progress - #9638
Conversation
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The new KnowledgeUploadProgress schema requires a file_total field, but the backend upload_progress objects never set this property, so the OpenAPI spec and runtime response shape are currently inconsistent.
- The searchQuery watcher now calls loadDocuments on every change without the previous debounce and requestId cancellation, which can cause excessive requests; consider reintroducing a small debounce and a simple cancellation mechanism to avoid race conditions and reduce load.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The new KnowledgeUploadProgress schema requires a file_total field, but the backend upload_progress objects never set this property, so the OpenAPI spec and runtime response shape are currently inconsistent.
- The searchQuery watcher now calls loadDocuments on every change without the previous debounce and requestId cancellation, which can cause excessive requests; consider reintroducing a small debounce and a simple cancellation mechanism to avoid race conditions and reduce load.
## Individual Comments
### Comment 1
<location path="dashboard/src/api/v1.ts" line_range="1496-1498" />
<code_context>
);
},
task(taskId: string) {
- return typed<any>(
+ return typed<KnowledgeUploadTask>(
openApiV1.getKnowledgeTask({ path: { task_id: taskId } }),
);
},
</code_context>
<issue_to_address>
**issue (bug_risk):** Type for `knowledgeApi.task` no longer matches the OpenAPI response envelope.
The OpenAPI spec for `GET /knowledge-bases/tasks/{task_id}` returns a `KnowledgeUploadTaskResponse` envelope (`status`, `message`, `data: KnowledgeUploadTask`). Typing this as `KnowledgeUploadTask` makes it look like `data` is the whole response and diverges from the spec and existing callers that expect the envelope (e.g. `response.data.status`). Please change this to the envelope type (e.g. `typed<KnowledgeUploadTaskResponse>`) and ensure `typed<T>` is consistently used for envelope vs payload types.
</issue_to_address>
### Comment 2
<location path="dashboard/src/api/v1.ts" line_range="1501-1503" />
<code_context>
openApiV1.getKnowledgeTask({ path: { task_id: taskId } }),
);
},
+ tasks(kbId: string) {
+ return typed<KnowledgeUploadTaskList>(
+ openApiV1.listKnowledgeTasks({ path: { kb_id: kbId } }),
+ );
+ },
</code_context>
<issue_to_address>
**issue (bug_risk):** Type for `knowledgeApi.tasks` is declared as the payload, but usage expects an envelope.
The function currently returns `typed<KnowledgeUploadTaskList>`, but `DocumentsTab.vue` accesses `response.data.status` and `response.data.data.items`, which assumes an envelope type (e.g. `KnowledgeUploadTaskListResponse`). This mismatch between the generic type and actual usage can cause TypeScript mis-typing and incorrect expectations of the runtime shape. Please either type `knowledgeApi.tasks` as the envelope or update callers to treat the result as the bare list rather than `status/data`.
</issue_to_address>
### Comment 3
<location path="astrbot/dashboard/services/knowledge_base_service.py" line_range="50" />
<code_context>
- "result": None,
- "error": None,
- }
+ def init_task(
+ self,
+ task_id: str,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting small helper functions for file metadata construction, progress updates, and per-file lifecycle handling to keep the new upload task features while reducing duplication and complexity.
You can keep all the new functionality while reducing complexity by introducing a few small, focused helpers and reusing them.
### 1. Deduplicate file dictionary construction in `init_task`
The two branches building `files` are identical. Extract a helper so the structure is defined once:
```python
def _build_file_infos(self, file_names: list[str] | None) -> list[dict[str, Any]]:
return [
{
"file_index": index,
"file_name": file_name,
"status": "pending",
"stage": "waiting",
"current": 0,
"total": 100,
"error": None,
"document": None,
}
for index, file_name in enumerate(file_names or [])
]
```
Then in `init_task`:
```python
if task is None:
task = {
"status": status,
"kb_id": kb_id,
"task_type": task_type,
"created_at": now,
"updated_at": now,
"files": self._build_file_infos(file_names),
"result": None,
"error": None,
}
self.upload_tasks[task_id] = task
return
# ...
if file_names is not None and not task.get("files"):
task["files"] = self._build_file_infos(file_names)
```
This keeps behavior identical but makes the file shape explicit and reusable.
### 2. Separate aggregate vs per-file progress updates
`update_progress` is now doing two different things. You can keep its public API but factor the internals into two small helpers:
```python
def _update_aggregate_progress(
self,
progress: dict[str, Any],
*,
status: str | None,
file_index: int | None,
file_name: str | None,
stage: str | None,
current: int | None,
total: int | None,
) -> None:
if status is not None:
progress["status"] = status
if file_index is not None:
progress["file_index"] = file_index
if file_name is not None:
progress["file_name"] = file_name
if stage is not None:
progress["stage"] = stage
if current is not None:
progress["current"] = current
if total is not None:
progress["total"] = total
def _update_file_progress(
self,
task: dict[str, Any],
*,
file_index: int | None,
file_name: str | None,
stage: str | None,
current: int | None,
total: int | None,
file_status: str | None,
error: str | None,
document: dict[str, Any] | None,
) -> None:
if file_index is None:
return
files = task.get("files", [])
if file_index < 0 or file_index >= len(files):
return
file_info = files[file_index]
if file_name is not None:
file_info["file_name"] = file_name
if stage is not None:
file_info["stage"] = stage
if current is not None:
file_info["current"] = current
if total is not None:
file_info["total"] = total
if file_status is not None:
file_info["status"] = file_status
if error is not None:
file_info["error"] = error
if document is not None:
file_info["document"] = document
```
Then `update_progress` becomes a thin coordinator:
```python
def update_progress(...):
progress = self.upload_progress.get(task_id)
if progress is not None:
self._update_aggregate_progress(
progress,
status=status,
file_index=file_index,
file_name=file_name,
stage=stage,
current=current,
total=total,
)
task = self.upload_tasks.get(task_id)
if task is None:
return
task["updated_at"] = time.time()
self._update_file_progress(
task,
file_index=file_index,
file_name=file_name,
stage=stage,
current=current,
total=total,
file_status=file_status,
error=error,
document=document,
)
```
This makes the logic easier to understand and test without changing callers.
### 3. Extract per-file lifecycle helpers for background tasks
The three background methods repeat the same patterns for “start file”, “complete file”, “fail file”. Small wrappers around `update_progress` will reduce duplication:
```python
def _start_file(
self,
task_id: str,
file_index: int,
file_name: str,
stage: str,
) -> None:
self.update_progress(
task_id,
status="processing",
file_index=file_index,
file_name=file_name,
stage=stage,
current=0,
total=100,
file_status="processing",
)
def _complete_file(
self,
task_id: str,
file_index: int,
document: dict[str, Any],
) -> None:
self.update_progress(
task_id,
file_index=file_index,
stage="completed",
current=100,
total=100,
file_status="completed",
document=document,
)
def _fail_file(
self,
task_id: str,
file_index: int,
error: str,
stage: str = "failed",
) -> None:
self.update_progress(
task_id,
file_index=file_index,
stage=stage,
file_status="failed",
error=error,
)
```
Example usage in `background_upload_task`:
```python
for file_idx, file_info in enumerate(files_to_upload):
try:
self._start_file(
task_id,
file_idx,
file_info["file_name"],
stage="parsing",
)
progress_callback = self.make_progress_callback(
task_id, file_idx, file_info["file_name"]
)
doc = await kb_helper.upload_document(...)
uploaded_doc = doc.model_dump()
uploaded_docs.append(uploaded_doc)
self._complete_file(task_id, file_idx, uploaded_doc)
except Exception as exc:
failed_error = self.format_failed_doc_error(file_info["file_name"], exc)
failed_docs.append({"file_name": file_info["file_name"], "error": failed_error})
self._fail_file(task_id, file_idx, failed_error)
```
Apply the same pattern in `background_import_task` and `background_upload_from_url_task` to centralize file progress behavior.
### 4. Isolate failure propagation in `set_task_result`
The “mark all pending/processing files as failed” behavior is important but currently buried in `set_task_result`. You can move it into a focused helper and keep `set_task_result` simpler:
```python
def _propagate_task_failure_to_files(
self,
task: dict[str, Any],
error: str | None,
) -> None:
for file_info in task.get("files", []):
if file_info["status"] in {"pending", "processing"}:
file_info["status"] = "failed"
file_info["stage"] = "failed"
file_info["error"] = error
```
Then:
```python
def set_task_result(...):
if task_id not in self.upload_tasks:
self.init_task(task_id, status=status)
task = self.upload_tasks[task_id]
task["status"] = status
task["result"] = result
task["error"] = error
task["updated_at"] = time.time()
if status == "failed":
self._propagate_task_failure_to_files(task, error)
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = status
```
This keeps behavior identical but clearly separates “task result” and “file failure propagation”, making future changes safer.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| task(taskId: string) { | ||
| return typed<any>( | ||
| return typed<KnowledgeUploadTask>( | ||
| openApiV1.getKnowledgeTask({ path: { task_id: taskId } }), |
There was a problem hiding this comment.
issue (bug_risk): Type for knowledgeApi.task no longer matches the OpenAPI response envelope.
The OpenAPI spec for GET /knowledge-bases/tasks/{task_id} returns a KnowledgeUploadTaskResponse envelope (status, message, data: KnowledgeUploadTask). Typing this as KnowledgeUploadTask makes it look like data is the whole response and diverges from the spec and existing callers that expect the envelope (e.g. response.data.status). Please change this to the envelope type (e.g. typed<KnowledgeUploadTaskResponse>) and ensure typed<T> is consistently used for envelope vs payload types.
| tasks(kbId: string) { | ||
| return typed<KnowledgeUploadTaskList>( | ||
| openApiV1.listKnowledgeTasks({ path: { kb_id: kbId } }), |
There was a problem hiding this comment.
issue (bug_risk): Type for knowledgeApi.tasks is declared as the payload, but usage expects an envelope.
The function currently returns typed<KnowledgeUploadTaskList>, but DocumentsTab.vue accesses response.data.status and response.data.data.items, which assumes an envelope type (e.g. KnowledgeUploadTaskListResponse). This mismatch between the generic type and actual usage can cause TypeScript mis-typing and incorrect expectations of the runtime shape. Please either type knowledgeApi.tasks as the envelope or update callers to treat the result as the bare list rather than status/data.
| "result": None, | ||
| "error": None, | ||
| } | ||
| def init_task( |
There was a problem hiding this comment.
issue (complexity): Consider extracting small helper functions for file metadata construction, progress updates, and per-file lifecycle handling to keep the new upload task features while reducing duplication and complexity.
You can keep all the new functionality while reducing complexity by introducing a few small, focused helpers and reusing them.
1. Deduplicate file dictionary construction in init_task
The two branches building files are identical. Extract a helper so the structure is defined once:
def _build_file_infos(self, file_names: list[str] | None) -> list[dict[str, Any]]:
return [
{
"file_index": index,
"file_name": file_name,
"status": "pending",
"stage": "waiting",
"current": 0,
"total": 100,
"error": None,
"document": None,
}
for index, file_name in enumerate(file_names or [])
]Then in init_task:
if task is None:
task = {
"status": status,
"kb_id": kb_id,
"task_type": task_type,
"created_at": now,
"updated_at": now,
"files": self._build_file_infos(file_names),
"result": None,
"error": None,
}
self.upload_tasks[task_id] = task
return
# ...
if file_names is not None and not task.get("files"):
task["files"] = self._build_file_infos(file_names)This keeps behavior identical but makes the file shape explicit and reusable.
2. Separate aggregate vs per-file progress updates
update_progress is now doing two different things. You can keep its public API but factor the internals into two small helpers:
def _update_aggregate_progress(
self,
progress: dict[str, Any],
*,
status: str | None,
file_index: int | None,
file_name: str | None,
stage: str | None,
current: int | None,
total: int | None,
) -> None:
if status is not None:
progress["status"] = status
if file_index is not None:
progress["file_index"] = file_index
if file_name is not None:
progress["file_name"] = file_name
if stage is not None:
progress["stage"] = stage
if current is not None:
progress["current"] = current
if total is not None:
progress["total"] = total
def _update_file_progress(
self,
task: dict[str, Any],
*,
file_index: int | None,
file_name: str | None,
stage: str | None,
current: int | None,
total: int | None,
file_status: str | None,
error: str | None,
document: dict[str, Any] | None,
) -> None:
if file_index is None:
return
files = task.get("files", [])
if file_index < 0 or file_index >= len(files):
return
file_info = files[file_index]
if file_name is not None:
file_info["file_name"] = file_name
if stage is not None:
file_info["stage"] = stage
if current is not None:
file_info["current"] = current
if total is not None:
file_info["total"] = total
if file_status is not None:
file_info["status"] = file_status
if error is not None:
file_info["error"] = error
if document is not None:
file_info["document"] = documentThen update_progress becomes a thin coordinator:
def update_progress(...):
progress = self.upload_progress.get(task_id)
if progress is not None:
self._update_aggregate_progress(
progress,
status=status,
file_index=file_index,
file_name=file_name,
stage=stage,
current=current,
total=total,
)
task = self.upload_tasks.get(task_id)
if task is None:
return
task["updated_at"] = time.time()
self._update_file_progress(
task,
file_index=file_index,
file_name=file_name,
stage=stage,
current=current,
total=total,
file_status=file_status,
error=error,
document=document,
)This makes the logic easier to understand and test without changing callers.
3. Extract per-file lifecycle helpers for background tasks
The three background methods repeat the same patterns for “start file”, “complete file”, “fail file”. Small wrappers around update_progress will reduce duplication:
def _start_file(
self,
task_id: str,
file_index: int,
file_name: str,
stage: str,
) -> None:
self.update_progress(
task_id,
status="processing",
file_index=file_index,
file_name=file_name,
stage=stage,
current=0,
total=100,
file_status="processing",
)
def _complete_file(
self,
task_id: str,
file_index: int,
document: dict[str, Any],
) -> None:
self.update_progress(
task_id,
file_index=file_index,
stage="completed",
current=100,
total=100,
file_status="completed",
document=document,
)
def _fail_file(
self,
task_id: str,
file_index: int,
error: str,
stage: str = "failed",
) -> None:
self.update_progress(
task_id,
file_index=file_index,
stage=stage,
file_status="failed",
error=error,
)Example usage in background_upload_task:
for file_idx, file_info in enumerate(files_to_upload):
try:
self._start_file(
task_id,
file_idx,
file_info["file_name"],
stage="parsing",
)
progress_callback = self.make_progress_callback(
task_id, file_idx, file_info["file_name"]
)
doc = await kb_helper.upload_document(...)
uploaded_doc = doc.model_dump()
uploaded_docs.append(uploaded_doc)
self._complete_file(task_id, file_idx, uploaded_doc)
except Exception as exc:
failed_error = self.format_failed_doc_error(file_info["file_name"], exc)
failed_docs.append({"file_name": file_info["file_name"], "error": failed_error})
self._fail_file(task_id, file_idx, failed_error)Apply the same pattern in background_import_task and background_upload_from_url_task to centralize file progress behavior.
4. Isolate failure propagation in set_task_result
The “mark all pending/processing files as failed” behavior is important but currently buried in set_task_result. You can move it into a focused helper and keep set_task_result simpler:
def _propagate_task_failure_to_files(
self,
task: dict[str, Any],
error: str | None,
) -> None:
for file_info in task.get("files", []):
if file_info["status"] in {"pending", "processing"}:
file_info["status"] = "failed"
file_info["stage"] = "failed"
file_info["error"] = errorThen:
def set_task_result(...):
if task_id not in self.upload_tasks:
self.init_task(task_id, status=status)
task = self.upload_tasks[task_id]
task["status"] = status
task["result"] = result
task["error"] = error
task["updated_at"] = time.time()
if status == "failed":
self._propagate_task_failure_to_files(task, error)
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = statusThis keeps behavior identical but clearly separates “task result” and “file failure propagation”, making future changes safer.
Modifications / 改动点
Backend / 后端
Frontend / 前端
主要修改文件:
dashboard/src/views/knowledge-base/components/DocumentsTab.vue- 前端进度显示dashboard/src/api/generated/openapi-v1.ts- API 类型定义改动统计: +801/-156 行
Screenshots or Test Results / 运行截图或测试结果
测试场景:
Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了验证步骤和运行截图。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。