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.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<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) {
verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> raws = rawService.listByKbId(kbId);
List<Map<String, Object>> result = new java.util.ArrayList<>(raws.size());
List<WikiRawMaterialEntity> raws = rawService.listByKbIdFiltered(
kbId, status, sourceType, keyword, parseTime(startTime, false), parseTime(endTime, true));
List<Map<String, Object>> result = new ArrayList<>(raws.size());
for (WikiRawMaterialEntity raw : raws) {
Map<String, Object> 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<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")
@Operation(summary = "下载原始材料")
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")

View File

@ -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<String> 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

View File

@ -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<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
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(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.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<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) {
return rawMapper.selectOne(
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}`),
// 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<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
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
// ---------------------------------------------------------------------------

View File

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

View File

@ -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

View File

@ -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})`)

View File

@ -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: {

View File

@ -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: {

View File

@ -148,6 +148,16 @@ export const useWikiStore = defineStore('wiki', () => {
const selectedRawId = ref<number | null>(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,

View File

@ -97,8 +97,61 @@
<h4 class="raw-list-title">
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
</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">
{{ t('wiki.noRawMaterials') }}
{{ hasActiveFilter ? t('wiki.batch.noMatch') : t('wiki.noRawMaterials') }}
</div>
<!-- Optimistic uploading items shown at the top -->
@ -156,6 +209,13 @@
@click="toggleRawFilter(raw.id)"
>
<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">
<span class="raw-item-title">{{ raw.title }}</span>
<span class="raw-item-type">{{ raw.sourceType }}</span>
@ -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<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.
// 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;
}
</style>

View File

@ -98,6 +98,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { wikiApi } from '@/api/index'
@ -110,6 +111,7 @@ import CitationDrawer from './CitationDrawer.vue'
import ImageLightbox from './ImageLightbox.vue'
const { t } = useI18n()
const router = useRouter()
const store = useWikiStore()
const workspace = useWorkspaceStore()
const { renderMarkdown } = useMarkdownRenderer()
@ -280,7 +282,17 @@ onMounted(() => {
const target = e.target as HTMLElement
if (target.classList.contains('wiki-link')) {
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;
}
.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
tells the reader the wikilink couldn't be resolved without committing to
navigation that would 404. Tooltip shows the rejection reason. */