feat(wiki): per-raw progress bar in raw-material card (RFC-012 M2 v2 UI)

This commit is contained in:
matevip 2026-04-14 18:56:45 +08:00
parent f285db22d0
commit 2a2f862257
9 changed files with 204 additions and 29 deletions

View File

@ -55,6 +55,18 @@ public class WikiRawMaterialEntity {
/** 错误信息 */
private String errorMessage;
/**
* RFC-012 M2 v2 UI当前处理阶段null 未开始 / "route" / "phase-b" / "done"
* 供前端决定是否显示进度条以及显示"准备中"还是具体进度
*/
private String progressPhase;
/** RFC-012 M2 v2 UI本次处理计划的总页数route 阶段确定后写入)。 */
private Integer progressTotal;
/** RFC-012 M2 v2 UI已完成的页数每个 phase B 页成功后 +1。 */
private Integer progressDone;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -22,9 +22,11 @@ import vip.mate.wiki.model.WikiRawMaterialEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
@ -50,6 +52,20 @@ public class WikiProcessingService {
/** 并行 chunk / 材料处理执行器JDK 21 虚拟线程Listener 跨包需要引用,故 public */
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
/**
* RFC-012 M2 v2 UI v2 raw 的进度计数器多个并行 chunk {@code processChunkTwoPhase}
* 共享同一份 atomic 计数避免 6 chunk 各写各的 progress 字段时互相覆盖导致 UI 永远 preparing
* <p>
* 生命周期{@code processRawMaterial} 入口 puttry/finally 出口 remove
*/
private static final class ProgressCounter {
final AtomicInteger total = new AtomicInteger(0);
final AtomicInteger done = new AtomicInteger(0);
final AtomicBoolean phaseBStarted = new AtomicBoolean(false);
}
private final ConcurrentHashMap<Long, ProgressCounter> progressCounters = new ConcurrentHashMap<>();
/**
* 处理单个原始材料
*/
@ -93,6 +109,10 @@ public class WikiProcessingService {
kbService.updateStatus(kb.getId(), "processing");
// RFC-012 M2 v2 UI v2为本次 raw 处理创建共享进度计数器 chunk 共享避免 race
progressCounters.put(rawId, new ProgressCounter());
rawService.updateProgress(rawId, "route", 0, 0); // UI 立即看到 indeterminate 滑条
try {
// Phase 1: 获取文本内容
String textContent = rawService.getTextContent(raw);
@ -152,6 +172,12 @@ public class WikiProcessingService {
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
rawService.updateProcessingStatus(rawId, "failed", e.getMessage());
kbService.updateStatus(kb.getId(), "active");
} finally {
// RFC-012 M2 v2 UI v2写入最终进度并清理共享计数器
ProgressCounter pc = progressCounters.remove(rawId);
if (pc != null) {
rawService.updateProgress(rawId, "done", pc.done.get(), pc.total.get());
}
}
}
@ -371,6 +397,10 @@ public class WikiProcessingService {
String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : "";
String rawTitle = raw.getTitle();
// RFC-012 M2 v2 UI v2取共享进度计数器processRawMaterial 入口已 put
// chunk 并行时所有 chunk 共享同一份 atomic 计数避免互相覆盖把 UI 拉回 preparing
ProgressCounter pc = progressCounters.get(rawId);
// 阶段 A路由
String routeSystem = PromptLoader.loadPrompt("wiki/route-system");
String routeUserTemplate = PromptLoader.loadPrompt("wiki/route-user");
@ -414,9 +444,19 @@ public class WikiProcessingService {
if (!slug.isBlank()) updateSlugs.add(slug);
}
}
int totalPlanned = createMetas.size() + updateSlugs.size();
log.info("[Wiki] Route phase: kbId={}, rawId={}, planned create={}, planned update={}",
kbId, rawId, createMetas.size(), updateSlugs.size());
// RFC-012 M2 v2 UI v2把本 chunk 的计划数累加到共享 total切换到 phase-b仅首次切换需 log
if (pc != null) {
pc.total.addAndGet(totalPlanned);
if (pc.phaseBStarted.compareAndSet(false, true)) {
log.info("[Wiki] Progress: switching to phase-b for raw={}", rawId);
}
rawService.updateProgress(rawId, "phase-b", pc.done.get(), pc.total.get());
}
// 阶段 B-1逐页 create每页一次单独 LLM call输入/输出都是单页规模
for (JsonNode meta : createMetas) {
try {
@ -427,6 +467,11 @@ public class WikiProcessingService {
log.warn("[Wiki] Phase B create page slug='{}' failed: {}",
meta.path("slug").asText(""), e.getMessage());
}
// 无论成功失败都推进 done 计数避免失败页卡死 UI 进度
if (pc != null) {
int d = pc.done.incrementAndGet();
rawService.updateProgress(rawId, "phase-b", d, pc.total.get());
}
}
// 阶段 B-2逐页 merge每页一次单独 LLM call
@ -438,7 +483,12 @@ public class WikiProcessingService {
} catch (RuntimeException e) {
log.warn("[Wiki] Phase B merge page slug='{}' failed: {}", slug, e.getMessage());
}
if (pc != null) {
int d = pc.done.incrementAndGet();
rawService.updateProgress(rawId, "phase-b", d, pc.total.get());
}
}
// chunk 完成时不写"done" chunk 还在跑最终"done" processRawMaterial finally 写入
log.info("[Wiki] Two-phase digest applied: kbId={}, rawId={}, created={}, updated={}",
kbId, rawId, created, updated);

View File

@ -165,10 +165,35 @@ public class WikiRawMaterialService {
}
entity.setProcessingStatus("processing");
entity.setErrorMessage(null);
// RFC-012 M2 v2 UI新一轮处理开始清掉上次遗留的进度显示
entity.setProgressPhase(null);
entity.setProgressTotal(0);
entity.setProgressDone(0);
rawMapper.updateById(entity);
return true;
}
/**
* RFC-012 M2 v2 UI更新 wiki 两阶段消化的进度字段
* <p>
* {@code WikiProcessingService.processChunkTwoPhase} 的四个节点被调用
* <ul>
* <li>方法开头 {@code phase="route"}, done=0, total=0进度条显示 indeterminate</li>
* <li>route 返回后 {@code phase="phase-b"}, done=0, total=N+M切换到 determinate</li>
* <li>每页 create/merge 成功 done +1</li>
* <li>方法结束 {@code phase="done"}UI 会随 status 变成 completed 自动隐藏进度条</li>
* </ul>
*/
@Transactional
public void updateProgress(Long id, String phase, int done, int total) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setProgressPhase(phase);
entity.setProgressDone(done);
entity.setProgressTotal(total);
rawMapper.updateById(entity);
}
@Transactional
public void updateProcessingStatus(Long id, String status, String errorMessage) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);

