mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): per-raw progress bar in raw-material card (RFC-012 M2 v2 UI)
This commit is contained in:
parent
f285db22d0
commit
2a2f862257
@ -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;
|
||||
|
||||
|
||||
@ -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} 入口 put,try/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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -1196,6 +1196,9 @@ export default {
|
||||
partial: 'PARTIAL',
|
||||
failed: 'FAILED',
|
||||
},
|
||||
progress: {
|
||||
preparing: 'Preparing…',
|
||||
},
|
||||
},
|
||||
cronJobs: {
|
||||
kicker: 'Automation',
|
||||
|
||||
@ -1206,6 +1206,9 @@ export default {
|
||||
partial: '部分完成',
|
||||
failed: '失败',
|
||||
},
|
||||
progress: {
|
||||
preparing: '准备中…',
|
||||
},
|
||||
},
|
||||
cronJobs: {
|
||||
kicker: '自动执行',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user