feat(wiki): cross-KB wikilinks [[kbId/slug]] and raw-material batch filter/reprocess/delete (#506)

This commit is contained in:
matevip 2026-07-10 12:01:48 +08:00
parent 84bbf1cb3e
commit 71ad735e95
14 changed files with 755 additions and 7 deletions

View File

@ -36,6 +36,9 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -509,13 +512,19 @@ public class WikiController {
// ==================== Raw Materials ==================== // ==================== Raw Materials ====================
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("viewer")
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数)") @Operation(summary = "获取原始材料列表(含每条材料生成的页面数),支持按状态/类型/关键词/时间筛选")
@GetMapping("/knowledge-bases/{kbId}/raw") @GetMapping("/knowledge-bases/{kbId}/raw")
public R<List<Map<String, Object>>> listRaw(@PathVariable Long kbId, public R<List<Map<String, Object>>> 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) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId); verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> raws = rawService.listByKbId(kbId); List<WikiRawMaterialEntity> raws = rawService.listByKbIdFiltered(
List<Map<String, Object>> result = new java.util.ArrayList<>(raws.size()); kbId, status, sourceType, keyword, parseTime(startTime, false), parseTime(endTime, true));
List<Map<String, Object>> result = new ArrayList<>(raws.size());
for (WikiRawMaterialEntity raw : raws) { for (WikiRawMaterialEntity raw : raws) {
Map<String, Object> item = new LinkedHashMap<>(); Map<String, Object> item = new LinkedHashMap<>();
// Serialize all entity fields via Jackson-friendly approach // Serialize all entity fields via Jackson-friendly approach
@ -648,6 +657,137 @@ public class WikiController {
return R.ok(); 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<Map<String, Object>> batchReprocessRaw(@PathVariable Long kbId,
@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<Long> 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<Map<String, Object>> batchDeleteRaw(@PathVariable Long kbId,
@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<Long> 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<Long> resolveBatchIds(Long kbId, Map<String, Object> body) {
Object rawIds = body.get("ids");
if (rawIds instanceof List<?> list && !list.isEmpty()) {
List<Long> 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<Long> 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") @RequireWorkspaceRole("viewer")
@Operation(summary = "下载原始材料") @Operation(summary = "下载原始材料")
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download") @GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")

View File

@ -55,6 +55,15 @@ public class WikiLinkService {
*/ */
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]"); 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 * 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 * 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(); if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet();
List<String> broken = new ArrayList<>(); List<String> broken = new ArrayList<>();
for (String t : outlinks) { 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); if (!resolvableKeysLower.contains(t)) broken.add(t);
} }
return broken; 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 * Convenience: extract + compute in one call. Used from
* {@code WikiPageService.save/update} where both fields are written in * {@code WikiPageService.save/update} where both fields are written in

View File

@ -20,6 +20,7 @@ import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import vip.mate.wiki.dto.WikiFailureItem; import vip.mate.wiki.dto.WikiFailureItem;
@ -64,9 +65,40 @@ public class WikiRawMaterialService {
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet(); private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
public List<WikiRawMaterialEntity> listByKbId(Long kbId) { public List<WikiRawMaterialEntity> 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.
* <p>
* 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<WikiRawMaterialEntity> listByKbIdFiltered(Long kbId, String status, String sourceType,
String keyword,
LocalDateTime startTime, LocalDateTime endTime) {
List<WikiRawMaterialEntity> list = rawMapper.selectList( List<WikiRawMaterialEntity> list = rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>() new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId) .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)); .orderByDesc(WikiRawMaterialEntity::getCreateTime));
// 不返回大文本字段 // 不返回大文本字段
list.forEach(r -> { list.forEach(r -> {
@ -80,6 +112,23 @@ public class WikiRawMaterialService {
return rawMapper.selectById(id); 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<Long> selectIdsByStatus(Long kbId, String status) {
if (status == null || status.isBlank()) return List.of();
return rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.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) { public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) {
return rawMapper.selectOne( return rawMapper.selectOne(
new LambdaQueryWrapper<WikiRawMaterialEntity>() new LambdaQueryWrapper<WikiRawMaterialEntity>()

View File

@ -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<String> outlinks = svc.extractOutlinks(
"See [[123/photosynthesis]] and [[missing-local]] and [[known-local]].");
// Only this KB's own slug is resolvable.
Set<String> resolvable = Set.of("known-local");
List<String> 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<String> outlinks = svc.extractOutlinks("Ref [[456/Some-Page|display]].");
// Lowercased, alias stripped, prefix preserved.
assertTrue(outlinks.contains("456/some-page"));
}
}

View File

@ -824,7 +824,10 @@ export const wikiApi = {
listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`), listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`),
// Raw Materials // 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 }) => addRawText: (kbId: number, data: { title: string; content: string }) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data), http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) => 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`), http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
cancelRaw: (kbId: number, rawId: number) => cancelRaw: (kbId: number, rawId: number) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/cancel`), 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) => downloadRaw: (kbId: number, rawId: number) =>
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, { http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
responseType: 'blob', responseType: 'blob',

View File

@ -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 // postprocessWikilinks — DOM walker behaviour
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -16,6 +16,7 @@ export type SSEEventType =
| 'message_start' | 'message_start'
// Agent 事件 // Agent 事件
| 'tool_call_started' | 'tool_call_started'
| 'tool_call_progress'
| 'tool_call_completed' | 'tool_call_completed'
| 'phase' | 'phase'
| 'plan_created' | 'plan_created'

View File

@ -63,6 +63,17 @@ export function useGlobalWikilinkClick() {
e.preventDefault() e.preventDefault()
e.stopPropagation() 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 { try {
// Pass both — backend matches slug first, falls back to title. For // Pass both — backend matches slug first, falls back to title. For
// a bracket like `[[StateGraph]]` the captured "title" is actually // a bracket like `[[StateGraph]]` the captured "title" is actually

View File

@ -31,6 +31,7 @@ export interface WikilinkRef {
/** Result of resolving a single `[[...]]` target string. */ /** Result of resolving a single `[[...]]` target string. */
export type WikilinkResolution = export type WikilinkResolution =
| { kind: 'hit'; slug: string; display: string; archived: boolean } | { 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' } | { 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. */ /** `[[...]]` matcher used during text-node walking. Non-greedy. */
const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g 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. * Resolve a single raw target into a render directive.
* *
@ -115,6 +124,19 @@ export function resolveWikilink(
return { kind: 'broken', display: literal, reason: 'too-long' } 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 lookupSlug = target.toLowerCase()
const lookupTitle = target.trim().toLowerCase() const lookupTitle = target.trim().toLowerCase()
@ -168,6 +190,20 @@ function buildLinkElement(
a.textContent = resolution.display a.textContent = resolution.display
return a 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') const span = doc.createElement('span')
span.className = 'wiki-link-broken' span.className = 'wiki-link-broken'
span.setAttribute('title', `Target not found (${resolution.reason})`) span.setAttribute('title', `Target not found (${resolution.reason})`)

View File

@ -2567,6 +2567,22 @@ export default {
cancelled: 'CANCELLED', cancelled: 'CANCELLED',
cancelling: 'CANCELLING…', 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. // Friendly, localized hints keyed by the backend's structured error code.
// The raw exception text is kept as the hover tooltip for troubleshooting. // The raw exception text is kept as the hover tooltip for troubleshooting.
errorCode: { errorCode: {

View File

@ -2579,6 +2579,22 @@ export default {
cancelled: '已取消', cancelled: '已取消',
cancelling: '正在取消…', 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. // Friendly, localized hints keyed by the backend's structured error code.
// The raw exception text is kept as the hover tooltip for troubleshooting. // The raw exception text is kept as the hover tooltip for troubleshooting.
errorCode: { errorCode: {

View File

@ -148,6 +148,16 @@ export const useWikiStore = defineStore('wiki', () => {
const selectedRawId = ref<number | null>(null) const selectedRawId = ref<number | null>(null)
const totalPageCount = ref(0) 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 // Active KB's pageType profile (parsed). Loaded alongside the KB so sidebar
// grouping, graph colouring and the transformation editor can render the // grouping, graph colouring and the transformation editor can render the
// KB's own classification. Null until a KB is selected / its profile loads. // 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 } if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
brokenLinksLoading.value = false brokenLinksLoading.value = false
selectedRawId.value = null selectedRawId.value = null
rawFilters.value = { status: '', sourceType: '', keyword: '' }
pageTypeProfile.value = null pageTypeProfile.value = null
} }
async function fetchRawMaterials(kbId: number) { 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 || [] 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) { async function fetchPages(kbId: number, rawId?: number | null) {
const res: any = await wikiApi.listPages(kbId, rawId ?? undefined) const res: any = await wikiApi.listPages(kbId, rawId ?? undefined)
pages.value = res.data || [] pages.value = res.data || []
@ -474,6 +507,9 @@ export const useWikiStore = defineStore('wiki', () => {
loading, loading,
selectedRawId, selectedRawId,
totalPageCount, totalPageCount,
rawFilters,
setRawFilters,
resetRawFilters,
pageTypeProfile, pageTypeProfile,
pageRefs, pageRefs,
archivedPageRefs, archivedPageRefs,

View File

@ -97,8 +97,61 @@
<h4 class="raw-list-title"> <h4 class="raw-list-title">
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }}) {{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
</h4> </h4>
<!-- Filter bar: status / source type / title keyword. Filters are held in
the store so they survive background refreshes. -->
<div v-if="store.rawMaterials.length > 0 || hasActiveFilter" class="raw-toolbar">
<div class="raw-filters">
<select v-model="filterStatus" class="raw-filter-select" @change="applyFilters">
<option value="">{{ t('wiki.batch.allStatus') }}</option>
<option v-for="s in RAW_STATUSES" :key="s" :value="s">{{ t(`wiki.status.${s}`) }}</option>
</select>
<select v-model="filterSourceType" class="raw-filter-select" @change="applyFilters">
<option value="">{{ t('wiki.batch.allTypes') }}</option>
<option v-for="st in RAW_SOURCE_TYPES" :key="st" :value="st">{{ st }}</option>
</select>
<input
v-model="filterKeyword"
type="text"
class="raw-filter-input"
:placeholder="t('wiki.batch.keywordPlaceholder')"
/>
<button v-if="hasActiveFilter" class="btn-text" @click="clearFilters">
{{ t('wiki.batch.clearFilter') }}
</button>
</div>
<button
v-if="canManageWiki && failedCount > 0"
class="btn-text retry-all-failed"
:disabled="batchBusy"
@click="retryAllFailed"
>
{{ t('wiki.batch.retryAllFailed', { n: failedCount }) }}
</button>
</div>
<!-- Selection + batch actions. Always shown (when manageable and rows
exist); the action buttons are disabled until at least one row is
selected. -->
<div v-if="canManageWiki && store.rawMaterials.length > 0" class="raw-selection-bar">
<label class="raw-select-all" @click.stop>
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
<span>{{ selectedIds.size > 0
? t('wiki.batch.selectedCount', { n: selectedIds.size })
: t('wiki.batch.selectAll') }}</span>
</label>
<div class="raw-selection-actions">
<button class="btn-text" :disabled="batchBusy || selectedIds.size === 0" @click="batchReprocess">
{{ t('wiki.batch.reprocess') }}
</button>
<button class="btn-text btn-danger-text" :disabled="batchBusy || selectedIds.size === 0" @click="batchDelete">
{{ t('wiki.batch.delete') }}
</button>
</div>
</div>
<div v-if="store.rawMaterials.length === 0 && uploadingFiles.length === 0" class="empty-hint"> <div v-if="store.rawMaterials.length === 0 && uploadingFiles.length === 0" class="empty-hint">
{{ t('wiki.noRawMaterials') }} {{ hasActiveFilter ? t('wiki.batch.noMatch') : t('wiki.noRawMaterials') }}
</div> </div>
<!-- Optimistic uploading items shown at the top --> <!-- Optimistic uploading items shown at the top -->
@ -156,6 +209,13 @@
@click="toggleRawFilter(raw.id)" @click="toggleRawFilter(raw.id)"
> >
<div class="raw-item-row"> <div class="raw-item-row">
<label v-if="canManageWiki" class="raw-select-box" @click.stop>
<input
type="checkbox"
:checked="selectedIds.has(String(raw.id))"
@change="toggleSelect(raw.id)"
/>
</label>
<div class="raw-item-info"> <div class="raw-item-info">
<span class="raw-item-title">{{ raw.title }}</span> <span class="raw-item-title">{{ raw.title }}</span>
<span class="raw-item-type">{{ raw.sourceType }}</span> <span class="raw-item-type">{{ raw.sourceType }}</span>
@ -316,6 +376,7 @@
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue' import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast' import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
import { useFileDrop } from '@/composables/useFileDrop' import { useFileDrop } from '@/composables/useFileDrop'
import { Download } from '@element-plus/icons-vue' import { Download } from '@element-plus/icons-vue'
import { useWikiStore } from '@/stores/useWikiStore' import { useWikiStore } from '@/stores/useWikiStore'
@ -333,6 +394,139 @@ const workspace = useWorkspaceStore()
const canManageWiki = computed(() => workspace.can('manage:wiki')) const canManageWiki = computed(() => workspace.can('manage:wiki'))
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(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<Set<string>>(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<typeof setTimeout> | 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. // 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 // 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. // the user always sees something meaningful never a blank "failed" badge.
@ -567,6 +761,15 @@ watch(() => store.rawMaterials, (rows) => {
} }
}, { deep: true }) }, { 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) { async function handleLocalRepair(rawId: number) {
if (!store.currentKB) return if (!store.currentKB) return
// For local repair, we'd need a page slug. For now, reprocess the raw material. // 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; 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;
}
</style> </style>

View File

@ -98,6 +98,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore' import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { wikiApi } from '@/api/index' import { wikiApi } from '@/api/index'
@ -110,6 +111,7 @@ import CitationDrawer from './CitationDrawer.vue'
import ImageLightbox from './ImageLightbox.vue' import ImageLightbox from './ImageLightbox.vue'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const store = useWikiStore() const store = useWikiStore()
const workspace = useWorkspaceStore() const workspace = useWorkspaceStore()
const { renderMarkdown } = useMarkdownRenderer() const { renderMarkdown } = useMarkdownRenderer()
@ -280,7 +282,17 @@ onMounted(() => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.classList.contains('wiki-link')) { if (target.classList.contains('wiki-link')) {
const slug = target.dataset.slug const slug = target.dataset.slug
if (slug) openPage(slug) if (!slug) return
// Cross-KB link [[kbId/slug]] data-kbid points at a different KB.
// Route through the query contract the Wiki view reads (?kbId&slug)
// so the store loads the target KB rather than the current one. IDs
// stay strings end-to-end (Snowflake precision).
const kbId = target.dataset.kbid
if (kbId && kbId !== String(store.currentKB?.id ?? '')) {
router.push({ name: 'Wiki', query: { kbId, slug } })
return
}
openPage(slug)
} }
}) })
}) })
@ -359,6 +371,10 @@ onMounted(() => {
font-style: italic; font-style: italic;
} }
.page-content :deep(.wiki-link.wiki-link-archived:hover) { color: var(--mc-text-secondary); } .page-content :deep(.wiki-link.wiki-link-archived:hover) { color: var(--mc-text-secondary); }
/* Cross-KB target clickable, routes to another knowledge base. Solid
underline distinguishes it from same-KB (dashed) links so readers know the
click leaves the current KB. */
.page-content :deep(.wiki-link.wiki-link-crosskb) { border-bottom-style: solid; }
/* Broken target no click, no href, no request. Dashed underline + muted tone /* Broken target no click, no href, no request. Dashed underline + muted tone
tells the reader the wikilink couldn't be resolved without committing to tells the reader the wikilink couldn't be resolved without committing to
navigation that would 404. Tooltip shows the rejection reason. */ navigation that would 404. Tooltip shows the rejection reason. */