From 71ad735e95054486e3d03d940c785a7ce3778943 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 10 Jul 2026 12:01:48 +0800 Subject: [PATCH] feat(wiki): cross-KB wikilinks [[kbId/slug]] and raw-material batch filter/reprocess/delete (#506) --- .../mate/wiki/controller/WikiController.java | 146 +++++++++- .../mate/wiki/service/WikiLinkService.java | 42 +++ .../wiki/service/WikiRawMaterialService.java | 49 ++++ .../service/WikiLinkServiceCrossKbTest.java | 64 +++++ mateclaw-ui/src/api/index.ts | 15 +- .../composables/__tests__/wikilink.test.ts | 40 +++ mateclaw-ui/src/composables/chat/useStream.ts | 1 + .../src/composables/useGlobalWikilinkClick.ts | 11 + mateclaw-ui/src/composables/wikilink.ts | 36 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 16 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 16 ++ mateclaw-ui/src/stores/useWikiStore.ts | 38 ++- .../Wiki/components/RawMaterialPanel.vue | 270 +++++++++++++++++- .../views/Wiki/components/WikiPageViewer.vue | 18 +- 14 files changed, 755 insertions(+), 7 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCrossKbTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index e4c1947b..7e2adf67 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -36,6 +36,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -509,13 +512,19 @@ public class WikiController { // ==================== Raw Materials ==================== @RequireWorkspaceRole("viewer") - @Operation(summary = "获取原始材料列表(含每条材料生成的页面数)") + @Operation(summary = "获取原始材料列表(含每条材料生成的页面数),支持按状态/类型/关键词/时间筛选") @GetMapping("/knowledge-bases/{kbId}/raw") public R>> listRaw(@PathVariable Long kbId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String sourceType, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String startTime, + @RequestParam(required = false) String endTime, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); - List raws = rawService.listByKbId(kbId); - List> result = new java.util.ArrayList<>(raws.size()); + List raws = rawService.listByKbIdFiltered( + kbId, status, sourceType, keyword, parseTime(startTime, false), parseTime(endTime, true)); + List> result = new ArrayList<>(raws.size()); for (WikiRawMaterialEntity raw : raws) { Map item = new LinkedHashMap<>(); // Serialize all entity fields via Jackson-friendly approach @@ -648,6 +657,137 @@ public class WikiController { return R.ok(); } + /** Hard cap on how many raw materials one batch call may touch. */ + private static final int MAX_RAW_BATCH = 500; + + @RequireWorkspaceRole("member") + @Operation(summary = "批量重新处理原始材料(按 ids 或按 status 选取;force=true 绕过 content_hash 短路)") + @PostMapping("/knowledge-bases/{kbId}/raw/batch/reprocess") + public R> batchReprocessRaw(@PathVariable Long kbId, + @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + List ids = resolveBatchIds(kbId, body); + if (ids == null) { + return R.fail(400, "Must supply non-empty 'ids' or a 'status' selector"); + } + if (ids.size() > MAX_RAW_BATCH) { + return R.fail(400, "Batch too large: " + ids.size() + " > " + MAX_RAW_BATCH); + } + boolean force = Boolean.TRUE.equals(body.get("force")); + int processed = 0; + int skipped = 0; + for (Long id : ids) { + WikiRawMaterialEntity raw = rawService.getById(id); + // Guard against cross-KB ids sneaking in via an explicit id list. + if (raw == null || !kbId.equals(raw.getKbId())) { + skipped++; + continue; + } + try { + if (force) { + rawService.setLastProcessedHash(id, null); + } + rawService.reprocess(id); + processed++; + } catch (Exception e) { + log.warn("[Wiki] Batch reprocess skipped raw={}: {}", id, e.getMessage()); + skipped++; + } + } + return R.ok(Map.of("requested", ids.size(), "processed", processed, "skipped", skipped)); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "批量删除原始材料(按 ids 或按 status 选取;级联清理页面/分块)") + @PostMapping("/knowledge-bases/{kbId}/raw/batch/delete") + public R> batchDeleteRaw(@PathVariable Long kbId, + @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + List ids = resolveBatchIds(kbId, body); + if (ids == null) { + return R.fail(400, "Must supply non-empty 'ids' or a 'status' selector"); + } + if (ids.size() > MAX_RAW_BATCH) { + return R.fail(400, "Batch too large: " + ids.size() + " > " + MAX_RAW_BATCH); + } + int deleted = 0; + int skipped = 0; + for (Long id : ids) { + WikiRawMaterialEntity raw = rawService.getById(id); + if (raw == null || !kbId.equals(raw.getKbId())) { + skipped++; + continue; + } + try { + rawService.delete(id); + kbService.decrementRawCount(kbId); + deleted++; + } catch (Exception e) { + log.warn("[Wiki] Batch delete skipped raw={}: {}", id, e.getMessage()); + skipped++; + } + } + return R.ok(Map.of("requested", ids.size(), "deleted", deleted, "skipped", skipped)); + } + + /** + * Resolve the target id set for a batch operation from the request body. + * An explicit non-empty {@code ids} array wins; otherwise a {@code status} + * selector resolves to every raw in the KB with that status. Returns + * {@code null} when neither is usable (caller returns 400). Ids are parsed + * leniently from string or number JSON forms (Snowflake precision). + */ + private List resolveBatchIds(Long kbId, Map body) { + Object rawIds = body.get("ids"); + if (rawIds instanceof List list && !list.isEmpty()) { + List ids = new ArrayList<>(list.size()); + for (Object o : list) { + if (o == null) continue; + try { + ids.add(Long.parseLong(String.valueOf(o).trim())); + } catch (NumberFormatException ignored) { + // Skip malformed ids rather than failing the whole batch. + } + } + return ids.isEmpty() ? null : ids; + } + Object status = body.get("status"); + if (status != null && !String.valueOf(status).isBlank()) { + List ids = rawService.selectIdsByStatus(kbId, String.valueOf(status).trim()); + return ids.isEmpty() ? List.of() : ids; + } + return null; + } + + /** + * Parse an optional ISO date or date-time filter bound. Accepts a full + * {@code LocalDateTime} (e.g. {@code 2026-07-10T13:00:00}) or a bare date + * (e.g. {@code 2026-07-10}); a bare date maps to start-of-day for a lower + * bound or end-of-day for an upper bound. Blank/unparseable input yields + * {@code null} (no clause) rather than an error. + * + * @param value the raw query-parameter string + * @param endOfDay when true and {@code value} is date-only, use 23:59:59.999999999 + */ + private LocalDateTime parseTime(String value, boolean endOfDay) { + if (value == null || value.isBlank()) return null; + String v = value.trim(); + try { + return LocalDateTime.parse(v); + } catch (Exception ignored) { + // Fall through to date-only parsing. + } + try { + LocalDate d = LocalDate.parse(v); + return endOfDay ? d.atTime(java.time.LocalTime.MAX) : d.atStartOfDay(); + } catch (Exception ignored) { + log.warn("[Wiki] Ignoring unparseable raw-material time filter: {}", v); + return null; + } + } + @RequireWorkspaceRole("viewer") @Operation(summary = "下载原始材料") @GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java index b7c5d1a0..64bded12 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java @@ -55,6 +55,15 @@ public class WikiLinkService { */ private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]"); + /** + * Matches a cross-KB wikilink target of the form {@code kbId/slug}, where + * {@code kbId} is a numeric knowledge-base id and {@code slug} is a page + * slug inside that KB. A plain single-KB slug never contains {@code /}, so + * this pattern is unambiguous — historical {@code [[slug]]} / + * {@code [[Title]]} content is unaffected. + */ + private static final Pattern CROSS_KB = Pattern.compile("^(\\d+)/(.+)$"); + /** * 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 @@ -135,11 +144,44 @@ public class WikiLinkService { if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet(); List broken = new ArrayList<>(); for (String t : outlinks) { + // Cross-KB targets ([[kbId/slug]]) can't be validated against this + // KB's key set — checking existence would need a cross-KB query. + // Exempt well-formed cross-KB refs from the single-KB broken-link + // rule so they aren't false-flagged; the target KB's viewer surfaces + // a real page-not-found on click if the slug is stale. + if (parseCrossKb(t) != null) continue; if (!resolvableKeysLower.contains(t)) broken.add(t); } return broken; } + /** + * Parse a cross-KB wikilink target {@code kbId/slug} into its parts, or + * {@code null} when {@code target} is a plain single-KB slug/title. + * Pure function — no I/O, no existence check. + * + * @param target the wikilink target (before any {@code |alias}); may be + * already lowercased by {@link #extractOutlinks} + * @return {@link CrossKbRef} when the target has a numeric KB prefix, else null + */ + public CrossKbRef parseCrossKb(String target) { + if (target == null || target.isBlank()) return null; + Matcher m = CROSS_KB.matcher(target.trim()); + if (!m.matches()) return null; + try { + long kbId = Long.parseLong(m.group(1)); + String slug = m.group(2).trim(); + if (slug.isEmpty()) return null; + return new CrossKbRef(kbId, slug); + } catch (NumberFormatException e) { + // kbId overflowed long — treat as a plain (broken) single-KB target. + return null; + } + } + + /** A parsed cross-KB wikilink target: target KB id + page slug. */ + public record CrossKbRef(long kbId, String slug) {} + /** * Convenience: extract + compute in one call. Used from * {@code WikiPageService.save/update} where both fields are written in diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index 0e278080..f7eaf7be 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -20,6 +20,7 @@ import vip.mate.wiki.repository.WikiRawMaterialMapper; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; import vip.mate.wiki.dto.WikiFailureItem; @@ -64,9 +65,40 @@ public class WikiRawMaterialService { private final Set partialResumeIds = ConcurrentHashMap.newKeySet(); public List listByKbId(Long kbId) { + return listByKbIdFiltered(kbId, null, null, null, null, null); + } + + /** + * List raw materials in a KB, optionally narrowed by any combination of + * processing status, source type, a title keyword, and a create-time range. + * All filter arguments are optional — a {@code null}/blank value drops that + * clause, so calling with all-null is identical to the unfiltered list. + *

+ * Powers the raw-material panel's filter bar (issue #506): with dozens of + * materials per KB, filtering by {@code status = "failed"} or a title + * keyword replaces page-by-page scrolling. + * + * @param kbId owning knowledge base (required) + * @param status processing status (pending/processing/completed/failed/partial/cancelled) + * @param sourceType source type (text/pdf/docx/image/…) + * @param keyword case-insensitive substring matched against the title + * @param startTime inclusive lower bound on create time + * @param endTime inclusive upper bound on create time + */ + public List listByKbIdFiltered(Long kbId, String status, String sourceType, + String keyword, + LocalDateTime startTime, LocalDateTime endTime) { List list = rawMapper.selectList( new LambdaQueryWrapper() .eq(WikiRawMaterialEntity::getKbId, kbId) + .eq(status != null && !status.isBlank(), + WikiRawMaterialEntity::getProcessingStatus, status) + .eq(sourceType != null && !sourceType.isBlank(), + WikiRawMaterialEntity::getSourceType, sourceType) + .like(keyword != null && !keyword.isBlank(), + WikiRawMaterialEntity::getTitle, keyword) + .ge(startTime != null, WikiRawMaterialEntity::getCreateTime, startTime) + .le(endTime != null, WikiRawMaterialEntity::getCreateTime, endTime) .orderByDesc(WikiRawMaterialEntity::getCreateTime)); // 不返回大文本字段 list.forEach(r -> { @@ -80,6 +112,23 @@ public class WikiRawMaterialService { return rawMapper.selectById(id); } + /** + * Ids of all raw materials in {@code kbId} with the given processing + * status, newest first. Used by the batch reprocess/delete endpoints to + * resolve a status selector (e.g. "retry all failed") server-side so the + * client doesn't have to enumerate ids. + */ + public List selectIdsByStatus(Long kbId, String status) { + if (status == null || status.isBlank()) return List.of(); + return rawMapper.selectList( + new LambdaQueryWrapper() + .select(WikiRawMaterialEntity::getId) + .eq(WikiRawMaterialEntity::getKbId, kbId) + .eq(WikiRawMaterialEntity::getProcessingStatus, status) + .orderByDesc(WikiRawMaterialEntity::getCreateTime)) + .stream().map(WikiRawMaterialEntity::getId).toList(); + } + public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) { return rawMapper.selectOne( new LambdaQueryWrapper() diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCrossKbTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCrossKbTest.java new file mode 100644 index 00000000..11298bda --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCrossKbTest.java @@ -0,0 +1,64 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Coverage for cross-KB wikilink targets ({@code [[kbId/slug]]}): parsing and + * the broken-link exemption. A cross-KB target must never be flagged broken by + * the single-KB lint (existence can only be checked in the target KB), while a + * plain single-KB target keeps its exact slug/title resolution. + */ +class WikiLinkServiceCrossKbTest { + + private final WikiLinkService svc = new WikiLinkService(new ObjectMapper()); + + @Test + void parseCrossKb_recognisesNumericPrefix() { + WikiLinkService.CrossKbRef ref = svc.parseCrossKb("2055137662148763649/photosynthesis"); + assertNotNull(ref); + assertEquals(2055137662148763649L, ref.kbId()); + assertEquals("photosynthesis", ref.slug()); + } + + @Test + void parseCrossKb_ignoresPlainSlugAndTitle() { + assertNull(svc.parseCrossKb("photosynthesis")); + assertNull(svc.parseCrossKb("Energy Metabolism")); + // Slug-shaped but non-numeric prefix stays single-KB. + assertNull(svc.parseCrossKb("chapter/section")); + // Numeric prefix but empty slug is not a valid cross-KB ref. + assertNull(svc.parseCrossKb("123/")); + assertNull(svc.parseCrossKb(null)); + } + + @Test + void computeBrokenLinks_exemptsCrossKbTargets() { + Set outlinks = svc.extractOutlinks( + "See [[123/photosynthesis]] and [[missing-local]] and [[known-local]]."); + // Only this KB's own slug is resolvable. + Set resolvable = Set.of("known-local"); + List broken = svc.computeBrokenLinks(outlinks, resolvable); + // Cross-KB target exempt; local unknown flagged; local known resolves. + assertTrue(broken.contains("missing-local")); + assertFalse(broken.contains("123/photosynthesis"), + "cross-KB target must not be flagged broken by the single-KB lint"); + assertFalse(broken.contains("known-local")); + } + + @Test + void extractOutlinks_keepsCrossKbTargetVerbatim() { + Set outlinks = svc.extractOutlinks("Ref [[456/Some-Page|display]]."); + // Lowercased, alias stripped, prefix preserved. + assertTrue(outlinks.contains("456/some-page")); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 5321165d..b0756329 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -824,7 +824,10 @@ export const wikiApi = { listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`), // Raw Materials - listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`), + listRaw: ( + kbId: number, + filters?: { status?: string; sourceType?: string; keyword?: string; startTime?: string; endTime?: string }, + ) => http.get(`/wiki/knowledge-bases/${kbId}/raw`, filters ? { params: filters } : undefined), addRawText: (kbId: number, data: { title: string; content: string }) => http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data), uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) => @@ -840,6 +843,16 @@ export const wikiApi = { http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`), cancelRaw: (kbId: number, rawId: number) => http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/cancel`), + // Batch reprocess/delete. Select by explicit `ids` (Snowflake strings — never + // coerce to number) or by a `status` selector (e.g. retry all failed). + batchReprocessRaw: ( + kbId: number, + body: { ids?: (string | number)[]; status?: string; force?: boolean }, + ) => http.post(`/wiki/knowledge-bases/${kbId}/raw/batch/reprocess`, body), + batchDeleteRaw: ( + kbId: number, + body: { ids?: (string | number)[]; status?: string }, + ) => http.post(`/wiki/knowledge-bases/${kbId}/raw/batch/delete`, body), downloadRaw: (kbId: number, rawId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, { responseType: 'blob', diff --git a/mateclaw-ui/src/composables/__tests__/wikilink.test.ts b/mateclaw-ui/src/composables/__tests__/wikilink.test.ts index f8398973..e0c94730 100644 --- a/mateclaw-ui/src/composables/__tests__/wikilink.test.ts +++ b/mateclaw-ui/src/composables/__tests__/wikilink.test.ts @@ -128,6 +128,46 @@ describe('resolveWikilink — safety cases', () => { }) }) +// --------------------------------------------------------------------------- +// resolveWikilink — cross-KB targets [[kbId/slug]] +// --------------------------------------------------------------------------- +describe('resolveWikilink — cross-KB', () => { + it('resolves [[kbId/slug]] to a cross-kb link', () => { + const r = resolveWikilink('2055137662148763649/photosynthesis', REFS) + expect(r).toEqual({ + kind: 'cross-kb', + kbId: '2055137662148763649', + slug: 'photosynthesis', + display: 'photosynthesis', + }) + }) + + it('honours explicit display for cross-KB targets', () => { + const r = resolveWikilink('123/some-page|Some Page', REFS) + expect(r.kind).toBe('cross-kb') + if (r.kind === 'cross-kb') { + expect(r.kbId).toBe('123') + expect(r.slug).toBe('some-page') + expect(r.display).toBe('Some Page') + } + }) + + it('does not treat a non-numeric prefix as cross-KB', () => { + // Falls through to normal (broken here) single-KB resolution. + expect(resolveWikilink('chapter/section', REFS).kind).toBe('broken') + }) + + it('renders a cross-KB anchor with data-kbid + data-slug', () => { + const root = document.createElement('div') + root.innerHTML = 'See [[123/photosynthesis]].' + postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS)) + const a = root.querySelector('a.wiki-link-crosskb') as HTMLAnchorElement + expect(a).not.toBeNull() + expect(a.getAttribute('data-kbid')).toBe('123') + expect(a.getAttribute('data-slug')).toBe('photosynthesis') + }) +}) + // --------------------------------------------------------------------------- // postprocessWikilinks — DOM walker behaviour // --------------------------------------------------------------------------- diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 03780ba3..f14619a0 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -16,6 +16,7 @@ export type SSEEventType = | 'message_start' // Agent 事件 | 'tool_call_started' + | 'tool_call_progress' | 'tool_call_completed' | 'phase' | 'plan_created' diff --git a/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts b/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts index e626c834..541e5e33 100644 --- a/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts +++ b/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts @@ -63,6 +63,17 @@ export function useGlobalWikilinkClick() { e.preventDefault() e.stopPropagation() + // Cross-KB reference [[kbId/slug]] — the token carries an explicit numeric + // KB id, so route straight to that KB instead of guessing via lookup. + const crossKb = /^(\d+)\/(.+)$/.exec(title.trim()) + if (crossKb) { + const slug = crossKb[2].trim() + if (slug) { + router.push({ name: 'Wiki', query: { kbId: crossKb[1], slug } }) + return + } + } + try { // Pass both — backend matches slug first, falls back to title. For // a bracket like `[[StateGraph]]` the captured "title" is actually diff --git a/mateclaw-ui/src/composables/wikilink.ts b/mateclaw-ui/src/composables/wikilink.ts index 956079e8..8ad1ec10 100644 --- a/mateclaw-ui/src/composables/wikilink.ts +++ b/mateclaw-ui/src/composables/wikilink.ts @@ -31,6 +31,7 @@ export interface WikilinkRef { /** Result of resolving a single `[[...]]` target string. */ export type WikilinkResolution = | { kind: 'hit'; slug: string; display: string; archived: boolean } + | { kind: 'cross-kb'; kbId: string; slug: string; display: string } | { kind: 'broken'; display: string; reason: 'empty' | 'dangerous' | 'too-long' | 'unknown' } /** @@ -61,6 +62,14 @@ const MAX_SLUG_LEN = 256 /** `[[...]]` matcher used during text-node walking. Non-greedy. */ const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g +/** + * Cross-KB target matcher: `kbId/slug` where `kbId` is a numeric knowledge-base + * id. A plain single-KB slug never contains `/`, so this is unambiguous and + * historical `[[slug]]` / `[[Title]]` content is unaffected. Mirrors the + * backend `WikiLinkService.CROSS_KB` pattern — both must stay in lockstep. + */ +const CROSS_KB_RE = /^(\d+)\/(.+)$/ + /** * Resolve a single raw target into a render directive. * @@ -115,6 +124,19 @@ export function resolveWikilink( return { kind: 'broken', display: literal, reason: 'too-long' } } + // Cross-KB reference `[[kbId/slug]]`: resolve to a deterministic link into + // the target KB. Existence is not checked here (that would need the target + // KB's refs) — the target KB's viewer surfaces page-not-found on click if + // the slug is stale. Display falls back to the slug since the target page's + // title lives in another KB. + const crossKb = CROSS_KB_RE.exec(target) + if (crossKb) { + const slug = crossKb[2].trim() + if (slug) { + return { kind: 'cross-kb', kbId: crossKb[1], slug, display: explicitDisplay || slug } + } + } + const lookupSlug = target.toLowerCase() const lookupTitle = target.trim().toLowerCase() @@ -168,6 +190,20 @@ function buildLinkElement( a.textContent = resolution.display return a } + if (resolution.kind === 'cross-kb') { + const a = doc.createElement('a') + a.className = 'wiki-link wiki-link-crosskb' + // data-slug keeps the existing click contract; data-kbid signals the + // viewer's click handler to route into a different KB rather than the + // current one. + a.setAttribute('data-slug', resolution.slug) + a.setAttribute('data-kbid', resolution.kbId) + a.setAttribute('role', 'link') + a.setAttribute('tabindex', '0') + a.setAttribute('title', 'Cross-KB link') + a.textContent = resolution.display + return a + } const span = doc.createElement('span') span.className = 'wiki-link-broken' span.setAttribute('title', `Target not found (${resolution.reason})`) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index f159d8c5..a87cf066 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2567,6 +2567,22 @@ export default { cancelled: 'CANCELLED', cancelling: 'CANCELLING…', }, + // Raw-material filtering + batch operations (issue #506). + batch: { + allStatus: 'All statuses', + allTypes: 'All types', + keywordPlaceholder: 'Search title…', + clearFilter: 'Clear filter', + noMatch: 'No matching materials', + selectAll: 'Select all', + selectedCount: '{n} selected', + reprocess: 'Reprocess', + delete: 'Delete', + retryAllFailed: 'Retry all failed ({n})', + deleteConfirm: 'Delete the {n} selected material(s)? Their generated pages are cascade-deleted and this cannot be undone.', + reprocessDone: 'Re-queued {n} item(s)', + deleteDone: 'Deleted {n} item(s)', + }, // Friendly, localized hints keyed by the backend's structured error code. // The raw exception text is kept as the hover tooltip for troubleshooting. errorCode: { diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 50783f5e..663cebd1 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2579,6 +2579,22 @@ export default { cancelled: '已取消', cancelling: '正在取消…', }, + // Raw-material filtering + batch operations (issue #506). + batch: { + allStatus: '全部状态', + allTypes: '全部类型', + keywordPlaceholder: '搜索标题…', + clearFilter: '清除筛选', + noMatch: '没有匹配的材料', + selectAll: '全选', + selectedCount: '已选 {n} 项', + reprocess: '批量重试', + delete: '批量删除', + retryAllFailed: '重试全部失败({n})', + deleteConfirm: '确定删除选中的 {n} 项材料?将级联删除其生成的页面,且不可撤销。', + reprocessDone: '已重新排队 {n} 项', + deleteDone: '已删除 {n} 项', + }, // Friendly, localized hints keyed by the backend's structured error code. // The raw exception text is kept as the hover tooltip for troubleshooting. errorCode: { diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index 72a5f893..466f29a5 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -148,6 +148,16 @@ export const useWikiStore = defineStore('wiki', () => { const selectedRawId = ref(null) const totalPageCount = ref(0) + // Raw-material list query filters (status / sourceType / title keyword). + // Held in the store so they stay applied across background refreshes + // (SSE completion, job poller) — a filtered list must not silently snap + // back to the full list when a raw finishes processing. + const rawFilters = ref<{ status: string; sourceType: string; keyword: string }>({ + status: '', + sourceType: '', + keyword: '', + }) + // Active KB's pageType profile (parsed). Loaded alongside the KB so sidebar // grouping, graph colouring and the transformation editor can render the // KB's own classification. Null until a KB is selected / its profile loads. @@ -278,14 +288,37 @@ export const useWikiStore = defineStore('wiki', () => { if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null } brokenLinksLoading.value = false selectedRawId.value = null + rawFilters.value = { status: '', sourceType: '', keyword: '' } pageTypeProfile.value = null } async function fetchRawMaterials(kbId: number) { - const res: any = await wikiApi.listRaw(kbId) + const f = rawFilters.value + const params = f.status || f.sourceType || f.keyword + ? { + status: f.status || undefined, + sourceType: f.sourceType || undefined, + keyword: f.keyword || undefined, + } + : undefined + const res: any = await wikiApi.listRaw(kbId, params) rawMaterials.value = res.data || [] } + // Update the raw-material filters and refetch. Empty-string fields clear + // their clause. Used by the raw-material panel's filter bar. + async function setRawFilters( + kbId: number, + filters: Partial<{ status: string; sourceType: string; keyword: string }>, + ) { + rawFilters.value = { ...rawFilters.value, ...filters } + await fetchRawMaterials(kbId) + } + + function resetRawFilters() { + rawFilters.value = { status: '', sourceType: '', keyword: '' } + } + async function fetchPages(kbId: number, rawId?: number | null) { const res: any = await wikiApi.listPages(kbId, rawId ?? undefined) pages.value = res.data || [] @@ -474,6 +507,9 @@ export const useWikiStore = defineStore('wiki', () => { loading, selectedRawId, totalPageCount, + rawFilters, + setRawFilters, + resetRawFilters, pageTypeProfile, pageRefs, archivedPageRefs, diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index 2700443f..96e62dcc 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -97,8 +97,61 @@

{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})

+ + +
+
+ + + + +
+ +
+ + +
+ +
+ + +
+
+
- {{ t('wiki.noRawMaterials') }} + {{ hasActiveFilter ? t('wiki.batch.noMatch') : t('wiki.noRawMaterials') }}
@@ -156,6 +209,13 @@ @click="toggleRawFilter(raw.id)" >
+
{{ raw.title }} {{ raw.sourceType }} @@ -316,6 +376,7 @@ import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { mcToast } from '@/composables/useMcToast' +import { mcConfirm } from '@/components/common/useConfirm' import { useFileDrop } from '@/composables/useFileDrop' import { Download } from '@element-plus/icons-vue' import { useWikiStore } from '@/stores/useWikiStore' @@ -333,6 +394,139 @@ const workspace = useWorkspaceStore() const canManageWiki = computed(() => workspace.can('manage:wiki')) const fileInput = ref(null) +// ---- Raw-material filtering + batch operations (issue #506) -------------- +// Processing states and source types used to populate the filter dropdowns. +const RAW_STATUSES = ['pending', 'processing', 'completed', 'failed', 'partial', 'cancelled'] as const +const RAW_SOURCE_TYPES = ['text', 'pdf', 'docx', 'xlsx', 'pptx', 'html', 'image', 'url'] as const + +// Local mirrors of the store's filter state; edits flow back through +// store.setRawFilters so background refreshes keep the filter applied. +const filterStatus = ref(store.rawFilters.status) +const filterSourceType = ref(store.rawFilters.sourceType) +const filterKeyword = ref(store.rawFilters.keyword) + +const hasActiveFilter = computed( + () => !!(filterStatus.value || filterSourceType.value || filterKeyword.value), +) +const failedCount = computed( + () => store.rawMaterials.filter(r => r.processingStatus === 'failed').length, +) + +// Selected raw ids for batch ops. Keyed by String(id) — raw ids are Snowflake +// values and must stay strings (never coerce to number). +const selectedIds = ref>(new Set()) +const batchBusy = ref(false) +const allSelected = computed( + () => store.rawMaterials.length > 0 + && store.rawMaterials.every(r => selectedIds.value.has(String(r.id))), +) + +function applyFilters() { + if (!store.currentKB) return + clearSelection() + void store.setRawFilters(store.currentKB.id, { + status: filterStatus.value, + sourceType: filterSourceType.value, + keyword: filterKeyword.value.trim(), + }) +} + +// Debounce keyword typing so we don't refetch on every keystroke. +let keywordTimer: ReturnType | null = null +watch(filterKeyword, () => { + if (keywordTimer) clearTimeout(keywordTimer) + keywordTimer = setTimeout(applyFilters, 350) +}) + +function clearFilters() { + filterStatus.value = '' + filterSourceType.value = '' + filterKeyword.value = '' + applyFilters() +} + +function toggleSelect(rawId: number | string) { + const key = String(rawId) + const next = new Set(selectedIds.value) + if (next.has(key)) next.delete(key) + else next.add(key) + selectedIds.value = next +} + +function toggleSelectAll() { + if (allSelected.value) { + selectedIds.value = new Set() + } else { + selectedIds.value = new Set(store.rawMaterials.map(r => String(r.id))) + } +} + +function clearSelection() { + selectedIds.value = new Set() +} + +async function batchReprocess() { + if (!store.currentKB || selectedIds.value.size === 0) return + const kbId = store.currentKB.id + batchBusy.value = true + try { + const res: any = await wikiApi.batchReprocessRaw(kbId, { ids: [...selectedIds.value] }) + const d = res.data || res || {} + mcToast.success(t('wiki.batch.reprocessDone', { n: d.processed ?? 0 })) + clearSelection() + await store.fetchRawMaterials(kbId) + setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000) + setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000) + } catch (e) { + mcToast.error(e instanceof Error ? e.message : String(e)) + } finally { + batchBusy.value = false + } +} + +async function batchDelete() { + if (!store.currentKB || selectedIds.value.size === 0) return + const ok = await mcConfirm({ + title: t('wiki.batch.delete'), + message: t('wiki.batch.deleteConfirm', { n: selectedIds.value.size }), + confirmText: t('common.delete'), + tone: 'danger', + }) + if (!ok) return + const kbId = store.currentKB.id + batchBusy.value = true + try { + const res: any = await wikiApi.batchDeleteRaw(kbId, { ids: [...selectedIds.value] }) + const d = res.data || res || {} + mcToast.success(t('wiki.batch.deleteDone', { n: d.deleted ?? 0 })) + clearSelection() + await store.fetchRawMaterials(kbId) + } catch (e) { + mcToast.error(e instanceof Error ? e.message : String(e)) + } finally { + batchBusy.value = false + } +} + +async function retryAllFailed() { + if (!store.currentKB) return + const kbId = store.currentKB.id + batchBusy.value = true + try { + const res: any = await wikiApi.batchReprocessRaw(kbId, { status: 'failed' }) + const d = res.data || res || {} + mcToast.success(t('wiki.batch.reprocessDone', { n: d.processed ?? 0 })) + clearSelection() + await store.fetchRawMaterials(kbId) + setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000) + setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000) + } catch (e) { + mcToast.error(e instanceof Error ? e.message : String(e)) + } finally { + batchBusy.value = false + } +} + // Map a structured backend errorCode to a localized, user-friendly hint. // Falls back to the raw backend message, then to a generic failure label, so // the user always sees something meaningful — never a blank "failed" badge. @@ -567,6 +761,15 @@ watch(() => store.rawMaterials, (rows) => { } }, { deep: true }) +// Prune batch selections that no longer exist after a refresh (deleted rows, +// or rows filtered out of the current view). +watch(() => store.rawMaterials, (rows) => { + if (selectedIds.value.size === 0) return + const present = new Set(rows.map(r => String(r.id))) + const next = new Set([...selectedIds.value].filter(id => present.has(id))) + if (next.size !== selectedIds.value.size) selectedIds.value = next +}) + async function handleLocalRepair(rawId: number) { if (!store.currentKB) return // For local repair, we'd need a page slug. For now, reprocess the raw material. @@ -987,4 +1190,69 @@ async function handleScanDir() { justify-content: space-between; } } + +/* ---- Filter bar + batch operations (issue #506) ---- */ +.raw-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} +.raw-filters { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.raw-filter-select, +.raw-filter-input { + padding: 5px 10px; + font-size: 12px; + border: 1px solid var(--mc-border); + border-radius: 8px; + background: var(--mc-bg-elevated); + color: var(--mc-text-primary); +} +.raw-filter-input { min-width: 140px; } +.raw-filter-select:focus, +.raw-filter-input:focus { outline: none; border-color: var(--mc-primary); } +.btn-text { + padding: 4px 8px; + font-size: 12px; + background: none; + border: none; + color: var(--mc-primary); + cursor: pointer; + border-radius: 6px; +} +.btn-text:hover:not(:disabled) { background: var(--mc-bg-sunken); } +.btn-text:disabled { color: var(--mc-text-tertiary); cursor: not-allowed; } +.btn-danger-text { color: var(--mc-danger, #e05252); } +.raw-selection-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + margin-bottom: 8px; + background: var(--mc-bg-sunken); + border-radius: 8px; +} +.raw-select-all { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--mc-text-secondary); + cursor: pointer; +} +.raw-selection-actions { display: inline-flex; align-items: center; gap: 4px; } +.raw-select-box { + display: inline-flex; + align-items: center; + margin-right: 8px; + cursor: pointer; +} diff --git a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue index 0c25cfca..24d4d2d4 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue @@ -98,6 +98,7 @@