Skip to content

Commit a69c7d4

Browse files
feat(workbench): content management — delete KB, delete/edit wiki pages (#198)
* feat(api): delete a knowledge base (POST /api/v1/kb/delete + openkb delete-kb) Physical, irreversible KB deletion with a type-the-name confirmation. - config.delete_kb: rmtree the KB directory + unregister it from the global registry (known_kbs / kb_aliases / default_kb, via the new unregister_kb). Guards against deleting a non-KB path; tolerates a ghost registry entry (directory already gone) by just unregistering. - POST /api/v1/kb/delete: confirm_name must equal kb, re-checked server-side. Lives in a new api_kbs_router (sibling of the config/graph/output routers) so api.py stays under the 800-line gate; delete_kb self-locks (config lock), no create_app closure needed. - openkb delete-kb NAME: type-the-name prompt (or --yes) then delete. Tests: endpoint (success + unregister, confirm mismatch, non-KB target) in test_api.py; config (physical delete + unregister, refuse non-KB, ghost tolerance) in test_config.py. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * feat(api): delete a wiki page (POST /api/v1/page/delete) with backlink impact Delete a concept/entity page and degrade references cleanly instead of leaving them dangling: - page_ops.delete_wiki_page: dry_run reports the backlink pages (whose inbound [[wikilinks]] would be demoted) without touching anything; execute removes the page under the KB ingest lock, strips its index.md entry outright (compiler.remove_doc_from_index — the entry is a [[link]] line), and demotes the now-dangling inbound [[links]] to plain text in ONLY those backlink pages (lint.fix_broken_links restrict_to). The page ref is "section/stem", validated to concepts/ or entities/ only (path-traversal-safe). - New api_pages_router hosts POST /api/v1/page (read — relocated here from api.py to keep it under the 800-line gate) + POST /api/v1/page/delete. Tests: dry-run impact + execute (page gone, inbound link demoted to text, index entry removed, sibling kept), 404 on missing page, and rejection of unsafe / non-editable refs. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * feat(api): edit a wiki page (PUT /api/v1/page) + link context (POST /api/v1/page/links) - page_ops.edit_wiki_page: replace a concept/entity page's BODY while preserving its code-managed OKF frontmatter (type/description/sources) verbatim; any frontmatter in the submitted content is dropped. Dead [[wikilinks]] the user typed are demoted to plain text on save (OpenKB keeps the wiki broken-link-free) and returned as ghosts_stripped so the UI can warn. Atomic write under the KB ingest lock. - page_ops.page_link_context (POST /api/v1/page/links): outbound + inbound links for the edit-impact panel. Editing the body does not break either (links are path-based), so this is context, not a blocker — the honest impact model. - PUT /api/v1/page added to api_pages_router. Tests: edit preserves frontmatter + keeps a resolvable link + demotes a dead one (reports ghosts_stripped) + replaces the body; 404 on a missing page; links reports out/backlinks. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * feat(web): delete a knowledge base from the settings sheet (type-name confirm) Adds a Danger Zone section to KbSettingsSheet: a type-the-name confirmation gates deleteKb() (POST /api/v1/kb/delete); on success KbDetail navigates back to the KB list. New deleteKb client + kbSettings danger* keys + common cancel (zh + en, identical key sets). Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * feat(web): in-reader page edit + delete with impact preview (F2/F3) For an open concept/entity page, the reader header now shows Edit and Delete: - Delete: a dry-run first (deletePage dryRun) surfaces the backlink pages whose [[links]] will demote to plain text; a red confirm card lists them, then the real delete closes the page and refreshes the inventory. - Edit: a body textarea (frontmatter is preserved server-side), Save via PUT /api/v1/page, a links panel (getPageLinks: out/backlinks), a "recompile may overwrite" note, and a toast listing any dead links demoted to text. Adds the deletePage/editPage/getPageLinks wiki clients and kb:pageOps.* locale keys (zh + en, identical key sets). Build green (i18n guard + tsc + vite). Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * fix(workbench): address code-review findings (content-mgmt correctness + concurrency) Backend (page_ops / kb_admin / lint): - delete/edit now do their read-modify-write INSIDE the KB ingest lock and re-check the page exists under it: no stale backlink snapshot, no resurrecting a concurrently-deleted page, no 500 on a racing unlink (missing_ok=True). [#4,#5,#6] - delete_kb serializes rmtree under the ingest lock (was fully unlocked). Moved delete_kb/unregister_kb into a new openkb/kb_admin.py so config.py drops back under the per-file line gate (751 lines). [#1,#15] - page deletion no longer scans or rewrites lint's _EXCLUDED_FILES (AGENTS.md/SCHEMA.md/log.md); fix_broken_links(restrict_to) also refuses them, which protects `openkb remove` too. [#2] - edit_wiki_page treats `content` as the body verbatim — no naive frontmatter split that silently dropped a body opening with a '---' block. [#3] - delete uses the real on-disk stem for the exact-match index-entry removal (case-insensitive FS), and adds index.md to the demotion set so a [[target]] embedded in another entry's brief no longer dangles. [#7,#9] API / CLI: - delete-KB endpoint & CLI can now clean a ghost registry entry (registered name whose directory was removed by hand), catch delete_kb's ValueError -> 400, and the endpoint is typed `-> KbDeleteResponse`. [#8,#13,E5] Frontend (KbDetail): - Deleting a KB dispatches `openkb:reload-kbs` so the sidebar refreshes immediately (was stale until a manual reload) — live-test fix. - Removed the dead `pageReloadSeq` path (editPage always returns content). [#14] Tests: excluded-doc-untouched, '---'-body preservation, ghost-KB unregister; non-KB-target test updated to the new resolution model. Backend 1234 passed, mypy/ruff clean, frontend build green. Not changed (intentional): pages_linking_to is NOT folded into build_graph (that excludes explorations/ backlinks) [#10]; the preview+confirm delete inherently scans per request [#11]; EDITABLE_SECTIONS stays duplicated in the frontend, which can't import the Python constant [#12]. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * feat(workbench): allow editing summary pages (edit ⊇ delete section sets) Summaries are compiled markdown like concepts/entities (a recompile regenerates them), so they are now editable via PUT /api/v1/page. They remain NOT independently deletable — a summary is removed by deleting its source document. Split page_ops into EDITABLE_SECTIONS (concepts/entities/summaries) vs DELETABLE_SECTIONS (concepts/entities); validate_page_ref takes the allowlist. Frontend ReaderBody gates Edit on canEdit (incl. summaries), Delete on canDelete. Test: summary editable (frontmatter preserved) + summary delete rejected (400). Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG * fix(api): cross-platform delete_kb + endpoint OSError handling (xhigh review #1,#2) - delete_kb no longer holds the ingest lock DURING rmtree (the lock file lives inside the KB dir; Windows cannot delete an open file — the prior review-fix regressed this). It now takes the lock as a BARRIER (drain + wait out any in-flight mutation), releases it, re-checks existence, then rmtrees. [#1] - delete-KB endpoint maps FileNotFoundError to an idempotent success (a concurrent delete already removed the tree) and other OSError to a clean 500 with a message, instead of an uncaught 500 stack trace. [#2] Tests: endpoint OSError -> clean 500, FileNotFoundError -> 200 deleted. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
1 parent 9036c2c commit a69c7d4

20 files changed

Lines changed: 1425 additions & 29 deletions

frontend/src/api/kb.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export function createKb(body: InitRequest): Promise<InitResponse> {
4141
return apiFetch<InitResponse>("/api/v1/init", { method: "POST", body })
4242
}
4343

44+
/** Permanently delete a knowledge base (physical removal + unregister).
45+
* `confirmName` must equal `kb` (re-checked server-side); the caller collects
46+
* the type-the-name confirmation. */
47+
export function deleteKb(kb: string, confirmName: string): Promise<{ deleted: boolean; kb: string; path: string }> {
48+
return apiFetch("/api/v1/kb/delete", { body: { kb, confirm_name: confirmName } })
49+
}
50+
4451
export type ConfigSource = "kb" | "global" | "default"
4552

4653
export interface KbConfig {

frontend/src/api/wiki.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,45 @@ export function getPage(kb: string, path: string): Promise<{ path: string; conte
5252
export function getKbInventory(kb: string): Promise<KbInventory> {
5353
return apiFetch<KbInventory>("/api/v1/list", { body: { kb } })
5454
}
55+
56+
/** Result of `/api/v1/page/delete`. `backlinks` are 'section/stem' refs whose
57+
* inbound [[links]] will be / were demoted to plain text. */
58+
export interface PageDeleteResult {
59+
status: string
60+
target: string
61+
backlinks: string[]
62+
files_changed?: number | null
63+
ghosts_stripped?: number | null
64+
}
65+
66+
/** Delete a concept/entity page. With `dryRun`, only reports the impact
67+
* (backlinks) without changing anything — used for the confirm preview. */
68+
export function deletePage(kb: string, path: string, dryRun = false): Promise<PageDeleteResult> {
69+
return apiFetch<PageDeleteResult>("/api/v1/page/delete", { body: { kb, path, dry_run: dryRun } })
70+
}
71+
72+
/** Outbound + inbound links for a page (`/api/v1/page/links`) — the edit panel. */
73+
export interface PageLinks {
74+
status: string
75+
target: string
76+
outlinks: string[]
77+
backlinks: string[]
78+
}
79+
80+
export function getPageLinks(kb: string, path: string): Promise<PageLinks> {
81+
return apiFetch<PageLinks>("/api/v1/page/links", { body: { kb, path } })
82+
}
83+
84+
/** Result of editing a page (`PUT /api/v1/page`). `ghosts_stripped` are the
85+
* dead [[links]] demoted to plain text on save. */
86+
export interface PageEditResult {
87+
status: string
88+
target: string
89+
ghosts_stripped: string[]
90+
content: string | null
91+
}
92+
93+
/** Save new BODY for a concept/entity page (frontmatter preserved server-side). */
94+
export function editPage(kb: string, path: string, content: string): Promise<PageEditResult> {
95+
return apiFetch<PageEditResult>("/api/v1/page", { method: "PUT", body: { kb, path, content } })
96+
}

frontend/src/components/KbSettingsSheet.tsx

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
ShieldCheck, Clock, Radio,
99
} from 'lucide-react'
1010
import { toast } from 'sonner'
11-
import { getKbConfig, patchKbConfig, type KbConfig, type ConfigSource } from '@/api/kb'
11+
import { deleteKb, getKbConfig, patchKbConfig, type KbConfig, type ConfigSource } from '@/api/kb'
1212
import {
1313
watchStart, watchStop, watchStatus, runRecompile, runLint, type WatchStatus,
1414
} from '@/api/maintenance'
@@ -28,12 +28,14 @@ export default function KbSettingsSheet({
2828
onClose,
2929
docCount,
3030
onChanged,
31+
onDeleted,
3132
}: {
3233
kb: string
3334
open: boolean
3435
onClose: () => void
3536
docCount: number
3637
onChanged?: () => void
38+
onDeleted?: () => void
3739
}) {
3840
const { t } = useTranslation(['kbSettings', 'common'])
3941
const reduce = useReducedMotion()
@@ -114,6 +116,7 @@ export default function KbSettingsSheet({
114116
<div className="flex-1 min-h-0 overflow-y-auto scroll-edge-top px-4 py-4 space-y-6">
115117
<KbConfigSection kb={kb} />
116118
<KbMaintenanceSection kb={kb} open={open} docCount={docCount} onChanged={onChanged} />
119+
<KbDangerSection kb={kb} onDeleted={onDeleted} />
117120
</div>
118121
</motion.aside>
119122
</Dialog.Content>
@@ -732,3 +735,84 @@ function KbMaintenanceSection({
732735
</div>
733736
)
734737
}
738+
739+
/** Danger zone: permanently delete this KB. A type-the-name confirmation gates
740+
* POST /api/v1/kb/delete; on success the parent (KbDetail) navigates away. */
741+
function KbDangerSection({ kb, onDeleted }: { kb: string; onDeleted?: () => void }) {
742+
const { t } = useTranslation(['kbSettings', 'common'])
743+
const [confirming, setConfirming] = useState(false)
744+
const [typed, setTyped] = useState('')
745+
const [busy, setBusy] = useState(false)
746+
const matches = typed.trim() === kb
747+
748+
const doDelete = useCallback(async () => {
749+
if (!matches || busy) return
750+
setBusy(true)
751+
try {
752+
await deleteKb(kb, kb)
753+
toast.success(t('kbSettings:dangerDeletedToast', { kb }))
754+
onDeleted?.()
755+
} catch (e) {
756+
toast.error(t('kbSettings:dangerDeleteError', { error: errMsg(e) }))
757+
setBusy(false)
758+
}
759+
}, [kb, matches, busy, onDeleted, t])
760+
761+
return (
762+
<div className="space-y-3 pt-2">
763+
<h3 className="text-[12px] font-semibold text-red-600 dark:text-red-400 tracking-wide">
764+
{t('kbSettings:dangerHeading')}
765+
</h3>
766+
<div className="rounded-2xl border border-red-200/70 dark:border-red-500/25 bg-red-50/50 dark:bg-red-500/5 px-4 py-3.5 space-y-3">
767+
<p className="text-[12.5px] text-muted-foreground">{t('kbSettings:dangerDesc')}</p>
768+
{!confirming ? (
769+
<button
770+
onClick={() => setConfirming(true)}
771+
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-red-300 dark:border-red-500/40 text-[12.5px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-500/10 transition-colors"
772+
>
773+
<Trash2 className="w-3.5 h-3.5" />
774+
{t('kbSettings:dangerDeleteButton')}
775+
</button>
776+
) : (
777+
<div className="space-y-2">
778+
<label className="block text-[12px] text-muted-foreground">
779+
{t('kbSettings:dangerConfirmPrompt', { kb })}
780+
</label>
781+
<input
782+
autoFocus
783+
value={typed}
784+
disabled={busy}
785+
onChange={(e) => setTyped(e.target.value)}
786+
placeholder={kb}
787+
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"
788+
/>
789+
<div className="flex items-center gap-2">
790+
<button
791+
onClick={doDelete}
792+
disabled={busy || !matches}
793+
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-red-600 text-white text-[12.5px] font-medium hover:bg-red-700 transition-colors disabled:opacity-50"
794+
>
795+
{busy ? (
796+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
797+
) : (
798+
<Trash2 className="w-3.5 h-3.5" />
799+
)}
800+
{t('kbSettings:dangerConfirmDelete')}
801+
</button>
802+
<button
803+
onClick={() => {
804+
setConfirming(false)
805+
setTyped('')
806+
}}
807+
disabled={busy}
808+
className="inline-flex items-center h-8 px-3 rounded-lg border border-[hsl(var(--glass-border))] text-[12.5px] font-medium text-muted-foreground hover:bg-accent transition-colors disabled:opacity-60"
809+
>
810+
{t('common:actions.cancel')}
811+
</button>
812+
</div>
813+
</div>
814+
)}
815+
</div>
816+
</div>
817+
)
818+
}

frontend/src/locales/en/common.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"actions": {
88
"newKb": "New knowledge base",
99
"close": "Close",
10-
"refresh": "Refresh"
10+
"refresh": "Refresh",
11+
"cancel": "Cancel"
1112
},
1213
"fields": {
1314
"model": "Model",

frontend/src/locales/en/kb.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@
4646
"multiple": "Delete failed: {{message}} (candidates: {{names}})"
4747
}
4848
},
49+
"pageOps": {
50+
"edit": "Edit",
51+
"delete": "Delete",
52+
"editorAria": "Page source (Markdown)",
53+
"deletePrompt": "Delete {{title}}?",
54+
"deleteImpact_one": "{{count}} page links here — its link will become plain text",
55+
"deleteImpact_other": "{{count}} pages link here — their links will become plain text",
56+
"deleteNoBacklinks": "No other pages link here",
57+
"deleteConfirm": "Delete page",
58+
"deleteSuccess": "Deleted {{title}}",
59+
"deleteError": "Delete failed: {{error}}",
60+
"save": "Save",
61+
"saveSuccess": "Page saved",
62+
"saveError": "Save failed: {{error}}",
63+
"ghostsDemoted": "These links didn't resolve and became plain text: {{links}}",
64+
"editRecompileNote": "Manual edits may be overwritten the next time this KB is recompiled.",
65+
"links": {
66+
"heading": "Page links",
67+
"outlinks": "This page links to",
68+
"backlinks": "Pages linking here",
69+
"none": "None",
70+
"error": "Failed to load links: {{error}}"
71+
}
72+
},
4973
"remote": {
5074
"heading": "Remote data sources",
5175
"note": "Cloud connectors are in development and not yet available · want one? Click a card to vote on GitHub"

frontend/src/locales/en/kbSettings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,12 @@
3838
"lint": "Structure check",
3939
"recompileHeading": "Recompile",
4040
"recompileSummary": "· {{total}} total · {{recompiled}} compiled · {{skipped}} skipped",
41-
"preparing": "Preparing…"
41+
"preparing": "Preparing…",
42+
"dangerHeading": "Danger zone",
43+
"dangerDesc": "Permanently delete this knowledge base — its raw documents and compiled wiki. This cannot be undone.",
44+
"dangerDeleteButton": "Delete this knowledge base",
45+
"dangerConfirmPrompt": "Type the KB name ({{kb}}) to confirm",
46+
"dangerConfirmDelete": "Delete permanently",
47+
"dangerDeletedToast": "Deleted knowledge base “{{kb}}”",
48+
"dangerDeleteError": "Delete failed: {{error}}"
4249
}

frontend/src/locales/zh/common.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"actions": {
88
"newKb": "新建知识库",
99
"close": "关闭",
10-
"refresh": "刷新"
10+
"refresh": "刷新",
11+
"cancel": "取消"
1112
},
1213
"fields": {
1314
"model": "模型",

frontend/src/locales/zh/kb.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@
4646
"multiple": "删除失败:{{message}}(候选:{{names}})"
4747
}
4848
},
49+
"pageOps": {
50+
"edit": "编辑",
51+
"delete": "删除",
52+
"editorAria": "页面源码(Markdown)",
53+
"deletePrompt": "确认删除 {{title}}?",
54+
"deleteImpact_one": "{{count}} 个页面链接到此页 — 删除后这些链接将变为纯文本",
55+
"deleteImpact_other": "{{count}} 个页面链接到此页 — 删除后这些链接将变为纯文本",
56+
"deleteNoBacklinks": "没有其他页面链接到此页",
57+
"deleteConfirm": "删除页面",
58+
"deleteSuccess": "已删除 {{title}}",
59+
"deleteError": "删除失败:{{error}}",
60+
"save": "保存",
61+
"saveSuccess": "页面已保存",
62+
"saveError": "保存失败:{{error}}",
63+
"ghostsDemoted": "以下链接无法解析,已变为纯文本:{{links}}",
64+
"editRecompileNote": "手动修改在下次重新编译该库时可能被覆盖。",
65+
"links": {
66+
"heading": "页面链接",
67+
"outlinks": "此页链接到",
68+
"backlinks": "链接到此页的页面",
69+
"none": "",
70+
"error": "链接加载失败:{{error}}"
71+
}
72+
},
4973
"remote": {
5074
"heading": "远程数据源",
5175
"note": "云端连接器开发中,尚不可用 · 想要它?点卡片去 GitHub 投票"

frontend/src/locales/zh/kbSettings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,12 @@
3838
"lint": "结构检查",
3939
"recompileHeading": "重新编译",
4040
"recompileSummary": "· 共 {{total}} · 编译 {{recompiled}} · 跳过 {{skipped}}",
41-
"preparing": "正在准备…"
41+
"preparing": "正在准备…",
42+
"dangerHeading": "危险操作",
43+
"dangerDesc": "永久删除这个知识库(原始文档 + 已编译的 wiki),不可撤销。",
44+
"dangerDeleteButton": "删除此知识库",
45+
"dangerConfirmPrompt": "输入知识库名称({{kb}})以确认",
46+
"dangerConfirmDelete": "永久删除",
47+
"dangerDeletedToast": "已删除知识库「{{kb}}」",
48+
"dangerDeleteError": "删除失败:{{error}}"
4249
}

0 commit comments

Comments
 (0)