Skip to content

fix(kb): support concurrent document upload progress - #9638

Open
lxfight wants to merge 2 commits into
AstrBotDevs:masterfrom
lxfight:fix/kb-concurrent-upload-progress
Open

fix(kb): support concurrent document upload progress#9638
lxfight wants to merge 2 commits into
AstrBotDevs:masterfrom
lxfight:fix/kb-concurrent-upload-progress

Conversation

@lxfight

@lxfight lxfight commented Aug 12, 2026

Copy link
Copy Markdown
Member

Modifications / 改动点

Backend / 后端

  • 添加了并发上传任务的进度追踪
  • 通过 API 暴露实时上传状态
  • 支持多文件并发上传的状态管理

Frontend / 前端

  • 在文档列表中显示多个文件的同步上传进度
  • 显示各阶段的进度信息(等待、上传、处理、向量化、完成、失败)
  • 添加进度条和状态图标
  • 上传中的文件禁用操作按钮

主要修改文件:

  • dashboard/src/views/knowledge-base/components/DocumentsTab.vue - 前端进度显示
  • dashboard/src/api/generated/openapi-v1.ts - API 类型定义

改动统计: +801/-156 行

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

测试场景:

  • ✅ 单文件上传进度显示
  • ✅ 多文件并发上传进度追踪
  • ✅ 上传失败场景处理
  • ✅ 各阶段状态正确切换(waiting → uploading → processing → embedding → completed/failed)

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.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:knowledge-base The bug / feature is about knowledge base labels Aug 12, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread dashboard/src/api/v1.ts
Comment on lines 1496 to 1498
task(taskId: string) {
return typed<any>(
return typed<KnowledgeUploadTask>(
openApiV1.getKnowledgeTask({ path: { task_id: taskId } }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dashboard/src/api/v1.ts
Comment on lines +1501 to +1503
tasks(kbId: string) {
return typed<KnowledgeUploadTaskList>(
openApiV1.listKnowledgeTasks({ path: { kb_id: kbId } }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"] = document

Then 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"] = error

Then:

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:knowledge-base The bug / feature is about knowledge base size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant