Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions src/components/settings/sections/maintenance-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { useWikiStore } from "@/stores/wiki-store"
import { hasUsableLlm } from "@/lib/has-usable-llm"
import { runDuplicateDetection } from "@/lib/dedup-runner"
import { runDuplicateDetection, type DuplicateDetectionProgress } from "@/lib/dedup-runner"
import { addNotDuplicate } from "@/lib/dedup-storage"
import {
enqueueMerge,
Expand Down Expand Up @@ -71,6 +71,7 @@ export function MaintenanceSection() {
const [scanError, setScanError] = useState<string | null>(null)
const [groups, setGroups] = useState<GroupUiEntry[]>([])
const [scanCompleted, setScanCompleted] = useState(false)
const [scanProgress, setScanProgress] = useState<DuplicateDetectionProgress | null>(null)
const [projectToolStatus, setProjectToolStatus] = useState<string | null>(null)
const [projectToolBusy, setProjectToolBusy] = useState(false)
const [historyStats, setHistoryStats] = useState<FileHistoryStats | null>(null)
Expand Down Expand Up @@ -254,8 +255,11 @@ export function MaintenanceSection() {
setScanError(null)
setGroups([])
setScanCompleted(false)
setScanProgress({ stage: "loading", completed: 0, total: 1 })
try {
const detected = await runDuplicateDetection(project.path, llmConfig)
const detected = await runDuplicateDetection(project.path, llmConfig, {
onProgress: setScanProgress,
})
setGroups(
detected.map((g) => ({
group: g,
Expand All @@ -268,6 +272,7 @@ export function MaintenanceSection() {
setScanError(err instanceof Error ? err.message : String(err))
} finally {
setScanning(false)
setScanProgress(null)
}
}, [project, llmConfig])

Expand Down Expand Up @@ -539,9 +544,21 @@ export function MaintenanceSection() {
{scanning ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("settings.sections.maintenance.dedup.scanning", {
defaultValue: "Scanning…",
})}
{scanProgress?.stage === "embedding" && scanProgress.total > 0
? t("settings.sections.maintenance.dedup.embeddingProgress", {
completed: scanProgress.completed,
total: scanProgress.total,
percent: Math.round((scanProgress.completed / scanProgress.total) * 100),
})
: scanProgress?.stage === "scanning" && scanProgress.total > 0
? t("settings.sections.maintenance.dedup.scanningProgress", {
completed: scanProgress.completed,
total: scanProgress.total,
percent: Math.round((scanProgress.completed / scanProgress.total) * 100),
})
: t("settings.sections.maintenance.dedup.scanning", {
defaultValue: "Scanning…",
})}
</>
) : (
t("settings.sections.maintenance.dedup.scanButton", {
Expand All @@ -550,6 +567,29 @@ export function MaintenanceSection() {
)}
</Button>

{scanning && (scanProgress?.stage === "embedding" || scanProgress?.stage === "scanning") && scanProgress.total > 0 && (
<div className="space-y-1.5" role="status" aria-live="polite">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t(
scanProgress.stage === "embedding"
? "settings.sections.maintenance.dedup.embeddingDetail"
: "settings.sections.maintenance.dedup.scanningDetail",
{
completed: scanProgress.completed,
total: scanProgress.total,
},
)}</span>
<span>{Math.round((scanProgress.completed / scanProgress.total) * 100)}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div
className="h-full rounded-full bg-foreground transition-[width] duration-300"
style={{ width: `${Math.round((scanProgress.completed / scanProgress.total) * 100)}%` }}
/>
</div>
</div>
)}

{scanError && (
<div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,10 @@
"description": "Asks the LLM to scan all entity / concept pages and group ones that likely refer to the same topic under different names (English vs Chinese, plural vs singular, abbreviation vs full form). You confirm each group before merging. Merges are queued and run one at a time so cross-references stay consistent.",
"scanButton": "Scan for duplicates",
"scanning": "Scanning…",
"scanningProgress": "Scanning batch {{completed}} / {{total}} ({{percent}}%)",
"scanningDetail": "Scanning batch {{completed}} / {{total}}",
"embeddingProgress": "Preparing embeddings {{completed}} / {{total}} ({{percent}}%)",
"embeddingDetail": "Preparing embeddings {{completed}} / {{total}}",
"noneFound": "No duplicate groups found. The wiki is clean.",
"candidates": "{{n}} candidates",
"canonicalLabel": "Keep this slug as canonical:",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,10 @@
"description": "Chiede all'LLM di analizzare tutte le pagine di entità / concetti e di raggruppare quelle che probabilmente si riferiscono allo stesso argomento con nomi diversi (lingue diverse, plurale o singolare, sigla o forma estesa). Confermi ogni gruppo prima dell'unione. Le unioni vengono messe in coda ed eseguite una alla volta, così i riferimenti incrociati restano coerenti.",
"scanButton": "Cerca duplicati",
"scanning": "Analisi…",
"scanningProgress": "Analisi batch {{completed}} / {{total}} ({{percent}}%)",
"scanningDetail": "Analisi batch {{completed}} / {{total}}",
"embeddingProgress": "Preparazione embedding {{completed}} / {{total}} ({{percent}}%)",
"embeddingDetail": "Preparazione embedding {{completed}} / {{total}}",
"noneFound": "Nessun gruppo di duplicati trovato. Il wiki è pulito.",
"candidates": "{{n}} candidati",
"canonicalLabel": "Mantieni questo slug come canonico:",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,10 @@
"description": "Просит LLM просканировать все страницы сущностей и понятий и сгруппировать те, что, вероятно, относятся к одной теме под разными названиями (англ. против кит., ед. ч. против мн. ч., аббревиатура против полной формы). Каждую группу вы подтверждаете перед объединением. Объединения ставятся в очередь и выполняются по одному, чтобы перекрёстные ссылки оставались согласованными.",
"scanButton": "Найти дубликаты",
"scanning": "Сканирование…",
"scanningProgress": "Сканирование пакета {{completed}} / {{total}} ({{percent}}%)",
"scanningDetail": "Сканирование пакета {{completed}} / {{total}}",
"embeddingProgress": "Подготовка эмбеддингов {{completed}} / {{total}} ({{percent}}%)",
"embeddingDetail": "Подготовка эмбеддингов {{completed}} / {{total}}",
"noneFound": "Групп-дубликатов не найдено. Вики чистая.",
"candidates": "{{n}} кандидатов",
"canonicalLabel": "Сохранить этот slug как канонический:",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,10 @@
"description": "让 LLM 扫描全部 entity / concept 页面,把可能指向同一主题但用了不同名字的页面分组(中英对照、单复数、缩写与全称等)。每一组都需要你确认后才会合并。合并任务会进入串行队列依次执行,避免交叉引用被并发改写覆盖。",
"scanButton": "扫描重复项",
"scanning": "扫描中…",
"scanningProgress": "扫描中…第 {{completed}} / {{total}} 批({{percent}}%)",
"scanningDetail": "正在扫描第 {{completed}} / {{total}} 批",
"embeddingProgress": "正在准备向量 {{completed}} / {{total}}({{percent}}%)",
"embeddingDetail": "正在准备向量 {{completed}} / {{total}}",
"noneFound": "未发现重复分组,wiki 很干净。",
"candidates": "{{n}} 个候选",
"canonicalLabel": "保留以下 slug 作为主条目:",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/dedup-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ describe("buildDedupLlmCall", () => {
})

describe("runDuplicateDetection embedding prefilter", () => {
it("uses a buffered response for the structured detector", async () => {
setupThreePageProject()
mockLoadNotDuplicates.mockResolvedValue([])
mockDetectorGroup()

await runDuplicateDetection("/project", cfg)

expect(mockStreamChat.mock.calls[0][0]).toMatchObject({ streamingEnabled: false })
})

it("sends only embedding candidate summaries to the LLM detector", async () => {
setupThreePageProject()
mockLoadNotDuplicates.mockResolvedValue([])
Expand Down
78 changes: 69 additions & 9 deletions src/lib/dedup-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,31 @@ export async function loadAllWikiPages(
* duplicate-candidate groups. Reads notDuplicates whitelist from
* disk so previously-confirmed false-positives don't reappear.
*/
export interface DuplicateDetectionProgress {
stage: "loading" | "embedding" | "scanning"
completed: number
total: number
}

export interface DuplicateDetectionOptions {
signal?: AbortSignal
onProgress?: (progress: DuplicateDetectionProgress) => void
}

export async function runDuplicateDetection(
projectPath: string,
llmConfig: LlmConfig,
options: { signal?: AbortSignal } = {},
options: DuplicateDetectionOptions = {},
): Promise<DuplicateGroup[]> {
options.onProgress?.({ stage: "loading", completed: 0, total: 1 })
const summaries = await loadAllEntitySummaries(projectPath)
options.onProgress?.({ stage: "loading", completed: 1, total: 1 })
if (summaries.length < 2) return []
const notDup = await loadNotDuplicates(projectPath)
const llm = buildDedupLlmCall(llmConfig, DEDUP_DETECTION_MAX_TOKENS)
const llm = buildDedupLlmCall(
{ ...llmConfig, streamingEnabled: false },
DEDUP_DETECTION_MAX_TOKENS,
)
const embeddingConfig = await loadEmbeddingConfig()

const embeddingEndpoint =
Expand All @@ -206,6 +222,7 @@ export async function runDuplicateDetection(
{
signal: options.signal,
notDuplicates: notDup,
onProgress: options.onProgress,
},
)
} catch (err) {
Expand All @@ -221,30 +238,51 @@ export async function runDuplicateDetection(
return detectDuplicateGroupsInBoundedBatches(summaries, llm, {
signal: options.signal,
notDuplicates: notDup,
onProgress: options.onProgress,
})
}

async function detectDuplicateGroupsInBoundedBatches(
summaries: EntitySummary[],
llm: DedupLlmCall,
options: { signal?: AbortSignal; notDuplicates?: string[][] },
options: {
signal?: AbortSignal
notDuplicates?: string[][]
onProgress?: (progress: DuplicateDetectionProgress) => void
},
): Promise<DuplicateGroup[]> {
const stride = DEDUP_DETECTOR_BATCH_SUMMARIES - DEDUP_FALLBACK_BATCH_OVERLAP
const total = Math.max(
1,
summaries.length <= DEDUP_DETECTOR_BATCH_SUMMARIES
? 1
: Math.ceil((summaries.length - DEDUP_DETECTOR_BATCH_SUMMARIES) / stride) + 1,
)
options.onProgress?.({ stage: "scanning", completed: 0, total })
if (summaries.length <= DEDUP_DETECTOR_BATCH_SUMMARIES) {
return detectDuplicateGroups(summaries, llm, options)
const result = await detectDuplicateGroups(summaries, llm, options)
options.onProgress?.({ stage: "scanning", completed: 1, total })
return result
}

// Keep likely aliases adjacent while bounding every LLM request. A small
// overlap prevents a duplicate pair at a batch boundary from being split.
const ordered = [...summaries].sort((left, right) =>
`${left.title}\u0000${left.slug}`.localeCompare(`${right.title}\u0000${right.slug}`),
)
const stride = DEDUP_DETECTOR_BATCH_SUMMARIES - DEDUP_FALLBACK_BATCH_OVERLAP
const groups: DuplicateGroup[] = []
let completed = 0
for (let start = 0; start < ordered.length; start += stride) {
if (options.signal?.aborted) throw new Error("Duplicate scan cancelled")
const batch = ordered.slice(start, start + DEDUP_DETECTOR_BATCH_SUMMARIES)
if (batch.length < 2) break
groups.push(...await detectDuplicateGroups(batch, llm, options))
try {
groups.push(...await detectDuplicateGroups(batch, llm, options))
} catch (error) {
throw new Error(`Duplicate scan failed in batch ${completed + 1}/${total}: ${error instanceof Error ? error.message : String(error)}`)
}
completed++
options.onProgress?.({ stage: "scanning", completed, total })
}
return uniqueDuplicateGroups(groups)
}
Expand All @@ -253,14 +291,27 @@ async function detectDuplicateGroupsWithEmbeddingPrefilter(
summaries: EntitySummary[],
embeddingConfig: EmbeddingConfig,
llm: DedupLlmCall,
options: { signal?: AbortSignal; notDuplicates?: string[][] },
options: {
signal?: AbortSignal
notDuplicates?: string[][]
onProgress?: (progress: DuplicateDetectionProgress) => void
},
): Promise<DuplicateGroup[]> {
const pages = summaries.map(summaryToEmbeddingPage)
options.onProgress?.({ stage: "embedding", completed: 0, total: pages.length })
const pairs = await candidatePairs(pages, embeddingConfig, {
topK: DEDUP_PREFILTER_TOP_K,
threshold: DEDUP_PREFILTER_THRESHOLD,
maxPages: DEDUP_PREFILTER_MAX_PAGES,
signal: options.signal,
onProgress: (completed, total) => {
options.onProgress?.({ stage: "embedding", completed, total })
},
})
options.onProgress?.({
stage: "embedding",
completed: pages.length,
total: pages.length,
})
if (pairs.length === 0) {
// Preserve recall for small/medium wikis: a weak or non-multilingual
Expand All @@ -283,10 +334,19 @@ async function detectDuplicateGroupsWithEmbeddingPrefilter(
const batches = batchCandidateClusters(clusters, summaryByPath)
const out: DuplicateGroup[] = []

let completed = 0
const total = Math.max(1, batches.length)
options.onProgress?.({ stage: "scanning", completed: 0, total })
for (const batch of batches) {
if (options.signal?.aborted) throw new Error("Duplicate scan cancelled")
const detected = await detectDuplicateGroups(batch, llm, options)
out.push(...detected)
try {
const detected = await detectDuplicateGroups(batch, llm, options)
out.push(...detected)
} catch (error) {
throw new Error(`Duplicate scan failed in batch ${completed + 1}/${total}: ${error instanceof Error ? error.message : String(error)}`)
}
completed++
options.onProgress?.({ stage: "scanning", completed, total })
}

return uniqueDuplicateGroups(out)
Expand Down
12 changes: 10 additions & 2 deletions src/lib/dedup_embedding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface CandidateOptions {
threshold?: number
maxPages?: number
signal?: AbortSignal
/** Report embedding progress as completed pages and total pages. */
onProgress?: (completed: number, total: number) => void
/**
* If too many embeddings fail, callers should fall back to the old full scan
* instead of silently missing most pages. Default: 0.8.
Expand Down Expand Up @@ -86,16 +88,21 @@ export function pageToEmbeddingText(page: Page, budget = 1500): string {
export async function embedPages(
pages: Page[],
cfg: EmbeddingConfig,
opts: { signal?: AbortSignal; textBudgetChars?: number } = {},
opts: {
signal?: AbortSignal
textBudgetChars?: number
onProgress?: (completed: number, total: number) => void
} = {},
): Promise<Map<string, number[] | null>> {
const out = new Map<string, number[] | null>()
const budget = opts.textBudgetChars ?? 1500
for (const p of pages) {
for (const [index, p] of pages.entries()) {
throwIfAborted(opts.signal)
const text = pageToEmbeddingText(p, budget)
const vec = await fetchEmbedding(text, cfg)
throwIfAborted(opts.signal)
out.set(p.id, vec)
opts.onProgress?.(index + 1, pages.length)
}
return out
}
Expand Down Expand Up @@ -128,6 +135,7 @@ export async function candidatePairs(
const embeddings = await embedPages(subset, cfg, {
signal: opts.signal,
textBudgetChars: opts.textBudgetChars,
onProgress: opts.onProgress,
})

const embeddedCount = [...embeddings.values()].filter((v) => v && v.length > 0).length
Expand Down