diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts index 7dfe9d20..6f9c9307 100644 --- a/frontend-web/src/core/knowledge/api.ts +++ b/frontend-web/src/core/knowledge/api.ts @@ -131,3 +131,61 @@ export async function exportKnowledgeGraph() { export function knowledgeExportFileUrl(filename: string) { return `${baseUrl()}/export/files/${encodeURIComponent(filename)}`; } + +// --- phase 2/3/4 --- + +export interface KnowledgeVersion { + id: string; + note_id: string; + title: string; + summary?: string | null; + content_md: string; + status: string; + tags: string[]; + edited_by?: string | null; + created_at?: string | null; +} + +export async function listNoteVersions(noteId: string) { + const res = await parseResponse<{ items: KnowledgeVersion[] }>( + await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}/versions`), + ); + return res.items; +} + +export interface DuplicateGroup { + primary: { id: string; title: string | null }; + duplicates: Array<{ id: string; title: string | null; score: number }>; +} + +export async function listDuplicates() { + const res = await parseResponse<{ groups: DuplicateGroup[] }>( + await apiFetch(`${baseUrl()}/duplicates`), + ); + return res.groups; +} + +export async function mergeKnowledgeNotes(primaryId: string, duplicateIds: string[]) { + return parseResponse<{ note: KnowledgeNote }>( + await apiFetch(`${baseUrl()}/merge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ primary_id: primaryId, duplicate_ids: duplicateIds }), + }), + ); +} + +export async function reindexKnowledge() { + return parseResponse<{ reindexed: number; embedding_enabled: number }>( + await apiFetch(`${baseUrl()}/reindex`, { method: "POST" }), + ); +} + +export interface KnowledgeGraph { + nodes: Array<{ id: string; type: string; label: string; category?: string | null }>; + edges: Array<{ id: string; source: string; target: string; label: string; weight: number }>; +} + +export async function getKnowledgeGraph() { + return parseResponse(await apiFetch(`${baseUrl()}/graph`)); +} diff --git a/frontend-web/src/core/knowledge/hooks.ts b/frontend-web/src/core/knowledge/hooks.ts index 5e304a60..22b27c5e 100644 --- a/frontend-web/src/core/knowledge/hooks.ts +++ b/frontend-web/src/core/knowledge/hooks.ts @@ -12,7 +12,9 @@ import { getKnowledgeNote, ingestThread, ingestThreadsBatch, + listDuplicates, listKnowledgeNotes, + mergeKnowledgeNotes, searchKnowledge, updateKnowledgeNote, } from "./api"; @@ -85,5 +87,19 @@ export function useKnowledgeActions() { onSuccess: invalidate, }), exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }), + merge: useMutation({ + mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) => + mergeKnowledgeNotes(primaryId, duplicateIds), + onSuccess: invalidate, + }), }; } + +export function useKnowledgeDuplicates(enabled: boolean) { + return useQuery({ + queryKey: [...ROOT_KEY, "duplicates"], + queryFn: listDuplicates, + enabled, + refetchOnWindowFocus: false, + }); +} diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index aa7c24a3..4b7315bd 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -1,5 +1,6 @@ import { BookOpen, + Copy, ExternalLink, Loader2, Network, @@ -37,6 +38,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { useKnowledgeActions, + useKnowledgeDuplicates, useKnowledgeNote, useKnowledgeNotes, } from "@/core/knowledge/hooks"; @@ -270,6 +272,83 @@ function BatchImportDialog({ ); } +function DedupDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { data: groups = [], isLoading } = useKnowledgeDuplicates(open); + const { merge } = useKnowledgeActions(); + + const handleMerge = async (primaryId: string, duplicateIds: string[]) => { + try { + await merge.mutateAsync({ primaryId, duplicateIds }); + toast.success("已合并重复知识"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "合并失败"); + } + }; + + return ( + + + + 查重与合并 + + 基于标题/摘要相似度与内容哈希检测重复知识;合并会把重复项归档并把来源并入主条目。 + + +
+ {isLoading ? ( +
检测中...
+ ) : groups.length === 0 ? ( +
+ 未发现明显重复的知识。 +
+ ) : ( +
    + {groups.map((g) => ( +
  • +
    + 保留:{g.primary.title || g.primary.id} +
    +
      + {g.duplicates.map((d) => ( +
    • + 合并:{d.title || d.id}(相似度 {Math.round(d.score * 100)}%) +
    • + ))} +
    + +
  • + ))} +
+ )} +
+ + + +
+
+ ); +} + function NoteDetail({ noteId }: { noteId: string }) { const navigate = useNavigate(); const { data: note, isLoading } = useKnowledgeNote(noteId); @@ -479,6 +558,7 @@ export default function KnowledgeBasePage() { const [status, setStatus] = useState("all"); const [createOpen, setCreateOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false); + const [dedupOpen, setDedupOpen] = useState(false); const params = useMemo( () => ({ @@ -535,6 +615,9 @@ export default function KnowledgeBasePage() { +