View File

@ -0,0 +1,6 @@
-- V8: wiki raw material two-phase digest progress fields, for UI progress bar
-- RFC-012 M2 v2 UI follow-up: expose per-raw progress (current phase + pages done / total planned)
-- so the frontend can render a determinate progress bar instead of an opaque "处理中" badge.
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS progress_phase VARCHAR(32) DEFAULT NULL;
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS progress_total INT DEFAULT 0;
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS progress_done INT DEFAULT 0;

View File

@ -0,0 +1,6 @@
-- V8: wiki raw material two-phase digest progress fields, for UI progress bar
-- RFC-012 M2 v2 UI follow-up: expose per-raw progress (current phase + pages done / total planned)
-- so the frontend can render a determinate progress bar instead of an opaque "处理中" badge.
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS progress_phase VARCHAR(32) DEFAULT NULL;
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS progress_total INT DEFAULT 0;
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS progress_done INT DEFAULT 0;

View File

@ -1196,6 +1196,9 @@ export default {
partial: 'PARTIAL',
failed: 'FAILED',
},
progress: {
preparing: 'Preparing…',
},
},
cronJobs: {
kicker: 'Automation',

View File

@ -1206,6 +1206,9 @@ export default {
partial: '部分完成',
failed: '失败',
},
progress: {
preparing: '准备中…',
},
},
cronJobs: {
kicker: '自动执行',

View File

@ -26,6 +26,10 @@ export interface WikiRawMaterial {
lastProcessedAt: string | null
errorMessage: string | null
createTime: string
// RFC-012 M2 v2 UI两阶段消化进度字段后端在 route 后写 total每页完成后 +1 done
progressPhase: string | null
progressTotal: number
progressDone: number
}
export interface WikiPage {

View File

@ -56,35 +56,56 @@
{{ t('wiki.noRawMaterials') }}
</div>
<div v-for="raw in store.rawMaterials" :key="raw.id" class="raw-item">
<div class="raw-item-info">
<span class="raw-item-title">{{ raw.title }}</span>
<span class="raw-item-type">{{ raw.sourceType }}</span>
<div class="raw-item-row">
<div class="raw-item-info">
<span class="raw-item-title">{{ raw.title }}</span>
<span class="raw-item-type">{{ raw.sourceType }}</span>
</div>
<div class="raw-item-meta">
<span class="status-badge" :class="raw.processingStatus">
{{ t(`wiki.status.${raw.processingStatus}`) }}
</span>
<span
v-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
class="error-hint" :title="raw.errorMessage"
>
{{ raw.errorMessage }}
</span>
</div>
<div class="raw-item-actions">
<button
v-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed'"
class="btn-icon" :title="t('wiki.reprocess')"
@click="reprocess(raw.id)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
<div class="raw-item-meta">
<span class="status-badge" :class="raw.processingStatus">
{{ t(`wiki.status.${raw.processingStatus}`) }}
<div v-if="raw.processingStatus === 'processing'" class="raw-progress">
<div class="raw-progress-track">
<div
class="raw-progress-fill"
:class="{ indeterminate: !raw.progressTotal }"
:style="raw.progressTotal
? { width: Math.min(100, Math.round((raw.progressDone / raw.progressTotal) * 100)) + '%' }
: {}"
></div>
</div>
<span class="raw-progress-label">
{{ raw.progressTotal
? `${raw.progressDone} / ${raw.progressTotal}`
: t('wiki.progress.preparing') }}
</span>
<span v-if="raw.errorMessage" class="error-hint" :title="raw.errorMessage">
{{ raw.errorMessage }}
</span>
</div>
<div class="raw-item-actions">
<button
v-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed'"
class="btn-icon" :title="t('wiki.reprocess')"
@click="reprocess(raw.id)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
@ -122,7 +143,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
@ -131,6 +152,33 @@ const { t } = useI18n()
const store = useWikiStore()
const fileInput = ref<HTMLInputElement | null>(null)
// RFC-012 M2 v2 UI processing 3s
// processing timer
let pollTimer: number | null = null
const hasProcessing = computed(() =>
store.rawMaterials.some(r => r.processingStatus === 'processing')
)
watch(
hasProcessing,
(active) => {
if (active && pollTimer == null) {
pollTimer = window.setInterval(() => {
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
}, 3000)
} else if (!active && pollTimer != null) {
clearInterval(pollTimer)
pollTimer = null
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (pollTimer != null) {
clearInterval(pollTimer)
pollTimer = null
}
})
const showAddText = ref(false)
const textTitle = ref('')
const textContent = ref('')
@ -250,8 +298,9 @@ async function handleScanDir() {
.raw-list-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); margin-bottom: 4px; }
.empty-hint { text-align: center; padding: 24px 0; font-size: 14px; color: var(--mc-text-tertiary); }
.raw-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; }
.raw-item { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; }
.raw-item:hover { border-color: var(--mc-border); transform: translateY(-1px); }
.raw-item-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.raw-item-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.raw-item-title { font-weight: 500; color: var(--mc-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@ -260,6 +309,23 @@ async function handleScanDir() {
.raw-item-actions { display: flex; gap: 4px; flex-shrink: 0; }
.error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Two-phase digest progress bar (RFC-012 M2 v2 UI) */
.raw-progress { display: flex; align-items: center; gap: 10px; padding-top: 2px; }
.raw-progress-track { flex: 1; height: 4px; background: var(--mc-bg-sunken); border-radius: 9999px; overflow: hidden; position: relative; }
.raw-progress-fill { height: 100%; background: var(--mc-primary); border-radius: 9999px; transition: width 0.3s ease; }
.raw-progress-fill.indeterminate {
width: 30%;
position: absolute;
left: 0;
animation: raw-progress-slide 1.6s ease-in-out infinite;
}
@keyframes raw-progress-slide {
0% { transform: translateX(-100%); }
50% { transform: translateX(170%); }
100% { transform: translateX(330%); }
}
.raw-progress-label { font-size: 11px; color: var(--mc-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; min-width: 56px; text-align: right; }
/* Icon button */
.btn-icon { width: 30px; height: 30px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); cursor: pointer; border-radius: 8px; color: var(--mc-text-secondary); transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
.btn-icon:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); border-color: var(--mc-border); }