feat(chat): show MinerU parse progress while waiting in-turn

When summarizing the latest upload, poll parse state up to MinerU timeout,
emit document_parsing phase events so the bubble stays live and input stays
locked, then summarize or return a clear slow/failed reply.
This commit is contained in:
WeiCHEN
2026-09-23 09:55:59 +08:00
parent 1546d89446
commit 1f37f90b4b
8 changed files with 268 additions and 2 deletions
+1
View File
@@ -18,6 +18,7 @@
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| Cursor | fix-latest-upload-parse-wait-2026-09-23 | feature/fix-latest-upload-summary-2026-09-23 | .worktrees/fix-latest-upload-summary-2026-09-23 | intent+chat-doc-summary | done |
| Cursor | fix-latest-upload-summary-2026-09-23 | feature/fix-latest-upload-summary-2026-09-23 | .worktrees/fix-latest-upload-summary-2026-09-23 | intent+chat-doc-summary | done |
| Cursor | modular-refactor-review-2026-09-23 | feature/modular-refactor-review-2026-09-23 | .worktrees/modular-refactor-review-2026-09-23 | ops-audit-modular-refactor-review | done |
| Cursor | 2026-09-22-status-reconcile | feature/status-reconcile-2026-09-22 | .worktrees/status-reconcile-2026-09-22 | ops-status-reconcile | done |
@@ -0,0 +1,55 @@
---
title: "上传文档解析中可见进度与同轮等待"
task_id: "fix-latest-upload-parse-wait-2026-09-23"
parent_task_id: "fix-latest-upload-summary-2026-09-23"
status: done
created: 2026-09-23
updated: 2026-09-23
owner: Cursor
task_type: implementation
task_tier: small
policy_revision: "agent-governance-2026-09-21-r1 @ 2ce8688db4f49fdd116799b9d36ef7ea802afc33"
branch: feature/fix-latest-upload-summary-2026-09-23
worktree: .worktrees/fix-latest-upload-summary-2026-09-23
base_dev_sha: "2ce8688db4f49fdd116799b9d36ef7ea802afc33"
write_scope:
- internal/server
- internal/coordinator
- internal/materialindex
- web/src
- .doc/ops
allowed_environments: []
head_sha: ""
mutex: "intent+chat-doc-summary"
review_state: pending
integration_state: pending
deployment: not_required
cleanup: pending
cleanup_owner: 独立维护者
cleanup_trigger: tip 为 local_dev 祖先且无进程后 remove
remote_sync: not_requested
---
# 上传文档解析中可见进度与同轮等待
父任务:[2026-09-23-latest-upload-doc-summary.md](./2026-09-23-latest-upload-doc-summary.md)。本追问:解析中发 `document_parsing` phase,同轮等待并锁定发送。
## 改动摘要
- `materialindex.ParseWaitLimit()`:暴露等待上限。
- `waitForUploadParse`:同轮每 2s 轮询并 `EmitPhase("document_parsing", …)`;超时 `slow`;失败/完成分派。
- `PHASE_META`:`document_parsing` / `document_summarizing`。
- 前端发送锁:复用既有 `isStreamingActive`(phase → streaming_status → 禁发)。
## 验证与证据
| 范围 | 命令 | 判定 |
|---|---|---|
| server | `go test ./internal/server/ -run 'ResolveLatest\|WaitForUploadParse\|PickLatest\|SelectReadable\|CrossWorkflow'` | PASS |
| vet | `go vet ./internal/materialindex/... ./internal/server/... ./internal/coordinator/...` | PASS |
| arch | `./scripts/arch-check.sh --mode block` | PASS |
| web | `npx tsx --test src/engine/phaseEventMapper.test.ts` | PASS(16) |
## 收口
- 本地提交见本分支 tip;dev 集成 / 部署另授;不 push。
+2
View File
@@ -167,6 +167,8 @@ func (e *ErrUploadParseState) Error() string {
return fmt.Sprintf("《%s》仍在解析中,请稍后再试总结。", name)
case "failed":
return fmt.Sprintf("《%s》解析失败。可在附件卡片点击重新解析后再试总结。", name)
case "slow":
return fmt.Sprintf("《%s》解析耗时较长(仍在后台进行),请稍后再让我总结。", name)
default:
return fmt.Sprintf("《%s》暂时无法读取正文,请稍后再试。", name)
}
+8
View File
@@ -147,6 +147,14 @@ func (p *Pipeline) RAGTopK() int {
return p.ragTopK
}
// ParseWaitLimit returns the bounded wait for pending/running MinerU parses.
func (p *Pipeline) ParseWaitLimit() time.Duration {
if p == nil || p.waitLimit <= 0 {
return defaultWait
}
return p.waitLimit
}
// GetParseState returns parse state for a blob (empty string if unknown).
func (p *Pipeline) GetParseState(ctx context.Context, blobSHA string) string {
rec, err := p.getRecord(ctx, blobSHA)
+107 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"unicode"
"chat-agent-system/internal/auth"
@@ -16,6 +17,7 @@ import (
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/storage"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
)
@@ -119,11 +121,50 @@ func (s *Server) resolveLatestUploadDocument(ctx context.Context, projectID int6
state = strings.TrimSpace(matIdx.GetParseState(ctx, latest.BlobSHA256))
}
switch state {
case "pending", "running", "failed":
case "failed":
return nil, &coordinator.ErrUploadParseState{
State: state,
Filename: latest.FileName,
}
case "pending", "running":
limit := 90 * time.Second
if s.cfg != nil && s.cfg.MineruTimeout > 0 {
limit = s.cfg.MineruTimeout
}
if matIdx != nil {
if w := matIdx.ParseWaitLimit(); w > 0 {
limit = w
}
}
finalState := waitForUploadParse(ctx, func() string {
if matIdx == nil {
return ""
}
return strings.TrimSpace(matIdx.GetParseState(ctx, latest.BlobSHA256))
}, latest.FileName, limit)
switch finalState {
case "done":
// continue to load content
case "failed":
return nil, &coordinator.ErrUploadParseState{
State: "failed",
Filename: latest.FileName,
}
case "slow", "pending", "running":
return nil, &coordinator.ErrUploadParseState{
State: "slow",
Filename: latest.FileName,
Message: fmt.Sprintf(
"《%s》解析耗时较长(仍在后台进行),请稍后再让我总结。",
defaultUploadTitle(latest.FileName),
),
}
default:
if ctx.Err() != nil {
return nil, ctx.Err()
}
// unknown / empty after wait: fall through and try EnsureParsed
}
}
data, ref, err := s.cas.ReadRefBytes(ctx, latest.ID)
@@ -164,6 +205,71 @@ func (s *Server) resolveLatestUploadDocument(ctx context.Context, projectID int6
}, nil
}
var uploadParsePollInterval = 2 * time.Second
// waitForUploadParse 在同轮对话内有界等待 MinerU 解析,并周期性发 document_parsing phase。
// stateFn 返回当前解析状态;终态 "done"/"failed" 立即返回;超时返回 "slow"。
func waitForUploadParse(ctx context.Context, stateFn func() string, fileName string, limit time.Duration) string {
if limit <= 0 {
limit = 90 * time.Second
}
title := defaultUploadTitle(fileName)
started := time.Now()
emitUploadParsePhase(ctx, title, 0, 5)
ticker := time.NewTicker(uploadParsePollInterval)
defer ticker.Stop()
deadline := time.NewTimer(limit)
defer deadline.Stop()
for {
state := ""
if stateFn != nil {
state = strings.TrimSpace(stateFn())
}
switch state {
case "done", "failed":
return state
}
select {
case <-ctx.Done():
return state
case <-deadline.C:
final := ""
if stateFn != nil {
final = strings.TrimSpace(stateFn())
}
if final == "done" || final == "failed" {
return final
}
return "slow"
case <-ticker.C:
elapsed := time.Since(started)
progress := int(elapsed.Seconds() / limit.Seconds() * 100)
if progress > 95 {
progress = 95
}
if progress < 5 {
progress = 5
}
emitUploadParsePhase(ctx, title, int(elapsed.Seconds()), progress)
}
}
}
func emitUploadParsePhase(ctx context.Context, title string, elapsedSec, progress int) {
st := stream.FromContext(ctx)
if st == nil {
return
}
msg := fmt.Sprintf("《%s》已排队解析,正在等待 MinerU…", title)
if elapsedSec > 0 {
msg = fmt.Sprintf("《%s》正在解析中…已等待 %ds", title, elapsedSec)
}
st.EmitPhase("document_parsing", msg, "assistant", 0, 0, progress)
}
func pickLatestFileReference(refs []*storage.FileReference) *storage.FileReference {
var latest *storage.FileReference
for _, ref := range refs {
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/storage"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
)
@@ -182,3 +184,79 @@ func TestResolveLatestUploadDocument_NoAsset(t *testing.T) {
t.Fatalf("want ErrNoUploadAsset, got %T %v", err, err)
}
}
func TestWaitForUploadParse_PendingThenDone(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 15 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
var calls atomic.Int32
stateFn := func() string {
n := calls.Add(1)
if n >= 3 {
return "done"
}
return "pending"
}
var phases []string
ctx, st := stream.WithListener(context.Background(), false, func(ev stream.StreamEvent) {
if ev.Type == stream.EventPhase && ev.Phase != nil {
phases = append(phases, ev.Phase.PhaseID+":"+ev.Phase.Message)
}
})
_ = st
got := waitForUploadParse(ctx, stateFn, "冲击波说明书.pdf", 2*time.Second)
if got != "done" {
t.Fatalf("got %q want done", got)
}
if calls.Load() < 3 {
t.Fatalf("expected multiple polls, calls=%d", calls.Load())
}
if len(phases) == 0 {
t.Fatal("expected document_parsing phase events")
}
if !strings.Contains(phases[0], "document_parsing:") {
t.Fatalf("first phase=%q", phases[0])
}
if !strings.Contains(phases[0], "MinerU") && !strings.Contains(phases[0], "解析") {
t.Fatalf("initial phase should mention parse wait: %q", phases[0])
}
}
func TestWaitForUploadParse_TimeoutSlow(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 10 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
got := waitForUploadParse(context.Background(), func() string { return "pending" }, "spec.pdf", 40*time.Millisecond)
if got != "slow" {
t.Fatalf("got %q want slow", got)
}
}
func TestWaitForUploadParse_ContextCancel(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 50 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := waitForUploadParse(ctx, func() string { return "running" }, "spec.pdf", time.Second)
if got != "running" && got != "" {
// cancelled before/at select: returns last observed state
t.Fatalf("got %q on cancel", got)
}
}
func TestWaitForUploadParse_Failed(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 10 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
got := waitForUploadParse(context.Background(), func() string { return "failed" }, "spec.pdf", time.Second)
if got != "failed" {
t.Fatalf("got %q want failed", got)
}
}
+15 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyPhaseEvent } from './phaseEventMapper';
import { applyPhaseEvent, PHASE_META } from './phaseEventMapper';
test('applyPhaseEvent: appends first phase as running node', () => {
const strip = applyPhaseEvent([], { phase_id: 'squad_assembly', message: '组建小队' });
@@ -126,3 +126,17 @@ test('applyPhaseEvent: stores detail from phase.message', () => {
const strip = applyPhaseEvent([], { phase_id: 'squad_assembly', message: '正在组建真实小队...' });
assert.equal(strip[0].detail, '正在组建真实小队...');
});
test('PHASE_META: document_parsing and document_summarizing are registered', () => {
assert.equal(PHASE_META.document_parsing.icon, '📄');
assert.equal(PHASE_META.document_parsing.label, '文档解析');
assert.equal(PHASE_META.document_summarizing.icon, '📝');
assert.equal(PHASE_META.document_summarizing.label, '正文总结');
const strip = applyPhaseEvent([], {
phase_id: 'document_parsing',
message: '《说明书》正在解析中…已等待 4s',
progress: 20,
});
assert.equal(strip[0].icon, '📄');
assert.match(strip[0].label, /解析中/);
});
+2
View File
@@ -24,6 +24,8 @@ export const PHASE_META: Record<string, { icon: string; label: string }> = {
stage_completed: { icon: '🎉', label: '阶段完成' },
step_failed: { icon: '⚠️', label: '步骤异常' },
task_failed: { icon: '⚠️', label: '任务异常' },
document_parsing: { icon: '📄', label: '文档解析' },
document_summarizing: { icon: '📝', label: '正文总结' },
};
/** 收口型 phase:不追加新节点,仅把当前 running 条目置为 completed */