diff --git a/frontend/src/api/kb.ts b/frontend/src/api/kb.ts index be2012e2..6309b21b 100644 --- a/frontend/src/api/kb.ts +++ b/frontend/src/api/kb.ts @@ -41,6 +41,13 @@ export function createKb(body: InitRequest): Promise { return apiFetch("/api/v1/init", { method: "POST", body }) } +/** Permanently delete a knowledge base (physical removal + unregister). + * `confirmName` must equal `kb` (re-checked server-side); the caller collects + * the type-the-name confirmation. */ +export function deleteKb(kb: string, confirmName: string): Promise<{ deleted: boolean; kb: string; path: string }> { + return apiFetch("/api/v1/kb/delete", { body: { kb, confirm_name: confirmName } }) +} + export type ConfigSource = "kb" | "global" | "default" export interface KbConfig { diff --git a/frontend/src/api/wiki.ts b/frontend/src/api/wiki.ts index 7ae08efa..5b8aec73 100644 --- a/frontend/src/api/wiki.ts +++ b/frontend/src/api/wiki.ts @@ -52,3 +52,45 @@ export function getPage(kb: string, path: string): Promise<{ path: string; conte export function getKbInventory(kb: string): Promise { return apiFetch("/api/v1/list", { body: { kb } }) } + +/** Result of `/api/v1/page/delete`. `backlinks` are 'section/stem' refs whose + * inbound [[links]] will be / were demoted to plain text. */ +export interface PageDeleteResult { + status: string + target: string + backlinks: string[] + files_changed?: number | null + ghosts_stripped?: number | null +} + +/** Delete a concept/entity page. With `dryRun`, only reports the impact + * (backlinks) without changing anything — used for the confirm preview. */ +export function deletePage(kb: string, path: string, dryRun = false): Promise { + return apiFetch("/api/v1/page/delete", { body: { kb, path, dry_run: dryRun } }) +} + +/** Outbound + inbound links for a page (`/api/v1/page/links`) — the edit panel. */ +export interface PageLinks { + status: string + target: string + outlinks: string[] + backlinks: string[] +} + +export function getPageLinks(kb: string, path: string): Promise { + return apiFetch("/api/v1/page/links", { body: { kb, path } }) +} + +/** Result of editing a page (`PUT /api/v1/page`). `ghosts_stripped` are the + * dead [[links]] demoted to plain text on save. */ +export interface PageEditResult { + status: string + target: string + ghosts_stripped: string[] + content: string | null +} + +/** Save new BODY for a concept/entity page (frontmatter preserved server-side). */ +export function editPage(kb: string, path: string, content: string): Promise { + return apiFetch("/api/v1/page", { method: "PUT", body: { kb, path, content } }) +} diff --git a/frontend/src/components/KbSettingsSheet.tsx b/frontend/src/components/KbSettingsSheet.tsx index 09b3fe33..e0865176 100644 --- a/frontend/src/components/KbSettingsSheet.tsx +++ b/frontend/src/components/KbSettingsSheet.tsx @@ -8,7 +8,7 @@ import { ShieldCheck, Clock, Radio, } from 'lucide-react' import { toast } from 'sonner' -import { getKbConfig, patchKbConfig, type KbConfig, type ConfigSource } from '@/api/kb' +import { deleteKb, getKbConfig, patchKbConfig, type KbConfig, type ConfigSource } from '@/api/kb' import { watchStart, watchStop, watchStatus, runRecompile, runLint, type WatchStatus, } from '@/api/maintenance' @@ -28,12 +28,14 @@ export default function KbSettingsSheet({ onClose, docCount, onChanged, + onDeleted, }: { kb: string open: boolean onClose: () => void docCount: number onChanged?: () => void + onDeleted?: () => void }) { const { t } = useTranslation(['kbSettings', 'common']) const reduce = useReducedMotion() @@ -114,6 +116,7 @@ export default function KbSettingsSheet({
+
@@ -732,3 +735,84 @@ function KbMaintenanceSection({ ) } + +/** Danger zone: permanently delete this KB. A type-the-name confirmation gates + * POST /api/v1/kb/delete; on success the parent (KbDetail) navigates away. */ +function KbDangerSection({ kb, onDeleted }: { kb: string; onDeleted?: () => void }) { + const { t } = useTranslation(['kbSettings', 'common']) + const [confirming, setConfirming] = useState(false) + const [typed, setTyped] = useState('') + const [busy, setBusy] = useState(false) + const matches = typed.trim() === kb + + const doDelete = useCallback(async () => { + if (!matches || busy) return + setBusy(true) + try { + await deleteKb(kb, kb) + toast.success(t('kbSettings:dangerDeletedToast', { kb })) + onDeleted?.() + } catch (e) { + toast.error(t('kbSettings:dangerDeleteError', { error: errMsg(e) })) + setBusy(false) + } + }, [kb, matches, busy, onDeleted, t]) + + return ( +
+

+ {t('kbSettings:dangerHeading')} +

+
+

{t('kbSettings:dangerDesc')}

+ {!confirming ? ( + + ) : ( +
+ + setTyped(e.target.value)} + placeholder={kb} + className="w-full h-9 rounded-md border border-input bg-transparent px-3 text-[13px] font-mono2 outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-red-400" + /> +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 88cc7561..d22624fd 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -7,7 +7,8 @@ "actions": { "newKb": "New knowledge base", "close": "Close", - "refresh": "Refresh" + "refresh": "Refresh", + "cancel": "Cancel" }, "fields": { "model": "Model", diff --git a/frontend/src/locales/en/kb.json b/frontend/src/locales/en/kb.json index 55dd02ad..6c89a8b2 100644 --- a/frontend/src/locales/en/kb.json +++ b/frontend/src/locales/en/kb.json @@ -46,6 +46,30 @@ "multiple": "Delete failed: {{message}} (candidates: {{names}})" } }, + "pageOps": { + "edit": "Edit", + "delete": "Delete", + "editorAria": "Page source (Markdown)", + "deletePrompt": "Delete {{title}}?", + "deleteImpact_one": "{{count}} page links here — its link will become plain text", + "deleteImpact_other": "{{count}} pages link here — their links will become plain text", + "deleteNoBacklinks": "No other pages link here", + "deleteConfirm": "Delete page", + "deleteSuccess": "Deleted {{title}}", + "deleteError": "Delete failed: {{error}}", + "save": "Save", + "saveSuccess": "Page saved", + "saveError": "Save failed: {{error}}", + "ghostsDemoted": "These links didn't resolve and became plain text: {{links}}", + "editRecompileNote": "Manual edits may be overwritten the next time this KB is recompiled.", + "links": { + "heading": "Page links", + "outlinks": "This page links to", + "backlinks": "Pages linking here", + "none": "None", + "error": "Failed to load links: {{error}}" + } + }, "remote": { "heading": "Remote data sources", "note": "Cloud connectors are in development and not yet available · want one? Click a card to vote on GitHub" diff --git a/frontend/src/locales/en/kbSettings.json b/frontend/src/locales/en/kbSettings.json index 3945cccd..a3cecab6 100644 --- a/frontend/src/locales/en/kbSettings.json +++ b/frontend/src/locales/en/kbSettings.json @@ -38,5 +38,12 @@ "lint": "Structure check", "recompileHeading": "Recompile", "recompileSummary": "· {{total}} total · {{recompiled}} compiled · {{skipped}} skipped", - "preparing": "Preparing…" + "preparing": "Preparing…", + "dangerHeading": "Danger zone", + "dangerDesc": "Permanently delete this knowledge base — its raw documents and compiled wiki. This cannot be undone.", + "dangerDeleteButton": "Delete this knowledge base", + "dangerConfirmPrompt": "Type the KB name ({{kb}}) to confirm", + "dangerConfirmDelete": "Delete permanently", + "dangerDeletedToast": "Deleted knowledge base “{{kb}}”", + "dangerDeleteError": "Delete failed: {{error}}" } diff --git a/frontend/src/locales/zh/common.json b/frontend/src/locales/zh/common.json index 524e0e0b..e76945a7 100644 --- a/frontend/src/locales/zh/common.json +++ b/frontend/src/locales/zh/common.json @@ -7,7 +7,8 @@ "actions": { "newKb": "新建知识库", "close": "关闭", - "refresh": "刷新" + "refresh": "刷新", + "cancel": "取消" }, "fields": { "model": "模型", diff --git a/frontend/src/locales/zh/kb.json b/frontend/src/locales/zh/kb.json index 02801c18..9668ce27 100644 --- a/frontend/src/locales/zh/kb.json +++ b/frontend/src/locales/zh/kb.json @@ -46,6 +46,30 @@ "multiple": "删除失败:{{message}}(候选:{{names}})" } }, + "pageOps": { + "edit": "编辑", + "delete": "删除", + "editorAria": "页面源码(Markdown)", + "deletePrompt": "确认删除 {{title}}?", + "deleteImpact_one": "{{count}} 个页面链接到此页 — 删除后这些链接将变为纯文本", + "deleteImpact_other": "{{count}} 个页面链接到此页 — 删除后这些链接将变为纯文本", + "deleteNoBacklinks": "没有其他页面链接到此页", + "deleteConfirm": "删除页面", + "deleteSuccess": "已删除 {{title}}", + "deleteError": "删除失败:{{error}}", + "save": "保存", + "saveSuccess": "页面已保存", + "saveError": "保存失败:{{error}}", + "ghostsDemoted": "以下链接无法解析,已变为纯文本:{{links}}", + "editRecompileNote": "手动修改在下次重新编译该库时可能被覆盖。", + "links": { + "heading": "页面链接", + "outlinks": "此页链接到", + "backlinks": "链接到此页的页面", + "none": "无", + "error": "链接加载失败:{{error}}" + } + }, "remote": { "heading": "远程数据源", "note": "云端连接器开发中,尚不可用 · 想要它?点卡片去 GitHub 投票" diff --git a/frontend/src/locales/zh/kbSettings.json b/frontend/src/locales/zh/kbSettings.json index 6f894169..85d42dcc 100644 --- a/frontend/src/locales/zh/kbSettings.json +++ b/frontend/src/locales/zh/kbSettings.json @@ -38,5 +38,12 @@ "lint": "结构检查", "recompileHeading": "重新编译", "recompileSummary": "· 共 {{total}} · 编译 {{recompiled}} · 跳过 {{skipped}}", - "preparing": "正在准备…" + "preparing": "正在准备…", + "dangerHeading": "危险操作", + "dangerDesc": "永久删除这个知识库(原始文档 + 已编译的 wiki),不可撤销。", + "dangerDeleteButton": "删除此知识库", + "dangerConfirmPrompt": "输入知识库名称({{kb}})以确认", + "dangerConfirmDelete": "永久删除", + "dangerDeletedToast": "已删除知识库「{{kb}}」", + "dangerDeleteError": "删除失败:{{error}}" } diff --git a/frontend/src/pages/KbDetail.tsx b/frontend/src/pages/KbDetail.tsx index c525aac9..25ae7d36 100644 --- a/frontend/src/pages/KbDetail.tsx +++ b/frontend/src/pages/KbDetail.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' -import { useParams } from 'react-router' +import { useNavigate, useParams } from 'react-router' import { useTranslation } from 'react-i18next' import { motion, useReducedMotion } from 'motion/react' -import { FileText, Loader2, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle } from 'lucide-react' +import { FileText, Link2, Loader2, Pencil, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle } from 'lucide-react' import { toast } from 'sonner' -import { getKbInventory, getPage, type KbInventory, type WikiDocument } from '@/api/wiki' +import { deletePage, editPage, getKbInventory, getPage, getPageLinks, type KbInventory, type WikiDocument } from '@/api/wiki' import { streamUpload, removeDocument, type AddResult } from '@/api/maintenance' import { ApiError } from '@/api/client' import MarkdownView from '@/components/MarkdownView' @@ -103,6 +103,7 @@ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)) export default function KbDetail() { const { id = '' } = useParams() + const navigate = useNavigate() const { t } = useTranslation(['kb', 'common']) const [inv, setInv] = useState(null) @@ -208,6 +209,33 @@ export default function KbDetail() { } }, [id]) + /** The open page was deleted (F2): close the now-gone page and refresh the + * inventory — backlink pages changed on disk too (their [[links]] were + * demoted to plain text). */ + const onPageDeleted = useCallback(() => { + setSelectedPath(null) + void refreshInventory() + }, [refreshInventory]) + + /** The open page was saved (F3): adopt the returned file content (stripping + * frontmatter exactly like the load effect) or re-fetch when the backend + * didn't return it, then refresh the inventory (edits can change the link + * graph). The functional set never clobbers a page that loaded for a + * DIFFERENT selection while the save was in flight. */ + const onPageSaved = useCallback( + (path: string, content: string | null) => { + // editPage always returns the saved content on 200 (PageEditResponse), so + // adopt it directly — frontmatter stripped exactly like the load effect. + // The functional set never clobbers a page loaded for a DIFFERENT + // selection while the save was in flight. + if (content != null) { + setPage((prev) => (prev && prev.path !== path ? prev : { path, content: stripFrontmatter(content) })) + } + void refreshInventory() + }, + [refreshInventory], + ) + const doUpload = useCallback( async (files: File[]) => { if (files.length === 0 || uploading) return @@ -403,6 +431,7 @@ export default function KbDetail() { > {section === 'index' ? ( ) : section === 'documents' ? ( - + )} @@ -453,6 +495,13 @@ export default function KbDetail() { onClose={() => setSettingsOpen(false)} docCount={docCount} onChanged={refreshInventory} + onDeleted={() => { + setSettingsOpen(false) + // Refresh the sidebar's KB list (same event CreateKbDialog fires) so + // the just-deleted KB disappears without a manual reload. + window.dispatchEvent(new CustomEvent('openkb:reload-kbs')) + navigate('/kb') + }} /> ) @@ -461,6 +510,8 @@ export default function KbDetail() { /** Shared props for the page-content column, whether it renders full-width * (Index) or beside the 300px `PageList` sidebar (a type card). */ interface ReaderProps { + /** KB name — the `kb` param for the page edit/delete/links endpoints. */ + kb: string selected: SelectedPage | null page: { path: string; content: string } | null pageError: { path: string; message: string } | null @@ -469,13 +520,49 @@ interface ReaderProps { inv: KbInventory | null /** Navigate to a `[[wikilink]]` target clicked inside the rendered page. */ onWikiLink: (target: string) => void + /** The open page was deleted: close it and refresh the inventory. */ + onDeleted: () => void + /** The open page was saved: adopt `content` (the saved file, frontmatter and + * all) or re-fetch when null, then refresh the inventory. */ + onSaved: (path: string, content: string | null) => void } /** The actual page body: breadcrumb + Markdown, or an empty/loading state. * Shared verbatim by `Reader` (Browse) and `IndexReader` (Index, full width) — - * they differ only in their outer scroll container. */ -function ReaderBody({ selected, page, pageError, selectedPath, hasPages, inv, onWikiLink }: ReaderProps) { + * they differ only in their outer scroll container. Concept/entity pages also + * carry inline Edit/Delete controls (F2/F3): delete previews its backlink + * impact via a dry-run before the destructive call; edit swaps the rendered + * Markdown for a body-only textarea plus a read-only outlink/backlink panel. */ +function ReaderBody({ kb, selected, page, pageError, selectedPath, hasPages, inv, onWikiLink, onDeleted, onSaved }: ReaderProps) { const { t } = useTranslation(['kb', 'common']) + + // Edit mode (F3). `links`/`linksError` are tagged with the path they belong + // to (same discipline as `page`/`pageError` in KbDetail) so a slow response + // never renders under a different page. + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const [links, setLinks] = useState<{ path: string; outlinks: string[]; backlinks: string[] } | null>(null) + const [linksError, setLinksError] = useState<{ path: string; message: string } | null>(null) + + // Delete confirm (F2): `deleteImpact` holds the dry-run backlink preview for + // the page awaiting confirmation (path-tagged like the edit state above). + const [checkingDelete, setCheckingDelete] = useState(false) + const [deleteImpact, setDeleteImpact] = useState<{ path: string; backlinks: string[] } | null>(null) + const [deleting, setDeleting] = useState(false) + + // Switching pages must never leak edit/confirm state into the next page. + useEffect(() => { + setEditing(false) + setDraft('') + setSaving(false) + setLinks(null) + setLinksError(null) + setCheckingDelete(false) + setDeleteImpact(null) + setDeleting(false) + }, [selectedPath]) + const pageReady = page && page.path === selectedPath const pageFailed = pageError && pageError.path === selectedPath @@ -487,6 +574,81 @@ function ReaderBody({ selected, page, pageError, selectedPath, hasPages, inv, on ) } + // Editable: concept/entity synthesis + per-document summaries (all compiled + // markdown a recompile can regenerate). Deletable is narrower — a summary is + // removed by deleting its source document, never on its own. reports/index.md + // are generated artifacts the backend refuses to mutate either way. + const canEdit = + selected.group === 'concepts/' || + selected.group === 'entities/' || + selected.group === 'summaries/' + const canDelete = selected.group === 'concepts/' || selected.group === 'entities/' + const confirmActive = deleteImpact !== null && deleteImpact.path === selected.path + const linksReady = links && links.path === selected.path + const linksFailed = linksError && linksError.path === selected.path + + const startEdit = () => { + if (!page || page.path !== selected.path) return + setDraft(page.content) + setEditing(true) + const path = selected.path + setLinks(null) + setLinksError(null) + getPageLinks(kb, path) + .then((r) => setLinks({ path, outlinks: r.outlinks, backlinks: r.backlinks })) + .catch((e) => setLinksError({ path, message: errMsg(e) })) + } + + const doSave = async () => { + if (saving) return + setSaving(true) + try { + const res = await editPage(kb, selected.path, draft) + const ghosts = res.ghosts_stripped ?? [] + if (ghosts.length > 0) { + toast.warning(t('kb:pageOps.ghostsDemoted', { links: ghosts.join(', ') })) + } else { + toast.success(t('kb:pageOps.saveSuccess')) + } + onSaved(selected.path, res.content) + setEditing(false) + setDraft('') + } catch (e) { + // Keep edit mode (and the draft) so a transient failure loses nothing. + toast.error(t('kb:pageOps.saveError', { error: errMsg(e) })) + } finally { + setSaving(false) + } + } + + const startDelete = async () => { + if (checkingDelete) return + setCheckingDelete(true) + const path = selected.path + try { + const res = await deletePage(kb, path, true) + setDeleteImpact({ path, backlinks: res.backlinks ?? [] }) + } catch (e) { + toast.error(t('kb:pageOps.deleteError', { error: errMsg(e) })) + } finally { + setCheckingDelete(false) + } + } + + const doDelete = async () => { + if (deleting) return + setDeleting(true) + try { + await deletePage(kb, selected.path) + toast.success(t('kb:pageOps.deleteSuccess', { title: selected.title })) + onDeleted() + } catch (e) { + toast.error(t('kb:pageOps.deleteError', { error: errMsg(e) })) + } finally { + setDeleting(false) + } + } + return (
@@ -494,11 +656,127 @@ function ReaderBody({ selected, page, pageError, selectedPath, hasPages, inv, on wiki/{selected.group} {selected.title} + {(canEdit || canDelete) && pageReady && !editing && !confirmActive && ( +
+ {canEdit && ( + + )} + {canDelete && ( + + )} +
+ )}
+ + {/* Delete confirm (F2): dry-run impact preview + Confirm/Cancel. */} + {confirmActive && ( +
+

{t('kb:pageOps.deletePrompt', { title: selected.title })}

+

+ {deleteImpact.backlinks.length > 0 + ? t('kb:pageOps.deleteImpact', { count: deleteImpact.backlinks.length }) + : t('kb:pageOps.deleteNoBacklinks')} +

+ {deleteImpact.backlinks.length > 0 && ( +
+ {deleteImpact.backlinks.map((b) => ( + + {b} + + ))} +
+ )} +
+ + +
+
+ )} + {pageFailed ? (
{t('common:pageLoadError', { error: pageError.message })}
+ ) : editing && pageReady ? ( + /* Edit mode (F3): body-only textarea + save/cancel + links panel. */ +
+