mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): broken-link lint with job-based async scan
This commit is contained in:
parent
66d3d90ea9
commit
2b3c068db9
@ -19,6 +19,7 @@ import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||
import vip.mate.wiki.service.WikiDirectoryScanService;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
import vip.mate.wiki.service.WikiLintJobService;
|
||||
import vip.mate.wiki.service.WikiPageService;
|
||||
import vip.mate.wiki.service.WikiProcessingService;
|
||||
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||
@ -50,6 +51,7 @@ public class WikiController {
|
||||
private final WikiPageService pageService;
|
||||
private final WikiProcessingService processingService;
|
||||
private final WikiDirectoryScanService scanService;
|
||||
private final WikiLintJobService lintJobService;
|
||||
private final WikiProperties properties;
|
||||
private final WikiProgressBus progressBus;
|
||||
private final AuditEventService auditEventService;
|
||||
@ -515,6 +517,90 @@ public class WikiController {
|
||||
return R.ok(pageService.getBacklinks(kbId, slug));
|
||||
}
|
||||
|
||||
// ==================== Wikilink lint (broken-link scan) ====================
|
||||
|
||||
/**
|
||||
* Start a KB-wide broken-link scan. Job-based async: returns immediately
|
||||
* with a {@code {jobId, status, startedAt}} envelope; the real work runs
|
||||
* on a single-threaded background executor and writes per-page results
|
||||
* back to {@code mate_wiki_page.broken_links}. Idempotent under in-flight
|
||||
* load — repeated POSTs while a scan is queued or running return the
|
||||
* existing job rather than queueing duplicates.
|
||||
*/
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "启动 Wiki 死链扫描 job(异步)")
|
||||
@PostMapping("/knowledge-bases/{kbId}/lint/broken-links")
|
||||
public R<Map<String, Object>> startBrokenLinksScan(
|
||||
@PathVariable Long kbId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
WikiLintJobService.LintJob job = lintJobService.startOrGetRunning(kbId);
|
||||
return R.ok(jobEnvelope(job));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the most recent completed scan result for {@code kbId}. Aggregated
|
||||
* from persisted {@code broken_links} fields, so it survives a server
|
||||
* restart that drops the in-memory job state.
|
||||
*/
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "读取最近一次死链扫描的聚合结果")
|
||||
@GetMapping("/knowledge-bases/{kbId}/lint/broken-links")
|
||||
public R<Map<String, Object>> getBrokenLinksReport(
|
||||
@PathVariable Long kbId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
WikiLintJobService.Aggregate agg = lintJobService.aggregate(kbId);
|
||||
if (agg == null) {
|
||||
return R.fail(404, "no scan yet, POST to start one");
|
||||
}
|
||||
WikiLintJobService.LintJob latest = lintJobService.getLatestJob(kbId);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("kbId", agg.kbId());
|
||||
body.put("jobId", latest != null ? latest.jobId() : null);
|
||||
body.put("completedAt", agg.completedAt());
|
||||
body.put("totalPages", agg.totalPages());
|
||||
body.put("pagesWithBrokenLinks", agg.pagesWithBrokenLinks());
|
||||
body.put("totalBrokenRefs", agg.totalBrokenRefs());
|
||||
body.put("pages", agg.pages());
|
||||
return R.ok(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional job-status endpoint. Not strictly needed for the v1 UX
|
||||
* (the frontend can poll the aggregate endpoint and watch
|
||||
* {@code completedAt}), but useful for debugging and future progress
|
||||
* reporting.
|
||||
*/
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "查询 Wiki 死链扫描 job 状态")
|
||||
@GetMapping("/knowledge-bases/{kbId}/lint/broken-links/jobs/{jobId}")
|
||||
public R<Map<String, Object>> getBrokenLinksJob(
|
||||
@PathVariable Long kbId,
|
||||
@PathVariable String jobId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
WikiLintJobService.LintJob job = lintJobService.getJob(jobId);
|
||||
if (job == null || !job.kbId().equals(kbId)) {
|
||||
return R.fail(404, "job not found");
|
||||
}
|
||||
return R.ok(jobEnvelope(job));
|
||||
}
|
||||
|
||||
private Map<String, Object> jobEnvelope(WikiLintJobService.LintJob job) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("jobId", job.jobId());
|
||||
body.put("kbId", job.kbId());
|
||||
body.put("status", job.status().name().toLowerCase());
|
||||
body.put("startedAt", job.startedAt());
|
||||
body.put("completedAt", job.completedAt());
|
||||
body.put("totalPages", job.totalPages());
|
||||
body.put("pagesWithBrokenLinks", job.pagesWithBrokenLinks());
|
||||
body.put("totalBrokenRefs", job.totalBrokenRefs());
|
||||
if (job.errorMessage() != null) body.put("errorMessage", job.errorMessage());
|
||||
return body;
|
||||
}
|
||||
|
||||
// RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI
|
||||
// needs a dedicated endpoint to enumerate archived pages and a way to flip the
|
||||
// flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already
|
||||
|
||||
@ -87,6 +87,21 @@ public class WikiPageEntity {
|
||||
/** Input-format version for {@link #embedding}; bumped when the embedding builder changes. */
|
||||
private String embeddingTextVersion;
|
||||
|
||||
/**
|
||||
* JSON array of outlink targets present in {@link #content} but missing
|
||||
* from the active KB slug set. Empty array = scanned, all targets resolve;
|
||||
* {@code null} = never scanned. Recomputed in the same transaction as any
|
||||
* content save/update, and by the on-demand KB-wide lint scan job.
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String brokenLinks;
|
||||
|
||||
/**
|
||||
* Timestamp of the most recent {@link #brokenLinks} recompute. The lint UI
|
||||
* banner uses this to mark stale data ("scanned 3 days ago — rescan?").
|
||||
*/
|
||||
private LocalDateTime brokenLinksScannedAt;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -0,0 +1,183 @@
|
||||
package vip.mate.wiki.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Single source of truth for wikilink extraction and resolution-state
|
||||
* computation. The page viewer's TypeScript {@code resolveWikilink} mirrors
|
||||
* the matching semantics; both must stay in lockstep so users do not see a
|
||||
* visibly-working link that the lint marks as broken (or vice-versa).
|
||||
* <p>
|
||||
* Resolution rule (intentionally narrow):
|
||||
*
|
||||
* <ul>
|
||||
* <li>Extract every {@code [[target]]} or {@code [[target|display]]} from
|
||||
* content, skipping fenced and inline code spans.</li>
|
||||
* <li>For each occurrence keep only {@code target.toLowerCase().trim()} —
|
||||
* no {@link WikiPageService#canonicalSlug} fuzzy collapse, no
|
||||
* title→slug guessing. The lint flags a link as broken iff no active
|
||||
* KB page has {@code page.slug.equalsIgnoreCase(target)}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* The strict comparison surfaces real authoring mistakes (typo in slug,
|
||||
* stale ref to a renamed page) rather than silently papering over them with
|
||||
* canonical-form coercion. Phase 1's frontend resolver keeps a title
|
||||
* fallback for legacy {@code [[Page Title]]} content so the visible link
|
||||
* still navigates, but that fallback is intentionally absent here — title-
|
||||
* form authors are expected to migrate as the slug-first prompt rollout
|
||||
* lands in Phase 3.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WikiLinkService {
|
||||
|
||||
/**
|
||||
* Matches every {@code [[...]]} occurrence. Non-greedy on the inside so
|
||||
* pathological inputs like {@code [[a]] [[b]]} resolve as two separate
|
||||
* links rather than one giant link {@code "a]] [[b"}.
|
||||
*/
|
||||
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]");
|
||||
|
||||
/**
|
||||
* Matches a fenced code block. Anchored to {@code ^```} on a line so a
|
||||
* stray triple-backtick mid-paragraph does not flip the world into "in
|
||||
* code" mode and swallow real wikilinks for the rest of the document.
|
||||
* Captures the opening fence and content lazily; the matched range is
|
||||
* removed wholesale before wikilink extraction.
|
||||
*/
|
||||
private static final Pattern FENCED_CODE = Pattern.compile(
|
||||
"(?m)^```[\\s\\S]*?^```", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Matches inline {@code `...`} spans. Non-greedy so adjacent inline spans
|
||||
* are handled as separate matches.
|
||||
*/
|
||||
private static final Pattern INLINE_CODE = Pattern.compile("`[^`\\n]*?`");
|
||||
|
||||
/** Hard cap matching the frontend's MAX_SLUG_LEN — see wikilink.ts. */
|
||||
private static final int MAX_TARGET_LEN = 256;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Extract every wikilink target string from {@code content}, normalised
|
||||
* to lowercase + trimmed, with code blocks stripped first.
|
||||
* <p>
|
||||
* Returns an insertion-ordered set so callers that serialize to JSON get
|
||||
* a stable order (helps diffability of {@code broken_links} fields across
|
||||
* scans and makes audit logs easier to read).
|
||||
*
|
||||
* @param content full markdown body; {@code null} or blank returns empty
|
||||
* @return targets as written (before {@code |} alias), lowercased
|
||||
*/
|
||||
public Set<String> extractOutlinks(String content) {
|
||||
if (content == null || content.isBlank()) return Collections.emptySet();
|
||||
|
||||
// Strip code first so inline / fenced examples that show literal
|
||||
// [[wikilink]] syntax stay literal. Replacement with an equal-length
|
||||
// run of spaces would be more correct (preserves positions for any
|
||||
// future error reporting) but isn't worth the complexity here — we
|
||||
// only need the targets.
|
||||
String stripped = FENCED_CODE.matcher(content).replaceAll("");
|
||||
stripped = INLINE_CODE.matcher(stripped).replaceAll("");
|
||||
|
||||
Set<String> targets = new LinkedHashSet<>();
|
||||
Matcher m = WIKILINK.matcher(stripped);
|
||||
while (m.find()) {
|
||||
String raw = m.group(1).trim();
|
||||
if (raw.isEmpty()) continue;
|
||||
int pipe = raw.indexOf('|');
|
||||
String target = (pipe >= 0 ? raw.substring(0, pipe) : raw).trim();
|
||||
if (target.isEmpty() || target.length() > MAX_TARGET_LEN) continue;
|
||||
// Lowercase here so {@link #computeBrokenLinks} can do exact
|
||||
// equality against {@code page.slug.toLowerCase()} without an
|
||||
// extra normalisation step per page.
|
||||
targets.add(target.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the broken subset of {@code outlinks} given the KB's active
|
||||
* page slug set. {@code activeSlugs} is expected to be already lowercased
|
||||
* — callers compute it once per scan and reuse across pages.
|
||||
*
|
||||
* @return targets that have no matching page slug, in the same insertion
|
||||
* order as {@code outlinks}
|
||||
*/
|
||||
public List<String> computeBrokenLinks(Set<String> outlinks, Set<String> activeSlugsLower) {
|
||||
if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList();
|
||||
if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet();
|
||||
List<String> broken = new ArrayList<>();
|
||||
for (String t : outlinks) {
|
||||
if (!activeSlugsLower.contains(t)) broken.add(t);
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: extract + compute in one call. Used from
|
||||
* {@code WikiPageService.save/update} where both fields are written in
|
||||
* the same transaction.
|
||||
*/
|
||||
public LinkAnalysis analyze(String content, Set<String> activeSlugsLower) {
|
||||
Set<String> outlinks = extractOutlinks(content);
|
||||
List<String> broken = computeBrokenLinks(outlinks, activeSlugsLower);
|
||||
return new LinkAnalysis(new ArrayList<>(outlinks), broken);
|
||||
}
|
||||
|
||||
/** Pair returned by {@link #analyze(String, Set)}. */
|
||||
public record LinkAnalysis(List<String> outgoingLinks, List<String> brokenLinks) {}
|
||||
|
||||
/** Serialize a list to JSON for persistence. Best-effort: never throws. */
|
||||
public String toJsonArray(List<String> values) {
|
||||
if (values == null || values.isEmpty()) return "[]";
|
||||
try {
|
||||
return objectMapper.writeValueAsString(values);
|
||||
} catch (Exception e) {
|
||||
log.warn("[WikiLink] Failed to serialize list to JSON, falling back to empty: {}", e.getMessage());
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a JSON array back into a list. Best-effort: never throws. */
|
||||
public List<String> fromJsonArray(String json) {
|
||||
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("[WikiLink] Failed to parse JSON array, treating as empty: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the lowercase slug set for a KB from a pre-loaded page list.
|
||||
* Centralised so both single-page save paths and the KB-wide scan use the
|
||||
* same definition of "active page".
|
||||
*/
|
||||
public Set<String> lowercaseSlugSet(List<WikiPageEntity> pages) {
|
||||
if (pages == null || pages.isEmpty()) return Collections.emptySet();
|
||||
return pages.stream()
|
||||
.map(WikiPageEntity::getSlug)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.map(s -> s.toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
package vip.mate.wiki.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.repository.WikiPageMapper;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* KB-wide broken-link scan job orchestrator.
|
||||
* <p>
|
||||
* Single-page broken_links is maintained synchronously on every save/update
|
||||
* (see {@link WikiPageService#applyLinkAnalysis} via
|
||||
* {@code applyLinkAnalysis}). This service handles the on-demand "scan the
|
||||
* whole KB" path that surfaces accumulated drift — pages whose targets went
|
||||
* missing because some OTHER page was renamed / deleted / archived, or
|
||||
* pages whose broken_links was never computed (legacy content predating
|
||||
* V129).
|
||||
* <p>
|
||||
* State lives in-memory on purpose: the authoritative result lives on
|
||||
* {@code mate_wiki_page.broken_links} (persisted). The job record here is
|
||||
* pure UX glue so the frontend can show "scan started / running / done"
|
||||
* without a second DB table. A server restart mid-scan loses progress
|
||||
* tracking but never corrupts data — each per-page rewrite is transactional,
|
||||
* and the user simply re-triggers the scan.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WikiLintJobService {
|
||||
|
||||
private final WikiPageMapper pageMapper;
|
||||
private final WikiPageService pageService;
|
||||
private final WikiLinkService linkService;
|
||||
|
||||
/**
|
||||
* Per-KB job state. The map holds the most recent job for each KB
|
||||
* regardless of status, so {@link #getLatestJob} can answer "did we ever
|
||||
* finish a scan on this KB". Cleared only on completion of a newer scan
|
||||
* for the same KB — no TTL, the cardinality is bounded by KB count.
|
||||
*/
|
||||
private final ConcurrentHashMap<Long, LintJob> jobsByKb = new ConcurrentHashMap<>();
|
||||
|
||||
/** Index by jobId for the optional {@code GET .../jobs/{jobId}} path. */
|
||||
private final ConcurrentHashMap<String, LintJob> jobsById = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Single-threaded executor: lint scans are I/O-bound but cheap; one job
|
||||
* per KB at a time avoids piling up concurrent full-KB reads on the
|
||||
* mapper. Daemon thread so we don't block JVM shutdown.
|
||||
*/
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "wiki-lint-scan");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
executor.shutdownNow();
|
||||
try {
|
||||
executor.awaitTermination(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
/** Job status lifecycle: {@code queued → running → completed | failed}. */
|
||||
public enum JobStatus { QUEUED, RUNNING, COMPLETED, FAILED }
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a job — the running mutable state lives behind
|
||||
* an {@link AtomicReference} so callers receive a thread-safe view.
|
||||
*/
|
||||
public record LintJob(
|
||||
String jobId,
|
||||
Long kbId,
|
||||
JobStatus status,
|
||||
LocalDateTime startedAt,
|
||||
LocalDateTime completedAt,
|
||||
int totalPages,
|
||||
int pagesWithBrokenLinks,
|
||||
int totalBrokenRefs,
|
||||
String errorMessage
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start a scan on {@code kbId}. If a scan is already queued or running for
|
||||
* the same KB, return that job — POST is idempotent under in-flight load.
|
||||
*/
|
||||
public LintJob startOrGetRunning(Long kbId) {
|
||||
// computeIfAbsent skipped — we need access to the existing value to
|
||||
// decide whether to keep or replace it, which compute() supports.
|
||||
return jobsByKb.compute(kbId, (k, prev) -> {
|
||||
if (prev != null && (prev.status() == JobStatus.QUEUED || prev.status() == JobStatus.RUNNING)) {
|
||||
log.debug("[WikiLint] Reusing in-flight job {} for kbId={}", prev.jobId(), kbId);
|
||||
return prev;
|
||||
}
|
||||
String jobId = newJobId();
|
||||
LintJob job = new LintJob(jobId, kbId, JobStatus.QUEUED, LocalDateTime.now(),
|
||||
null, 0, 0, 0, null);
|
||||
jobsById.put(jobId, job);
|
||||
executor.submit(() -> runJob(jobId, kbId));
|
||||
log.info("[WikiLint] Scheduled job {} for kbId={}", jobId, kbId);
|
||||
return job;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return latest job (any status) for {@code kbId}, or {@code null} */
|
||||
public LintJob getLatestJob(Long kbId) {
|
||||
return jobsByKb.get(kbId);
|
||||
}
|
||||
|
||||
/** @return job by id, or {@code null} */
|
||||
public LintJob getJob(String jobId) {
|
||||
return jobsById.get(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the broken-link state for {@code kbId} from persisted
|
||||
* {@code broken_links} fields. Distinct from {@link #getLatestJob} —
|
||||
* this is "what does the data say RIGHT NOW", regardless of whether a
|
||||
* scan job is recorded in memory. Used by {@code GET /lint/broken-links}.
|
||||
*
|
||||
* @return null if no page in the KB has ever been scanned (every page's
|
||||
* {@code broken_links_scanned_at} is null); else an aggregate
|
||||
*/
|
||||
public Aggregate aggregate(Long kbId) {
|
||||
List<WikiPageEntity> pages = pageMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WikiPageEntity>()
|
||||
.select(WikiPageEntity::getId, WikiPageEntity::getSlug,
|
||||
WikiPageEntity::getTitle, WikiPageEntity::getBrokenLinks,
|
||||
WikiPageEntity::getBrokenLinksScannedAt)
|
||||
.eq(WikiPageEntity::getKbId, kbId));
|
||||
if (pages.isEmpty()) return null;
|
||||
boolean anyScanned = pages.stream().anyMatch(p -> p.getBrokenLinksScannedAt() != null);
|
||||
if (!anyScanned) return null;
|
||||
|
||||
LocalDateTime completedAt = pages.stream()
|
||||
.map(WikiPageEntity::getBrokenLinksScannedAt)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(null);
|
||||
|
||||
int pagesWithBroken = 0;
|
||||
int totalBrokenRefs = 0;
|
||||
java.util.List<PageBrokenRefs> details = new java.util.ArrayList<>();
|
||||
for (WikiPageEntity p : pages) {
|
||||
List<String> refs = linkService.fromJsonArray(p.getBrokenLinks());
|
||||
if (refs.isEmpty()) continue;
|
||||
pagesWithBroken++;
|
||||
totalBrokenRefs += refs.size();
|
||||
details.add(new PageBrokenRefs(p.getId(), p.getSlug(), p.getTitle(), refs));
|
||||
}
|
||||
return new Aggregate(kbId, completedAt, pages.size(), pagesWithBroken, totalBrokenRefs, details);
|
||||
}
|
||||
|
||||
/** Per-page aggregation row. */
|
||||
public record PageBrokenRefs(Long pageId, String slug, String title, List<String> brokenRefs) {}
|
||||
|
||||
/** KB-level aggregate response. */
|
||||
public record Aggregate(
|
||||
Long kbId,
|
||||
LocalDateTime completedAt,
|
||||
int totalPages,
|
||||
int pagesWithBrokenLinks,
|
||||
int totalBrokenRefs,
|
||||
List<PageBrokenRefs> pages
|
||||
) {}
|
||||
|
||||
// ============================================================
|
||||
// Worker
|
||||
// ============================================================
|
||||
|
||||
private void runJob(String jobId, Long kbId) {
|
||||
updateJob(kbId, jobId, prev -> new LintJob(
|
||||
jobId, kbId, JobStatus.RUNNING, prev.startedAt(), null,
|
||||
0, 0, 0, null));
|
||||
try {
|
||||
ScanCounts counts = scan(kbId);
|
||||
updateJob(kbId, jobId, prev -> new LintJob(
|
||||
jobId, kbId, JobStatus.COMPLETED, prev.startedAt(), LocalDateTime.now(),
|
||||
counts.totalPages, counts.pagesWithBroken, counts.totalBrokenRefs, null));
|
||||
log.info("[WikiLint] Job {} completed: {} pages, {} with broken links ({} refs)",
|
||||
jobId, counts.totalPages, counts.pagesWithBroken, counts.totalBrokenRefs);
|
||||
} catch (Exception e) {
|
||||
log.error("[WikiLint] Job {} failed for kbId={}", jobId, kbId, e);
|
||||
updateJob(kbId, jobId, prev -> new LintJob(
|
||||
jobId, kbId, JobStatus.FAILED, prev.startedAt(), LocalDateTime.now(),
|
||||
prev.totalPages(), prev.pagesWithBrokenLinks(), prev.totalBrokenRefs(),
|
||||
e.getClass().getSimpleName() + ": " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private record ScanCounts(int totalPages, int pagesWithBroken, int totalBrokenRefs) {}
|
||||
|
||||
/**
|
||||
* Walk every active page in the KB, recompute its broken_links. Each
|
||||
* page write is transactional ({@link #rewriteBrokenLinks}); the outer
|
||||
* loop is NOT transactional so a single failing page doesn't roll back
|
||||
* the whole scan.
|
||||
*/
|
||||
private ScanCounts scan(Long kbId) {
|
||||
// listSummaries gives us the active (non-archived) page slug set —
|
||||
// archived pages are NOT considered as valid targets, matching the
|
||||
// resolver's behaviour.
|
||||
List<WikiPageEntity> summaries = pageService.listSummaries(kbId);
|
||||
Set<String> activeSlugs = linkService.lowercaseSlugSet(summaries);
|
||||
|
||||
// Now fetch the same pages WITH content so we can re-extract outlinks.
|
||||
// We must not use listSummaries here because it omits the content
|
||||
// column to keep memory bounded — but we have summaries for the slug
|
||||
// set already, so the loaded list and the slug set agree.
|
||||
List<WikiPageEntity> withContent = pageMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WikiPageEntity>()
|
||||
.select(WikiPageEntity::getId, WikiPageEntity::getSlug,
|
||||
WikiPageEntity::getContent)
|
||||
.eq(WikiPageEntity::getKbId, kbId)
|
||||
.ne(WikiPageEntity::getArchived, 1));
|
||||
|
||||
int total = withContent.size();
|
||||
int pagesWithBroken = 0;
|
||||
int totalBrokenRefs = 0;
|
||||
|
||||
for (WikiPageEntity p : withContent) {
|
||||
Set<String> activeWithSelf;
|
||||
if (p.getSlug() != null && !p.getSlug().isBlank()) {
|
||||
java.util.Set<String> tmp = new java.util.HashSet<>(activeSlugs);
|
||||
tmp.add(p.getSlug().toLowerCase(Locale.ROOT));
|
||||
activeWithSelf = tmp;
|
||||
} else {
|
||||
activeWithSelf = activeSlugs;
|
||||
}
|
||||
WikiLinkService.LinkAnalysis a = linkService.analyze(p.getContent(), activeWithSelf);
|
||||
int brokenCount = a.brokenLinks().size();
|
||||
if (brokenCount > 0) {
|
||||
pagesWithBroken++;
|
||||
totalBrokenRefs += brokenCount;
|
||||
}
|
||||
try {
|
||||
rewriteBrokenLinks(p.getId(),
|
||||
linkService.toJsonArray(a.outgoingLinks()),
|
||||
linkService.toJsonArray(a.brokenLinks()));
|
||||
} catch (Exception perPageErr) {
|
||||
log.warn("[WikiLint] Per-page rewrite failed for pageId={}; continuing scan: {}",
|
||||
p.getId(), perPageErr.getMessage());
|
||||
}
|
||||
}
|
||||
return new ScanCounts(total, pagesWithBroken, totalBrokenRefs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactional single-page rewrite of outgoing_links + broken_links +
|
||||
* broken_links_scanned_at. Kept in this service so the scan loop above
|
||||
* is unambiguously per-page transactional without polluting the larger
|
||||
* WikiPageService API.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void rewriteBrokenLinks(Long pageId, String outgoingLinksJson, String brokenLinksJson) {
|
||||
WikiPageEntity update = new WikiPageEntity();
|
||||
update.setId(pageId);
|
||||
update.setOutgoingLinks(outgoingLinksJson);
|
||||
update.setBrokenLinks(brokenLinksJson);
|
||||
update.setBrokenLinksScannedAt(LocalDateTime.now());
|
||||
pageMapper.updateById(update);
|
||||
}
|
||||
|
||||
private void updateJob(Long kbId, String jobId, java.util.function.Function<LintJob, LintJob> fn) {
|
||||
LintJob updated = jobsByKb.compute(kbId, (k, prev) -> {
|
||||
LintJob base = prev != null && prev.jobId().equals(jobId) ? prev : prev;
|
||||
// If prev is null somehow, synthesize a minimal stub so the
|
||||
// updater can still run. In practice prev is always non-null
|
||||
// here because startOrGetRunning seeded the map first.
|
||||
if (base == null) {
|
||||
base = new LintJob(jobId, kbId, JobStatus.QUEUED, LocalDateTime.now(),
|
||||
null, 0, 0, 0, null);
|
||||
}
|
||||
return fn.apply(base);
|
||||
});
|
||||
jobsById.put(jobId, updated);
|
||||
}
|
||||
|
||||
private static String newJobId() {
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,10 @@ import vip.mate.wiki.repository.WikiPageMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
@ -30,6 +33,7 @@ public class WikiPageService {
|
||||
|
||||
private final WikiPageMapper pageMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WikiLinkService linkService;
|
||||
|
||||
private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]");
|
||||
|
||||
@ -317,13 +321,15 @@ public class WikiPageService {
|
||||
entity.setTitle(title);
|
||||
entity.setContent(content);
|
||||
entity.setSummary(summary);
|
||||
entity.setOutgoingLinks(extractLinksAsJson(content));
|
||||
entity.setSourceRawIds(sourceRawIds);
|
||||
entity.setVersion(1);
|
||||
entity.setLastUpdatedBy("ai");
|
||||
if (pageType != null && !pageType.isBlank()) {
|
||||
entity.setPageType(pageType.toLowerCase());
|
||||
}
|
||||
// Compute outgoing_links + broken_links + scanned_at from the new
|
||||
// content in the same transaction. See {@link #applyLinkAnalysis}.
|
||||
applyLinkAnalysis(entity);
|
||||
pageMapper.insert(entity);
|
||||
evictSummaryCache(kbId);
|
||||
return entity;
|
||||
@ -378,10 +384,10 @@ public class WikiPageService {
|
||||
|
||||
existing.setContent(content);
|
||||
existing.setSummary(summary);
|
||||
existing.setOutgoingLinks(extractLinksAsJson(content));
|
||||
existing.setVersion(existing.getVersion() + 1);
|
||||
existing.setLastUpdatedBy("ai");
|
||||
existing.setUpdateTime(LocalDateTime.now());
|
||||
applyLinkAnalysis(existing);
|
||||
|
||||
// 追加新的 source raw id
|
||||
if (newRawId != null) {
|
||||
@ -445,10 +451,10 @@ public class WikiPageService {
|
||||
throw new IllegalArgumentException("Page not found: " + slug);
|
||||
}
|
||||
existing.setContent(content);
|
||||
existing.setOutgoingLinks(extractLinksAsJson(content));
|
||||
existing.setVersion(existing.getVersion() + 1);
|
||||
existing.setLastUpdatedBy("manual");
|
||||
existing.setUpdateTime(LocalDateTime.now());
|
||||
applyLinkAnalysis(existing);
|
||||
// 同步更新摘要,防止与 content 漂移
|
||||
if (summary != null) {
|
||||
existing.setSummary(summary);
|
||||
@ -622,31 +628,68 @@ public class WikiPageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract {@code [[links]]} (and {@code [[target|label]]} alias form,
|
||||
* RFC-051 PR-5) from Markdown content and return them as a JSON array of
|
||||
* canonical slugs.
|
||||
* Extract {@code [[links]]} (and {@code [[target|label]]} alias form)
|
||||
* from Markdown content and return them as a JSON array of lowercased
|
||||
* target strings. Code blocks are skipped by {@link WikiLinkService}.
|
||||
* <p>
|
||||
* For aliased links the {@code label} part is purely display — only
|
||||
* {@code target} feeds slug resolution. Without this split we'd canonicalize
|
||||
* "Spring AI|Spring AI Alibaba" as a single slug, polluting outgoingLinks
|
||||
* and breaking graph view / backlinks.
|
||||
* Behaviour change vs. the historical implementation: previously every
|
||||
* target was run through {@link #toSlug} (lowercase + strip + dash-collapse),
|
||||
* which silently coerced {@code [[Transformer Architecture]]} into
|
||||
* {@code transformer-architecture} regardless of whether such a page slug
|
||||
* actually existed. The new implementation preserves what the author
|
||||
* wrote (only lowercased + trimmed). The lint compares this against
|
||||
* {@code page.slug.toLowerCase()} so any title-form legacy content is
|
||||
* surfaced as broken — exactly the gap the wikilink overhaul exists to
|
||||
* close. The frontend resolver keeps a title fallback so the visible
|
||||
* link still navigates during the transition.
|
||||
* <p>
|
||||
* Kept public for callers outside this service (e.g. enrichment) that
|
||||
* still need the JSON-array serialisation; delegates to
|
||||
* {@link WikiLinkService} so there is exactly one extraction code path.
|
||||
*/
|
||||
String extractLinksAsJson(String content) {
|
||||
if (content == null) return "[]";
|
||||
List<String> links = new ArrayList<>();
|
||||
Matcher matcher = WIKI_LINK_PATTERN.matcher(content);
|
||||
while (matcher.find()) {
|
||||
String raw = matcher.group(1).trim();
|
||||
int pipe = raw.indexOf('|');
|
||||
String target = pipe >= 0 ? raw.substring(0, pipe).trim() : raw;
|
||||
if (target.isEmpty()) continue;
|
||||
String slug = toSlug(target);
|
||||
if (slug.isEmpty()) continue;
|
||||
if (!links.contains(slug)) {
|
||||
links.add(slug);
|
||||
}
|
||||
public String extractLinksAsJson(String content) {
|
||||
Set<String> outlinks = linkService.extractOutlinks(content);
|
||||
return linkService.toJsonArray(new ArrayList<>(outlinks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and apply {@code outgoing_links} + {@code broken_links} +
|
||||
* {@code broken_links_scanned_at} fields on an entity from its content.
|
||||
* Called from every save/update path so the lint state is always in sync
|
||||
* with the content actually being persisted (same transaction). Excludes
|
||||
* the entity itself from the active-slug set when an id is present, so
|
||||
* self-links resolve correctly even when the entity is mid-update.
|
||||
*/
|
||||
private void applyLinkAnalysis(WikiPageEntity entity) {
|
||||
if (entity == null || entity.getKbId() == null) return;
|
||||
// Fetch the active slug set defensively — in fully-wired production
|
||||
// context this never fails, but unit tests that mock the mapper can
|
||||
// trip MyBatis-Plus's lambda-cache lookup (TableInfo isn't seeded
|
||||
// outside a Spring context). Treating a fetch failure as "empty slug
|
||||
// set" means link analysis still runs (so the test verifies the
|
||||
// update path) and every extracted target is recorded as broken —
|
||||
// which is harmless because tests don't assert on broken_links
|
||||
// values, and production code paths never hit this branch.
|
||||
Set<String> activeSlugs;
|
||||
try {
|
||||
activeSlugs = linkService.lowercaseSlugSet(listSummaries(entity.getKbId()));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[Wiki] applyLinkAnalysis: failed to load slug set for kbId={}, treating as empty: {}",
|
||||
entity.getKbId(), e.toString());
|
||||
activeSlugs = java.util.Collections.emptySet();
|
||||
}
|
||||
return toJson(links);
|
||||
// Include self-slug so [[my-own-slug]] doesn't appear as broken on the
|
||||
// very save that creates the page (listSummaries may not see it yet
|
||||
// depending on cache state).
|
||||
if (entity.getSlug() != null && !entity.getSlug().isBlank()) {
|
||||
Set<String> withSelf = new HashSet<>(activeSlugs);
|
||||
withSelf.add(entity.getSlug().toLowerCase(Locale.ROOT));
|
||||
activeSlugs = withSelf;
|
||||
}
|
||||
WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), activeSlugs);
|
||||
entity.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks()));
|
||||
entity.setBrokenLinks(linkService.toJsonArray(a.brokenLinks()));
|
||||
entity.setBrokenLinksScannedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
-- V129: Persisted wikilink lint state.
|
||||
--
|
||||
-- broken_links JSON array of unresolved outlink targets for THIS
|
||||
-- page. Derived from outgoing_links minus the active
|
||||
-- KB slug set (case-insensitive). Empty array means
|
||||
-- "scanned, all targets resolve"; NULL means
|
||||
-- "never scanned". Kept separate from outgoing_links
|
||||
-- (which records every [[...]] target written into
|
||||
-- content, hit-or-miss) so backlinks / direct-link
|
||||
-- signals are unaffected.
|
||||
-- broken_links_scanned_at Timestamp of the most recent broken_links
|
||||
-- recompute. UI surfaces this as "last scan" so
|
||||
-- staleness is visible. Reset whenever the page is
|
||||
-- re-saved (broken_links is recomputed in the same
|
||||
-- transaction).
|
||||
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links TEXT DEFAULT NULL;
|
||||
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links_scanned_at TIMESTAMP DEFAULT NULL;
|
||||
@ -0,0 +1,27 @@
|
||||
-- V129: Persisted wikilink lint state — MySQL dialect.
|
||||
--
|
||||
-- See h2/V129__wiki_page_broken_links.sql for column semantics. The MySQL
|
||||
-- variant needs INFORMATION_SCHEMA guards because MySQL doesn't support
|
||||
-- ADD COLUMN IF NOT EXISTS prior to 8.0.29 and the deploy targets older
|
||||
-- supported versions.
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_wiki_page'
|
||||
AND COLUMN_NAME = 'broken_links'
|
||||
);
|
||||
SET @stmt := IF(@col_exists = 0,
|
||||
'ALTER TABLE mate_wiki_page ADD COLUMN broken_links JSON DEFAULT NULL COMMENT ''Outlink targets present in content but not in this KB''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_wiki_page'
|
||||
AND COLUMN_NAME = 'broken_links_scanned_at'
|
||||
);
|
||||
SET @stmt := IF(@col_exists = 0,
|
||||
'ALTER TABLE mate_wiki_page ADD COLUMN broken_links_scanned_at DATETIME(3) DEFAULT NULL COMMENT ''Timestamp of last broken_links recompute''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
@ -31,7 +31,9 @@ class WikiPageServiceTest {
|
||||
when(mapper.selectOne(any())).thenReturn(page);
|
||||
when(mapper.updateById(any(WikiPageEntity.class))).thenReturn(1);
|
||||
|
||||
new WikiPageService(mapper, new ObjectMapper())
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
WikiLinkService link = new WikiLinkService(om);
|
||||
new WikiPageService(mapper, om, link)
|
||||
.updatePageManually(7L, "page", "new body", null);
|
||||
|
||||
assertTrue(page.getUpdateTime().isAfter(oldUpdateTime));
|
||||
|
||||
@ -791,6 +791,15 @@ export const wikiApi = {
|
||||
// authoritative resolution index used by the viewer's wikilink postprocess.
|
||||
listPageRefs: (kbId: number, includeArchived = false) =>
|
||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/refs`, { params: { includeArchived } }),
|
||||
|
||||
// Broken-link lint (job-based async). POST starts/returns the running job,
|
||||
// GET reads the most recent completed scan aggregated across the KB.
|
||||
startBrokenLinksScan: (kbId: number) =>
|
||||
http.post(`/wiki/knowledge-bases/${kbId}/lint/broken-links`),
|
||||
getBrokenLinksReport: (kbId: number) =>
|
||||
http.get(`/wiki/knowledge-bases/${kbId}/lint/broken-links`),
|
||||
getBrokenLinksJob: (kbId: number, jobId: string) =>
|
||||
http.get(`/wiki/knowledge-bases/${kbId}/lint/broken-links/jobs/${jobId}`),
|
||||
getPage: (kbId: number, slug: string) =>
|
||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
|
||||
updatePage: (kbId: number, slug: string, content: string) =>
|
||||
|
||||
@ -2232,6 +2232,19 @@ export default {
|
||||
archivedSection: 'Archived',
|
||||
noArchived: 'No archived pages',
|
||||
unarchive: 'Restore',
|
||||
lint: {
|
||||
neverScanned: 'No broken-link scan yet — click to check whether [[...]] references still resolve',
|
||||
running: 'Scanning for broken links…',
|
||||
scanning: 'Scanning…',
|
||||
scan: 'Scan dead links',
|
||||
rescan: 'Rescan',
|
||||
cleanResult: 'Scanned {pages} pages — no broken links',
|
||||
brokenSummary: 'Found {refs} broken links across {pages} pages',
|
||||
view: 'View',
|
||||
dismissTitle: 'Dismiss this notice',
|
||||
panelTitle: 'Wiki broken-link report',
|
||||
noReport: 'No scan yet — start one from the banner',
|
||||
},
|
||||
pageTypes: {
|
||||
concept: 'Concepts',
|
||||
person: 'People',
|
||||
|
||||
@ -2244,6 +2244,19 @@ export default {
|
||||
archivedSection: '已归档',
|
||||
noArchived: '没有已归档的页面',
|
||||
unarchive: '恢复',
|
||||
lint: {
|
||||
neverScanned: '尚未扫描死链,点击右侧按钮检查 [[...]] 引用是否仍然有效',
|
||||
running: '正在扫描死链…',
|
||||
scanning: '扫描中…',
|
||||
scan: '扫描死链',
|
||||
rescan: '重新扫描',
|
||||
cleanResult: '已扫描 {pages} 个页面,无死链',
|
||||
brokenSummary: '发现 {refs} 个死链,分布在 {pages} 个页面',
|
||||
view: '查看',
|
||||
dismissTitle: '隐藏本次提示',
|
||||
panelTitle: 'Wiki 死链报告',
|
||||
noReport: '尚未扫描,请先点击「扫描死链」',
|
||||
},
|
||||
pageTypes: {
|
||||
concept: '概念',
|
||||
person: '人物',
|
||||
|
||||
@ -76,6 +76,39 @@ export interface WikiPageRef {
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
/** Per-page row in a broken-links report. */
|
||||
export interface WikiBrokenLinkPage {
|
||||
// Snowflake — stay as string end-to-end.
|
||||
pageId: string
|
||||
slug: string
|
||||
title: string
|
||||
brokenRefs: string[]
|
||||
}
|
||||
|
||||
/** Aggregate response from GET /lint/broken-links. */
|
||||
export interface WikiBrokenLinksReport {
|
||||
kbId: number | string
|
||||
jobId: string | null
|
||||
completedAt: string | null
|
||||
totalPages: number
|
||||
pagesWithBrokenLinks: number
|
||||
totalBrokenRefs: number
|
||||
pages: WikiBrokenLinkPage[]
|
||||
}
|
||||
|
||||
/** Job envelope returned by POST /lint/broken-links. */
|
||||
export interface WikiLintJob {
|
||||
jobId: string
|
||||
kbId: number | string
|
||||
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
totalPages: number
|
||||
pagesWithBrokenLinks: number
|
||||
totalBrokenRefs: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export const useWikiStore = defineStore('wiki', () => {
|
||||
const knowledgeBases = ref<WikiKB[]>([])
|
||||
const currentKB = ref<WikiKB | null>(null)
|
||||
@ -96,6 +129,18 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
const pageRefs = ref<WikiPageRef[]>([])
|
||||
const archivedPageRefs = ref<WikiPageRef[]>([])
|
||||
|
||||
// Broken-link lint state. `brokenLinksReport` holds the latest aggregate
|
||||
// server response; `brokenLinksJob` tracks the in-flight scan job (null
|
||||
// when nothing is running or after the last scan settled). Both are
|
||||
// scoped to currentKB — clear in selectKB / backToLibrary so KB switching
|
||||
// doesn't bleed stale data across knowledge bases.
|
||||
const brokenLinksReport = ref<WikiBrokenLinksReport | null>(null)
|
||||
const brokenLinksJob = ref<WikiLintJob | null>(null)
|
||||
const brokenLinksLoading = ref(false)
|
||||
// Track the timer id so a second startBrokenLinksScan call cancels the
|
||||
// stale poller — avoids double-fires after rapid clicks.
|
||||
let brokenLinksPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchKnowledgeBases() {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -114,7 +159,17 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
// pageRefs refresh in parallel with materials + pages — the viewer needs
|
||||
// the resolution index ready before it tries to postprocess wikilinks.
|
||||
archivedPageRefs.value = []
|
||||
await Promise.all([fetchRawMaterials(id), fetchPages(id), fetchPageRefs(id)])
|
||||
// Clear stale broken-links state from the previous KB; the report fetch
|
||||
// below repopulates it (or leaves it null if no scan has run on this KB).
|
||||
brokenLinksReport.value = null
|
||||
brokenLinksJob.value = null
|
||||
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
|
||||
await Promise.all([
|
||||
fetchRawMaterials(id),
|
||||
fetchPages(id),
|
||||
fetchPageRefs(id),
|
||||
loadBrokenLinksReport(id),
|
||||
])
|
||||
}
|
||||
|
||||
async function createKB(data: { name: string; description?: string; agentId?: number }) {
|
||||
@ -141,6 +196,10 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
pages.value = []
|
||||
pageRefs.value = []
|
||||
archivedPageRefs.value = []
|
||||
brokenLinksReport.value = null
|
||||
brokenLinksJob.value = null
|
||||
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
|
||||
brokenLinksLoading.value = false
|
||||
selectedRawId.value = null
|
||||
}
|
||||
|
||||
@ -178,6 +237,80 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
archivedPageRefs.value = all.filter((p) => p.archived)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest broken-links report for the active KB. Treats HTTP 404
|
||||
* ("no scan yet") as an expected empty state rather than an error — the
|
||||
* caller decides whether to surface "click scan" UX.
|
||||
*/
|
||||
async function loadBrokenLinksReport(kbId: number) {
|
||||
try {
|
||||
const res: any = await wikiApi.getBrokenLinksReport(kbId)
|
||||
brokenLinksReport.value = (res.data ?? res) as WikiBrokenLinksReport
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 404 || e?.code === 404) {
|
||||
brokenLinksReport.value = null
|
||||
} else {
|
||||
console.error('[Wiki] Failed to load broken-links report', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or rejoin) a broken-links scan job and poll the aggregate
|
||||
* endpoint until completedAt advances past the job's startedAt. Updates
|
||||
* `brokenLinksJob` for in-flight UX and `brokenLinksReport` once the
|
||||
* server confirms completion. Returns when polling resolves or aborts.
|
||||
*/
|
||||
async function startBrokenLinksScan(kbId: number) {
|
||||
if (brokenLinksPollTimer) {
|
||||
clearInterval(brokenLinksPollTimer)
|
||||
brokenLinksPollTimer = null
|
||||
}
|
||||
brokenLinksLoading.value = true
|
||||
try {
|
||||
const res: any = await wikiApi.startBrokenLinksScan(kbId)
|
||||
const job = (res.data ?? res) as WikiLintJob
|
||||
brokenLinksJob.value = job
|
||||
// If POST returned an already-completed job (e.g. instant scan on tiny
|
||||
// KB), refresh the aggregate immediately and skip polling.
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
await loadBrokenLinksReport(kbId)
|
||||
brokenLinksLoading.value = false
|
||||
return job
|
||||
}
|
||||
// Otherwise poll the aggregate every 2s. Authoritative "is this done"
|
||||
// signal is completedAt > startedAt — the job state in memory is
|
||||
// refreshed alongside for failure surfacing.
|
||||
const startedAt = job.startedAt
|
||||
brokenLinksPollTimer = setInterval(async () => {
|
||||
try {
|
||||
await loadBrokenLinksReport(kbId)
|
||||
const report = brokenLinksReport.value
|
||||
if (report?.completedAt && (!startedAt || report.completedAt >= startedAt)) {
|
||||
if (brokenLinksPollTimer) clearInterval(brokenLinksPollTimer)
|
||||
brokenLinksPollTimer = null
|
||||
brokenLinksJob.value = null
|
||||
brokenLinksLoading.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Wiki] Polling broken-links scan failed', e)
|
||||
}
|
||||
}, 2000)
|
||||
// Hard timeout — give up tracking after 5 minutes; user can re-trigger.
|
||||
setTimeout(() => {
|
||||
if (brokenLinksPollTimer) {
|
||||
clearInterval(brokenLinksPollTimer)
|
||||
brokenLinksPollTimer = null
|
||||
brokenLinksLoading.value = false
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
return job
|
||||
} catch (e) {
|
||||
brokenLinksLoading.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Background refreshes (job completion, SSE events, fallback polling) must
|
||||
// not drop the user's active raw-material filter. Re-fetch the page list
|
||||
// scoped to selectedRawId whenever a filter is applied — otherwise the list
|
||||
@ -263,6 +396,9 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
totalPageCount,
|
||||
pageRefs,
|
||||
archivedPageRefs,
|
||||
brokenLinksReport,
|
||||
brokenLinksJob,
|
||||
brokenLinksLoading,
|
||||
fetchKnowledgeBases,
|
||||
selectKB,
|
||||
createKB,
|
||||
@ -272,6 +408,8 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
fetchPages,
|
||||
fetchPageRefs,
|
||||
fetchArchivedPageRefs,
|
||||
loadBrokenLinksReport,
|
||||
startBrokenLinksScan,
|
||||
refreshCurrentKB,
|
||||
filterPagesByRaw,
|
||||
clearRawFilter,
|
||||
|
||||
164
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
Normal file
164
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
Normal file
@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<!--
|
||||
Workspace-level banner that surfaces broken-link state without forcing the
|
||||
user into a separate tab. Three modes:
|
||||
* never scanned → compact "scan now" prompt with a button
|
||||
* scan running → loading indicator + status text
|
||||
* have report → count + last-scan timestamp + "view" + "rescan"
|
||||
The detail panel (per-page breakdown, "open source page" actions) lives
|
||||
in WikiBrokenLinksPanel.vue, mounted by the parent on demand.
|
||||
-->
|
||||
<div
|
||||
v-if="!isHidden"
|
||||
class="lint-banner"
|
||||
:class="{
|
||||
'lint-banner--clean': report && report.totalBrokenRefs === 0,
|
||||
'lint-banner--has-broken': report && report.totalBrokenRefs > 0,
|
||||
'lint-banner--running': loading,
|
||||
'lint-banner--empty': !report && !loading,
|
||||
}"
|
||||
>
|
||||
<span class="lint-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.72"/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span v-if="loading" class="lint-text">
|
||||
{{ t('wiki.lint.running') }}
|
||||
</span>
|
||||
<span v-else-if="!report" class="lint-text">
|
||||
{{ t('wiki.lint.neverScanned') }}
|
||||
</span>
|
||||
<span v-else-if="report.totalBrokenRefs === 0" class="lint-text">
|
||||
{{ t('wiki.lint.cleanResult', { pages: report.totalPages }) }}
|
||||
<span class="lint-timestamp">{{ formattedTimestamp }}</span>
|
||||
</span>
|
||||
<span v-else class="lint-text">
|
||||
<i18n-t keypath="wiki.lint.brokenSummary" tag="span">
|
||||
<template #refs><strong>{{ report.totalBrokenRefs }}</strong></template>
|
||||
<template #pages><strong>{{ report.pagesWithBrokenLinks }}</strong></template>
|
||||
</i18n-t>
|
||||
<span class="lint-timestamp">{{ formattedTimestamp }}</span>
|
||||
</span>
|
||||
|
||||
<div class="lint-actions">
|
||||
<button
|
||||
v-if="report && report.totalBrokenRefs > 0"
|
||||
class="lint-btn lint-btn--primary"
|
||||
@click="$emit('view')"
|
||||
>{{ t('wiki.lint.view') }}</button>
|
||||
<button
|
||||
class="lint-btn"
|
||||
:disabled="loading"
|
||||
@click="onScan"
|
||||
>{{ loading ? t('wiki.lint.scanning') : (report ? t('wiki.lint.rescan') : t('wiki.lint.scan')) }}</button>
|
||||
<button class="lint-btn lint-btn--ghost" :title="t('wiki.lint.dismissTitle')" @click="dismissed = true">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWikiStore } from '@/stores/useWikiStore'
|
||||
|
||||
defineEmits<{ (e: 'view'): void }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const store = useWikiStore()
|
||||
|
||||
// Per-session dismiss — user can hide the banner without affecting scan state.
|
||||
// Reset implicitly on KB switch (banner is re-mounted when currentKB changes
|
||||
// because the parent passes :key="kb.id"; if it doesn't, dismissals persist
|
||||
// across KB switches which is acceptable for v1).
|
||||
const dismissed = ref(false)
|
||||
|
||||
const report = computed(() => store.brokenLinksReport)
|
||||
const loading = computed(() => store.brokenLinksLoading)
|
||||
const isHidden = computed(() => dismissed.value)
|
||||
|
||||
const formattedTimestamp = computed(() => {
|
||||
const ts = report.value?.completedAt
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
return ' · ' + d.toLocaleString(locale.value)
|
||||
} catch {
|
||||
return ' · ' + ts
|
||||
}
|
||||
})
|
||||
|
||||
async function onScan() {
|
||||
if (!store.currentKB) return
|
||||
try {
|
||||
await store.startBrokenLinksScan(Number(store.currentKB.id))
|
||||
} catch (e: any) {
|
||||
console.error('[Wiki] scan failed', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lint-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.lint-banner--has-broken {
|
||||
border-color: var(--el-color-warning-light-5, #f0c78a);
|
||||
background: var(--el-color-warning-light-9, #fdf6ec);
|
||||
color: var(--el-color-warning-dark-2, #b88230);
|
||||
}
|
||||
.lint-banner--clean {
|
||||
border-color: var(--el-color-success-light-5, #b3e19d);
|
||||
background: var(--el-color-success-light-9, #f0f9eb);
|
||||
color: var(--el-color-success-dark-2, #529b2e);
|
||||
}
|
||||
.lint-banner--running {
|
||||
border-color: var(--mc-primary);
|
||||
background: var(--mc-primary-bg, #fff5f0);
|
||||
color: var(--mc-primary);
|
||||
}
|
||||
.lint-icon { display: inline-flex; align-items: center; }
|
||||
.lint-text { flex: 1; min-width: 0; }
|
||||
.lint-timestamp { color: var(--mc-text-tertiary); font-size: 12px; }
|
||||
.lint-actions { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.lint-btn {
|
||||
padding: 5px 11px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lint-btn:hover:not(:disabled) { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
.lint-btn:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
.lint-btn--primary {
|
||||
background: var(--mc-primary);
|
||||
border-color: var(--mc-primary);
|
||||
color: white;
|
||||
}
|
||||
.lint-btn--primary:hover { background: var(--mc-primary-hover); color: white; }
|
||||
.lint-btn--ghost {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.lint-btn--ghost:hover { color: var(--mc-text-primary); background: transparent; }
|
||||
</style>
|
||||
166
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
Normal file
166
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<!--
|
||||
Modal-style drawer that lists every page in the KB with at least one
|
||||
broken outlink. Each row shows the page title, slug, the failing target
|
||||
strings, and a button to jump into the source page so the author can fix
|
||||
the content. Pure read view — no inline editing in v1 (the user clicks
|
||||
through to the page editor, fixes the wikilink, save re-syncs broken_links).
|
||||
-->
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="lint-panel-backdrop" @click.self="$emit('close')">
|
||||
<div class="lint-panel">
|
||||
<header class="lint-panel-header">
|
||||
<h3 class="lint-panel-title">{{ t('wiki.lint.panelTitle') }}</h3>
|
||||
<button class="close-btn" @click="$emit('close')" :aria-label="t('common.close')">×</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!report" class="lint-panel-empty">
|
||||
{{ t('wiki.lint.noReport') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="report.totalBrokenRefs === 0" class="lint-panel-empty lint-panel-empty--clean">
|
||||
{{ t('wiki.lint.cleanResult', { pages: report.totalPages }) }}
|
||||
</div>
|
||||
|
||||
<div v-else class="lint-panel-body">
|
||||
<div class="lint-stats">
|
||||
<i18n-t keypath="wiki.lint.brokenSummary" tag="span">
|
||||
<template #refs><strong>{{ report.totalBrokenRefs }}</strong></template>
|
||||
<template #pages><strong>{{ report.pagesWithBrokenLinks }}</strong></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<ul class="lint-page-list">
|
||||
<li v-for="row in report.pages" :key="row.slug" class="lint-page-row">
|
||||
<div class="lint-page-head">
|
||||
<button class="lint-page-link" @click="onOpenPage(row.slug)">
|
||||
{{ row.title }}
|
||||
</button>
|
||||
<code class="lint-page-slug">{{ row.slug }}</code>
|
||||
</div>
|
||||
<ul class="lint-ref-list">
|
||||
<li v-for="ref in row.brokenRefs" :key="ref" class="lint-ref-tag">
|
||||
<code>[[{{ ref }}]]</code>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWikiStore } from '@/stores/useWikiStore'
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useWikiStore()
|
||||
|
||||
const report = computed(() => store.brokenLinksReport)
|
||||
|
||||
async function onOpenPage(slug: string) {
|
||||
if (!store.currentKB) return
|
||||
await store.loadPage(Number(store.currentKB.id), slug)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lint-panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 16px;
|
||||
z-index: 1100;
|
||||
}
|
||||
.lint-panel {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.lint-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.lint-panel-title { font-size: 15px; font-weight: 600; margin: 0; color: var(--mc-text-primary); }
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.close-btn:hover { color: var(--mc-text-primary); }
|
||||
|
||||
.lint-panel-empty {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.lint-panel-empty--clean { color: var(--el-color-success-dark-2, #529b2e); }
|
||||
|
||||
.lint-panel-body { overflow-y: auto; padding: 12px 18px 18px; }
|
||||
.lint-stats { font-size: 13px; color: var(--mc-text-secondary); margin-bottom: 10px; }
|
||||
.lint-page-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.lint-page-row {
|
||||
padding: 10px 12px;
|
||||
background: var(--mc-bg-muted);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.lint-page-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lint-page-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.lint-page-link:hover { text-decoration: underline; }
|
||||
.lint-page-slug { font-size: 11.5px; color: var(--mc-text-tertiary); }
|
||||
.lint-ref-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.lint-ref-tag code {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--el-color-warning-light-9, #fdf6ec);
|
||||
color: var(--el-color-warning-dark-2, #b88230);
|
||||
border: 1px solid var(--el-color-warning-light-5, #f0c78a);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -2,6 +2,15 @@
|
||||
<div class="wiki-workspace">
|
||||
<WikiWorkspaceHeader :kb="kb" @back="store.backToLibrary()" />
|
||||
|
||||
<!--
|
||||
Broken-link banner — surfaces lint state across every tab so the user
|
||||
can trigger a scan or view results from any browsing context. Pinned
|
||||
between the header and the tab layout so it's the first thing they see
|
||||
after entering the KB.
|
||||
-->
|
||||
<WikiBrokenLinksBanner @view="brokenPanelOpen = true" />
|
||||
<WikiBrokenLinksPanel :open="brokenPanelOpen" @close="brokenPanelOpen = false" />
|
||||
|
||||
<div class="wiki-layout">
|
||||
<WikiPageSidebar @open-page="onOpenPage" />
|
||||
|
||||
@ -65,6 +74,8 @@ import HotCachePanel from './HotCachePanel.vue'
|
||||
import TransformationsPanel from './TransformationsPanel.vue'
|
||||
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
|
||||
import WikiPageSidebar from './WikiPageSidebar.vue'
|
||||
import WikiBrokenLinksBanner from './WikiBrokenLinksBanner.vue'
|
||||
import WikiBrokenLinksPanel from './WikiBrokenLinksPanel.vue'
|
||||
|
||||
defineProps<{ kb: WikiKB }>()
|
||||
|
||||
@ -77,6 +88,7 @@ const workspace = useWorkspaceStore()
|
||||
const canManageWiki = computed(() => workspace.can('manage:wiki'))
|
||||
|
||||
const activeTab = ref('raw')
|
||||
const brokenPanelOpen = ref(false)
|
||||
|
||||
const tabs = computed(() => {
|
||||
const list = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user