From 44d6175559ee4d3c9bb5eac344cb2f7daa1b08ac Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 28 May 2026 18:26:56 +0800 Subject: [PATCH] feat: add knowledge parse cancellation with finalizing post-process state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users stop an in-flight document parse to free up LLM / worker resources without losing the chunks and index already written. The core insight is that the previous parse_status=completed flipped as soon as primary chunks landed, while the most expensive subtasks (graph extract = N LLM calls per chunk, plus summary, question generation) were still running in the background — so "completed" wasn't actually terminal from a resource standpoint. State machine pending -> processing -> finalizing -> completed | +-> cancelled (any of the three in-flight states) +-> failed +-> deleting `finalizing` is the new post-process fan-out window. parse_status only promotes to `completed` once pending_subtasks_count (a new column tracking summary + question + per-chunk graph extract) drains to zero via atomic FinalizeSubtask. Wiki ingest is intentionally excluded from the counter — it's a KB-scoped debounced batch and would otherwise pin parse_status in `finalizing` for the wiki batch window. Backend - New ParseStatusFinalizing + pending_subtasks_count column with migration 000056. - knowledgeRepository.SetFinalizing transitions processing -> finalizing conditionally so a racing cancel cannot be clobbered. - knowledgeRepository.FinalizeSubtask atomically decrements the counter and self-promotes the row to completed when it hits zero. - KnowledgePostProcess restructured to compute expected subtask count up front, flip to finalizing (or completed when no enrichment is enabled), and only then fan out subtasks. Subtask handlers (summary, question, graph extract) defer-decrement on terminal exit using the existing isFinalAsynqAttempt convention. - New POST /api/v1/knowledge/{id}/cancel-parse handler accepting pending / processing / finalizing. Marks the row cancelled, zeroes the counter, best-effort dequeues asynq tasks via a new TaskInspector abstraction (asynq-mode walks pending/scheduled/ retry queues; Lite-mode noop), and scrubs wiki ingest pending op. - SpanTracker.AbortAttempt flat-sweeps every still-running span for the attempt via a new repo.CancelAllOpenSpans helper so the trace viewer's striped bars all flip to cancelled, even leaf generations whose parent stage already EndSpan'd (multimodal fan-out pattern). knowledge_post_process closes its postSpan via SkipSpan on the cancel/deleting entry guard so a worker that opens a span AFTER the cancel sweep doesn't leak it. - Housekeeping and resetPendingTasks sweep finalizing rows identically to processing so a crash/restart can't strand them. - DeleteKnowledge/DeleteKnowledgeList proactively dequeue downstream tasks via the same TaskInspector path. - ChunkExtractService gets a cancel entry guard so the most expensive enrichment (graph extract) bails immediately when the parent knowledge is aborted. Frontend - New cancelKnowledgeParse API client + "Stop parsing" entry in both list view and card view more menus, gated on pending/processing/finalizing. - Polling predicate refactored to a shared isParseInFlight helper that recognises `finalizing` (previously the doc list silently stopped polling once parse_status flipped from processing). - Knowledge processing timeline: isPolling includes finalizing, new isHardTerminal short-circuits LIVE for cancelled/failed/ completed so stranded child spans cannot pin LIVE on. - DocumentListView.computeStatus distinguishes finalizing ("增强中") from completed and shows the previous "生成摘要中" copy when summary_status is still pending under finalizing. Added cancelled badge as well. - i18n: statusFinalizing / statusCancelled / cancelParse* keys across zh-CN, en-US, ko-KR, ru-RU. Docs / SDK - docs/api/knowledge.md: documents the new finalizing state, cancel-parse semantics, and which statuses accept cancel. - client (Go SDK): CancelKnowledgeParse with docstring listing the cancellable statuses. --- client/README.md | 14 + client/knowledge.go | 50 ++++ docs/api/knowledge.md | 48 +++- frontend/src/api/knowledge-base/index.ts | 4 + .../knowledge-processing-timeline.vue | 19 +- frontend/src/i18n/locales/en-US.ts | 6 + frontend/src/i18n/locales/ko-KR.ts | 6 + frontend/src/i18n/locales/ru-RU.ts | 8 +- frontend/src/i18n/locales/zh-CN.ts | 6 + .../src/views/knowledge/KnowledgeBase.vue | 89 ++++-- .../knowledge/components/DocumentListView.vue | 37 ++- internal/application/repository/knowledge.go | 97 +++++++ .../repository/knowledge_span_repo.go | 39 +++ internal/application/service/extract.go | 28 ++ .../application/service/image_multimodal.go | 12 + internal/application/service/knowledge.go | 30 +++ .../application/service/knowledge_delete.go | 27 +- .../service/knowledge_housekeeping.go | 17 +- .../service/knowledge_housekeeping_test.go | 1 + .../service/knowledge_post_process.go | 137 ++++++++-- .../application/service/knowledge_process.go | 240 +++++++++++++++-- .../service/knowledge_span_tracker.go | 46 ++++ internal/application/service/wiki_ingest.go | 6 +- internal/container/container.go | 23 +- internal/handler/knowledge.go | 53 ++++ internal/router/router.go | 1 + internal/router/task_inspector.go | 254 ++++++++++++++++++ internal/types/interfaces/knowledge.go | 19 ++ internal/types/interfaces/task_inspector.go | 26 ++ internal/types/knowledge.go | 23 +- ...000056_knowledge_pending_subtasks.down.sql | 1 + .../000056_knowledge_pending_subtasks.up.sql | 31 +++ 32 files changed, 1312 insertions(+), 86 deletions(-) create mode 100644 internal/router/task_inspector.go create mode 100644 internal/types/interfaces/task_inspector.go create mode 100644 migrations/versioned/000056_knowledge_pending_subtasks.down.sql create mode 100644 migrations/versioned/000056_knowledge_pending_subtasks.up.sql diff --git a/client/README.md b/client/README.md index a264a4b5..dd6c8faa 100644 --- a/client/README.md +++ b/client/README.md @@ -339,6 +339,20 @@ for { } ``` +### 示例:取消解析 + +```go +// 取消正在进行的解析任务(资源紧张 / 上传错误文件时使用) +// - 已经 completed / failed 的知识不能取消 +// - 已写入的分块/索引会保留,可后续调用 ReparseKnowledge 重新解析 + +knowledge, err := apiClient.CancelKnowledgeParse(context.Background(), knowledgeID) +if err != nil { + // 处理错误 +} +fmt.Printf("Parse Status: %s\n", knowledge.ParseStatus) // "cancelled" +``` + ### 示例:获取会话消息 ```go diff --git a/client/knowledge.go b/client/knowledge.go index 10119abc..cbecf54d 100644 --- a/client/knowledge.go +++ b/client/knowledge.go @@ -489,6 +489,56 @@ func (c *Client) ReparseKnowledge(ctx context.Context, knowledgeID string) (*Kno return &response.Data, nil } +// CancelKnowledgeParse cancels an in-progress knowledge parse. +// The server marks the knowledge as cancelled and best-effort dequeues +// any pending downstream tasks (multimodal, post-process, summary, +// question generation, graph extract) for the same knowledge ID. Any +// chunks/index already written are preserved; the user can re-trigger +// parsing later via ReparseKnowledge. +// +// Cancellable parse_status values: +// - pending — task has not started +// - processing — DocReader / chunking / embedding stage +// - finalizing — primary parse done, enrichment subtasks (summary, +// question generation, graph extract) still running +// +// Returns an error when the knowledge is in a terminal state +// (completed, failed) or already being deleted. +// +// Parameters: +// - ctx: Context for the request +// - knowledgeID: The ID of the knowledge entry whose parse should be cancelled +// +// Returns: +// - *Knowledge: The updated knowledge entry with status set to "cancelled" +// - error: Error information if the request fails +// +// Example: +// +// knowledge, err := client.CancelKnowledgeParse(ctx, "knowledge-id-123") +// if err != nil { +// log.Fatalf("Failed to cancel parse: %v", err) +// } +// fmt.Printf("Knowledge parse cancelled, status: %s\n", knowledge.ParseStatus) +func (c *Client) CancelKnowledgeParse(ctx context.Context, knowledgeID string) (*Knowledge, error) { + if knowledgeID == "" { + return nil, fmt.Errorf("knowledge ID cannot be empty") + } + + path := fmt.Sprintf("/api/v1/knowledge/%s/cancel-parse", knowledgeID) + resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil) + if err != nil { + return nil, err + } + + var response KnowledgeResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + + return &response.Data, nil +} + // UpdateChunk updates a chunk's information // Updates information for a specific chunk under a knowledge document // Parameters: diff --git a/docs/api/knowledge.md b/docs/api/knowledge.md index 48b071df..dcc7014d 100644 --- a/docs/api/knowledge.md +++ b/docs/api/knowledge.md @@ -17,6 +17,7 @@ | DELETE | `/knowledge/:id` | 删除单条知识 | | PUT | `/knowledge/manual/:id` | 更新手工 Markdown 知识 | | POST | `/knowledge/:id/reparse` | 重新解析知识(异步) | +| POST | `/knowledge/:id/cancel-parse` | 取消正在进行的解析任务 | | GET | `/knowledge/:id/download` | 下载原始文件(attachment) | | GET | `/knowledge/:id/preview` | 内联预览文件(按扩展名设置 Content-Type) | | PUT | `/knowledge/image/:id/:chunk_id` | 更新分块图像信息 | @@ -28,8 +29,10 @@ > **公共说明**: > - 路径中的 `:id`(知识库路径下)为**知识库 ID**,`/knowledge/:id` 中的 `:id` 为**知识 ID**。 -> - 所有写操作(创建、更新、删除、迁移、重新解析)需要当前用户在知识库所属组织内具有 `editor` 或 `admin` 权限;清空知识库内容仅 KB **所有者**(admin 且租户匹配)可操作。 -> - 关键状态字段:`parse_status` 取值 `pending` / `processing` / `completed` / `failed`;`enable_status` 取值 `enabled` / `disabled`。 +> - 所有写操作(创建、更新、删除、迁移、重新解析、取消解析)需要当前用户在知识库所属组织内具有 `editor` 或 `admin` 权限;清空知识库内容仅 KB **所有者**(admin 且租户匹配)可操作。 +> - 关键状态字段:`parse_status` 取值 `pending` / `processing` / `finalizing` / `completed` / `failed` / `cancelled`;`enable_status` 取值 `enabled` / `disabled`。 +> - `processing` 指 DocReader / 分块 / 向量化阶段;`finalizing` 指主解析已完成、但摘要 / 问题生成 / 图谱抽取等异步增强子任务仍在执行;只有当全部子任务到达终态后才进入 `completed`。 +> - `cancelled` 表示解析被用户主动取消,可通过 `reparse` 重新触发。`pending` / `processing` / `finalizing` 这三种状态都可通过 `cancel-parse` 终止。 ## POST `/knowledge-bases/:id/knowledge/file` - 上传文件创建知识 @@ -562,6 +565,47 @@ curl --location --request POST 'http://localhost:8080/api/v1/knowledge/4c4e7c1a- 调用后 `parse_status` 会先变为 `pending`,再由后台 worker 转为 `processing` → `completed`/`failed`。 +## POST `/knowledge/:id/cancel-parse` - 取消解析 + +中止正在进行的解析任务,常用于资源紧张时主动放弃当前文档的解析过程。 + +**行为**: + +- 将 `parse_status` 置为 `cancelled`,`error_message` 写入「用户已取消解析」,并把 `pending_subtasks_count` 清零。 +- 已写入数据库的分块 / 索引保留,可通过 `reparse` 接口在同一记录上重新触发解析。 +- 后台异步会 best-effort 从队列中删除该知识对应的下游任务(多模态、问题生成、摘要、图谱抽取、Post-Process 等),并对正在执行的 worker 发出停止信号;worker 在下一个检查点退出。 +- **可取消的状态**:`pending` / `processing` / `finalizing`。`finalizing` 表示主解析已完成、但摘要 / 问题生成 / 图谱抽取等增强子任务仍在执行;在该状态取消可以及时止血最昂贵的增强阶段(图谱抽取按 chunk 调 LLM,开销最大)。 +- 已经完成 (`completed`) 或失败 (`failed`) 的知识不允许取消;正在删除 (`deleting`) 的知识不允许取消。 +- 接口幂等:对已经 `cancelled` 的记录重复调用直接返回当前状态。 + +**请求**: + +```curl +curl --location --request POST 'http://localhost:8080/api/v1/knowledge/4c4e7c1a-09cf-485b-a7b5-24b8cdc5acf5/cancel-parse' \ +--header 'X-API-Key: sk-xxxxx' +``` + +**响应**: + +```json +{ + "success": true, + "message": "Knowledge parse cancelled", + "data": { + "id": "4c4e7c1a-09cf-485b-a7b5-24b8cdc5acf5", + "tenant_id": 1, + "knowledge_base_id": "kb-00000001", + "type": "file", + "title": "彗星.txt", + "parse_status": "cancelled", + "error_message": "用户已取消解析", + "enable_status": "disabled", + "created_at": "2025-08-12T11:52:36.168632+08:00", + "updated_at": "2025-08-12T13:05:00.000000+08:00" + } +} +``` + ## GET `/knowledge/:id/download` - 下载原始文件 以 `attachment` 方式下载知识对应的原始文件。 diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 16da9165..a1d555d3 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -223,6 +223,10 @@ export function reparseKnowledge(id: string) { return post(`/api/v1/knowledge/${id}/reparse`); } +export function cancelKnowledgeParse(id: string) { + return post(`/api/v1/knowledge/${id}/cancel-parse`); +} + export function getKnowledgeSpans(id: string, attempt?: number) { const qs = attempt ? `?attempt=${attempt}` : ''; return get(`/api/v1/knowledge/${id}/spans${qs}`); diff --git a/frontend/src/components/knowledge-processing-timeline.vue b/frontend/src/components/knowledge-processing-timeline.vue index 667acec1..781f549f 100644 --- a/frontend/src/components/knowledge-processing-timeline.vue +++ b/frontend/src/components/knowledge-processing-timeline.vue @@ -194,7 +194,18 @@ function formatRelativeTime(ts: number): string { } function isPolling(status?: string): boolean { - return status === 'pending' || status === 'processing' + // finalizing is the post-process fan-out window — subspans + // (summary / question / graph.chunk[*]) are still actively producing + // events, so we must keep polling and drawing LIVE. + return status === 'pending' || status === 'processing' || status === 'finalizing' +} + +// Hard-terminal statuses override traceActive: even if child spans were +// left in 'running' state (e.g. a cancel raced ahead of a worker's +// FailSpan), the parse pipeline is definitively done for this attempt +// and we should stop polling instead of refreshing forever. +function isHardTerminal(status?: string): boolean { + return status === 'cancelled' || status === 'failed' || status === 'completed' } // True if any node in the trace is still running/pending. Crucial for @@ -224,8 +235,14 @@ const traceActive = computed(() => spanTreeActive(data.value?.trace)) // parseStatus hint so the UI shows LIVE immediately. const isLive = computed(() => { if (data.value) { + // Hard-terminal parse_status wins over a stale traceActive: cancel + // and irrecoverable failure can leave child spans stranded as + // 'running' (worker process died, cancel raced FailSpan, etc.), + // and we must NOT keep polling forever on those. + if (isHardTerminal(data.value.parse_status)) return false return isPolling(data.value.parse_status) || traceActive.value } + if (isHardTerminal(props.parseStatus)) return false return isPolling(props.parseStatus) }) diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 0712483c..95c8a867 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -268,6 +268,10 @@ export default { rebuildSubmitted: 'Rebuild task submitted', rebuildFailed: 'Rebuild failed. Please try again later', rebuildInProgress: 'This document is currently being parsed. Please try again later', + cancelParse: 'Stop parsing', + cancelParseConfirmBody: 'Stop parsing "{title}"? Already-written chunks are kept and can be re-parsed later via "Rebuild"; pending enrichment subtasks (summary / Q&A / knowledge graph) will be dropped immediately.', + cancelParseSubmitted: 'Parsing stopped', + cancelParseFailed: 'Failed to stop, please try again later', draft: 'Draft', draftTip: 'Temporarily saved and not included in retrieval', untitledDocument: 'Untitled Document', @@ -317,8 +321,10 @@ export default { batchDeleteFailed: 'Batch delete failed', statusCompleted: 'Completed', statusProcessing: 'Processing', + statusFinalizing: 'Finalizing', statusPending: 'Pending', statusFailed: 'Failed', + statusCancelled: 'Cancelled', statusDraft: 'Draft', selectKnowledgeBaseFirst: 'Please select a knowledge base first', sessionCreationFailed: 'Failed to create chat session', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 9d33ed8d..ac78e2d3 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -209,6 +209,10 @@ export default { rebuildSubmitted: "재구축 작업이 제출되었습니다", rebuildFailed: "재구축 실패, 나중에 다시 시도해주세요", rebuildInProgress: "현재 문서가 파싱 중입니다. 나중에 다시 시도해주세요", + cancelParse: "파싱 중지", + cancelParseConfirmBody: '"{title}"의 파싱을 중지하시겠습니까? 이미 저장된 청크는 유지되며 "다시 빌드"를 통해 다시 파싱할 수 있습니다. 보강 단계(요약 / Q&A / 지식 그래프)의 대기 중인 하위 작업은 즉시 취소됩니다.', + cancelParseSubmitted: "파싱이 중지되었습니다", + cancelParseFailed: "중지에 실패했습니다. 나중에 다시 시도해주세요", characters: "자", segment: "조각", chunkCount: "총 {count}개 조각", @@ -321,8 +325,10 @@ export default { batchDeleteFailed: "일괄 삭제 실패", statusCompleted: "완료", statusProcessing: "처리 중", + statusFinalizing: "마무리 중", statusPending: "대기 중", statusFailed: "실패", + statusCancelled: "취소됨", statusDraft: "초안", selectKnowledgeBaseFirst: "먼저 지식베이스를 선택하세요", sessionCreationFailed: "세션 생성 실패", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index f285b03d..b98c1e18 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -247,8 +247,10 @@ export default { batchDeleteFailed: 'Ошибка пакетного удаления', statusCompleted: 'Завершено', statusProcessing: 'Обработка', + statusFinalizing: 'Завершение', statusPending: 'Ожидание', statusFailed: 'Ошибка', + statusCancelled: 'Отменено', statusDraft: 'Черновик', selectKnowledgeBaseFirst: 'Пожалуйста, сначала выберите базу знаний', sessionCreationFailed: 'Не удалось создать диалог', @@ -356,7 +358,11 @@ export default { rebuildConfirm: 'Подтвердить пересборку документа "{fileName}"? Существующие фрагменты будут удалены и документ будет повторно проанализирован.', rebuildSubmitted: 'Задача пересборки отправлена', rebuildFailed: 'Ошибка пересборки, попробуйте позже', - rebuildInProgress: 'Документ сейчас анализируется, попробуйте позже' + rebuildInProgress: 'Документ сейчас анализируется, попробуйте позже', + cancelParse: 'Остановить разбор', + cancelParseConfirmBody: 'Остановить разбор «{title}»? Уже записанные фрагменты сохранятся, и их можно будет разобрать заново через «Пересобрать». Ожидающие подзадачи обогащения (резюме / вопросы и ответы / граф знаний) будут немедленно отменены.', + cancelParseSubmitted: 'Разбор остановлен', + cancelParseFailed: 'Не удалось остановить, попробуйте позже' }, knowledgeStages: { title: 'Конвейер обработки', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ae181c52..4e267f64 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -269,6 +269,10 @@ export default { rebuildSubmitted: "重建任务已提交", rebuildFailed: "重建失败,请稍后再试", rebuildInProgress: "当前文档正在解析中,请稍后重试", + cancelParse: "停止解析", + cancelParseConfirmBody: '确认停止解析"{title}"?已写入的分块会保留,可稍后通过"重建"重新触发;增强阶段(摘要 / 问答 / 知识图谱)的待执行子任务会被立即丢弃。', + cancelParseSubmitted: "已停止解析", + cancelParseFailed: "停止失败,请稍后再试", draft: "草稿", draftTip: "暂存内容,未参与检索", untitledDocument: "未命名文档", @@ -318,8 +322,10 @@ export default { batchDeleteFailed: "批量删除失败", statusCompleted: "已完成", statusProcessing: "解析中", + statusFinalizing: "增强中", statusPending: "等待中", statusFailed: "失败", + statusCancelled: "已取消", statusDraft: "草稿", selectKnowledgeBaseFirst: "请先选择知识库", sessionCreationFailed: "创建会话失败", diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 86630a53..5ed347a8 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -31,6 +31,7 @@ import { createKnowledgeFromURL, listKnowledgeBases, reparseKnowledge, + cancelKnowledgeParse, batchDeleteKnowledge, getKnowledgeSpans, } from "@/api/knowledge-base/index"; @@ -305,9 +306,16 @@ function clearTraceAvailabilityCache() { traceProbeInflight.clear(); } +// Parse phases where the backend pipeline is still actively running +// (primary parse OR post-process fan-out). Trace data exists and the +// UI should treat the row as "in flight" rather than terminal. +function isParseInFlight(status?: string): boolean { + return status === 'pending' || status === 'processing' || status === 'finalizing'; +} + function isTraceMenuVisible(item: KnowledgeCard): boolean { if (!item?.id) return false; - if (item.parse_status === 'pending' || item.parse_status === 'processing') { + if (isParseInFlight(item.parse_status)) { return true; } return traceAvailableById[item.id] === true; @@ -316,7 +324,7 @@ function isTraceMenuVisible(item: KnowledgeCard): boolean { async function probeTraceAvailable(item: KnowledgeCard) { const id = item.id; if (!id || traceProbeInflight.has(id)) return; - if (item.parse_status === 'pending' || item.parse_status === 'processing') { + if (isParseInFlight(item.parse_status)) { traceAvailableById[id] = true; return; } @@ -1031,12 +1039,7 @@ watch(() => cardList.value, (newValue) => { let analyzeList = []; // Filter items that need polling: parsing in progress OR summary generation in progress - analyzeList = newValue.filter(item => { - const isParsing = item.parse_status == 'pending' || item.parse_status == 'processing'; - const isSummaryPending = item.parse_status == 'completed' && - (item.summary_status == 'pending' || item.summary_status == 'processing'); - return isParsing || isSummaryPending; - }) + analyzeList = newValue.filter(needsStatusPolling); if (timeout !== null) { clearTimeout(timeout); timeout = null; @@ -1064,6 +1067,18 @@ type KnowledgeCard = { error_message?: string; tag_id?: string; }; +// needsStatusPolling decides whether a card row is still "in flight" +// enough that the doc list should keep refreshing it. Keep in sync with +// the backend lifecycle: pending / processing are the primary parse +// phase, finalizing is the post-process fan-out (summary / question / +// graph extract still running), and a `completed` row whose summary +// hasn't landed yet keeps polling so the description fills in. +const needsStatusPolling = (item: KnowledgeCard) => { + if (isParseInFlight(item.parse_status)) return true; + return item.parse_status == 'completed' && + (item.summary_status == 'pending' || item.summary_status == 'processing'); +}; + const updateStatus = (analyzeList: KnowledgeCard[]) => { if (timeout !== null) { clearTimeout(timeout); @@ -1099,23 +1114,13 @@ const updateStatus = (analyzeList: KnowledgeCard[]) => { // If there are no changes, the watch won't trigger, so we must manually poll again // Even if there are changes, we can manually poll again just to be safe. // The watch will clear this timeout if it triggers. - const stillPending = cardList.value.filter(item => { - const isParsing = item.parse_status == 'pending' || item.parse_status == 'processing'; - const isSummaryPending = item.parse_status == 'completed' && - (item.summary_status == 'pending' || item.summary_status == 'processing'); - return isParsing || isSummaryPending; - }); + const stillPending = cardList.value.filter(needsStatusPolling); if (stillPending.length > 0) { updateStatus(stillPending); } }).catch((_err) => { // 错误处理 - const stillPending = cardList.value.filter(item => { - const isParsing = item.parse_status == 'pending' || item.parse_status == 'processing'; - const isSummaryPending = item.parse_status == 'completed' && - (item.summary_status == 'pending' || item.summary_status == 'processing'); - return isParsing || isSummaryPending; - }); + const stillPending = cardList.value.filter(needsStatusPolling); if (stillPending.length > 0) { updateStatus(stillPending); } @@ -1773,7 +1778,7 @@ const handleKnowledgeReparse = (index: number, item: KnowledgeCard) => { MessagePlugin.warning(t('knowledgeEditor.messages.missingId')); return; } - if (item.parse_status === 'pending' || item.parse_status === 'processing') { + if (isParseInFlight(item.parse_status)) { MessagePlugin.info(t('knowledgeBase.rebuildInProgress')); return; } @@ -1959,14 +1964,36 @@ const confirmBatchDelete = async () => { } }; +// Cancel an in-progress parse. Mirrors the backend gate: only the +// pending / processing / finalizing states accept cancel. Uses the +// native confirm dialog to match the existing delete-tag pattern in +// this view and avoid a heavier dialog dependency. +const handleKnowledgeCancelParse = async (item: KnowledgeCard) => { + if (!item?.id) return; + const confirmed = window.confirm( + t('knowledgeBase.cancelParseConfirmBody', { title: item.title || item.id }) as string, + ); + if (!confirmed) return; + try { + await cancelKnowledgeParse(item.id); + MessagePlugin.success(t('knowledgeBase.cancelParseSubmitted')); + // Refresh so the row reflects parse_status=cancelled and the + // menu drops the cancel entry on the next open. + loadKnowledgeFiles(kbId.value); + } catch (error: any) { + MessagePlugin.error(error?.message || t('knowledgeBase.cancelParseFailed')); + } +}; + // Bridge list-view actions back to existing per-card handlers. const handleListAction = ( - action: 'edit' | 'reparse' | 'move' | 'delete', + action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'delete', item: KnowledgeCard, ) => { const idx = (cardList.value || []).findIndex((i: KnowledgeCard) => i.id === item.id); if (action === 'edit') return handleManualEdit(idx, item); if (action === 'reparse') return handleKnowledgeReparse(idx, item); + if (action === 'cancel-parse') return handleKnowledgeCancelParse(item); if (action === 'move') return handleMoveKnowledge(item); if (action === 'delete') return delCard(idx, item); }; @@ -2356,6 +2383,14 @@ async function createNewSession(value: string): Promise { {{ t('knowledgeBase.rebuildDocument') }} +
+ + {{ t('knowledgeBase.cancelParse') }} +
@@ -2440,6 +2475,14 @@ async function createNewSession(value: string): Promise { {{ t('knowledgeBase.parsingInProgress') }}
+
+ + {{ + (item.summary_status === 'pending' || item.summary_status === 'processing') + ? t('knowledgeBase.generatingSummary') + : t('knowledgeBase.statusFinalizing') + }} +
{{ t('knowledgeBase.parsingFailed') }} @@ -2511,7 +2554,7 @@ async function createNewSession(value: string): Promise {