Files
bisheng/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx
T
dolphin fce368f177 fix(platform): make page heights license-banner-aware; fix form file-type image var backfill
F037 license banner shifts all admin content down by its height, but page
scroll containers hardcode viewport-relative heights (calc(100vh - Npx)) that
ignore it, so their bottoms fell off-screen and could not be scrolled to when
the banner was shown.

- LicenseBanner publishes its rendered height as the CSS var --license-banner-h
  on :root (0px when hidden, so all subtractions are no-ops without the banner)
- sweep every calc(100vh - Npx) page/scroll height to subtract the var
- MainLayout sidebar nav max-height subtracts the var too
- SystemPage 组织同步/角色管理: root lacked a fill+scroll container (or used a
  viewport calc miscalibrated for the nested TabsList) -> switch to h-full +
  internal overflow-y-auto so they fill their flex parent, banner-agnostic
- InputFormItem: in edit mode, switching the upload file type to an image-capable
  type no longer backfilled the image variable name (the new-item auto-fill effect
  is skipped when editing); backfill image_file / file_path on file-type change
2026-07-09 19:01:31 +08:00

738 lines
35 KiB
TypeScript

import { useNavigate } from "react-router-dom";
import { Button } from "../../components/bs-ui/button";
import { Input, SearchInput } from "../../components/bs-ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "../../components/bs-ui/table";
import { BookIcon } from "@/components/bs-icons/knowledge";
import { LoadIcon, LoadingIcon } from "@/components/bs-icons/loading";
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm";
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/bs-ui/select";
import { toast, useToast } from "@/components/bs-ui/toast/use-toast";
import { QuestionTooltip } from "@/components/bs-ui/tooltip";
import Tip from "@/components/bs-ui/tooltip/tip";
import { getKnowledgeModelConfig } from "@/controllers/API/finetune";
import { CircleAlert, Copy, Ellipsis, LoaderCircle, Settings, Shield, Trash2 } from "lucide-react";
import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Textarea } from "../../components/bs-ui/input";
import LoadMore from "../../components/bs-comp/loadMore";
import { userContext } from "../../contexts/userContext";
import { copyLibDatabase, createFileLib, deleteFileLib, readFileLibDatabase, updateKnowledge } from "../../controllers/API";
import { captureAndAlertRequestErrorHoc } from "../../controllers/request";
import { useInfiniteCursorTable } from "../../util/hook";
import { useModel } from "../ModelPage/manage";
import { ModelSelect } from "../ModelPage/manage/tabs/WorkbenchModel";
// Knowledge base status
const enum KnowledgeBaseStatus {
Unpublished = 0,
Published = 1, // Document knowledge base build success status
Copying = 2,
Rebuilding = 3, // Document knowledge base rebuilding status
Failed = 4 // Document knowledge base rebuild failed status
}
const KB_MANAGE_PERMISSION_IDS = [
'manage_kb_owner',
'manage_kb_manager',
'manage_kb_viewer',
]
function CreateModal({ datalist, open, onOpenChange, onLoadEnd, mode = 'create', currentLib = null }) {
const { t } = useTranslation('knowledge')
const navigate = useNavigate()
const nameRef = useRef(null)
const descRef = useRef(null)
const [modelId, setModelId] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isModelChanged, setIsModelChanged] = useState(false)
const { embeddings, isLoading } = useModel()
// Unified handling of model data fetching
useEffect(() => {
if (!open) return;
const fetchModelData = async () => {
try {
if (mode === 'create') {
const config = await getKnowledgeModelConfig();
setModelId(config.embedding_model_id);
} else {
setModelId(currentLib.model);
}
if (mode === 'edit' && currentLib) {
// Use setTimeout to ensure DOM has been rendered
setTimeout(() => {
if (nameRef.current) nameRef.current.value = currentLib.name || '';
if (descRef.current) descRef.current.value = currentLib.description || '';
}, 0);
}
} catch (error) {
console.error('Failed to load model data:', error);
toast({
variant: "error",
description: t('loadModelError')
});
}
};
fetchModelData();
}, [open, mode, currentLib]);
useEffect(() => {
// Clear all internal state when modal closes
if (!open) {
setModelId('');
setIsSubmitting(false);
setIsModelChanged(false);
setError({ name: false, desc: false });
}
}, [open]);
const { toast } = useToast()
const [error, setError] = useState({ name: false, desc: false })
const handleCreate = async (e, isImport = false) => {
const name = nameRef.current.value || ''; // Name (default empty string to avoid null)
let desc = descRef.current.value || ''; // Description (default empty string)
// 1. Define the fixed text part of the default description (excluding name)
const defaultDescPrefix = t('defaultDescPrefix');
const defaultDescSuffix = t('defaultDescSuffix');
// Fixed text total length = prefix length + suffix length
const fixedTextLength = defaultDescPrefix.length + defaultDescSuffix.length;
// Maximum name length allowed = 200 - fixed text length (ensure name + fixed text ≤ 200)
const maxNameLengthForDefaultDesc = 200 - fixedTextLength;
// 2. When description is not entered, generate default description (strictly control total length ≤ 200)
if (!desc) {
// Case 1: Name length ≤ maximum allowable length → directly concatenate to generate default description
if (name.length <= maxNameLengthForDefaultDesc) {
desc = `${defaultDescPrefix}${name}${defaultDescSuffix}`;
}
// Case 2: Name length > maximum allowable length → truncate name then concatenate
else {
desc = '';
}
}
// 3. Original validation logic (only for user-entered descriptions, default description already ensures ≤ 200)
if (!name) {
handleError(t('lib.enterLibraryName', { ns: 'bs' }));
return;
}
if (name.length > 200) {
handleError(t('nameExceedsLimit'));
return;
}
// Fix: Name duplication validation logic
// In edit mode and name unchanged, skip duplication check
const isEditMode = mode === 'edit' && currentLib;
const nameUnchanged = isEditMode && name === currentLib.name;
if (!nameUnchanged && datalist.find(data => data.name === name && (!currentLib || data.id !== currentLib.id))) {
handleError(t('lib.nameExists', { ns: 'bs' }));
return;
}
if (descRef.current.value && desc.length > 200) {
handleError(t('lib.descriptionLimit', { ns: 'bs' }));
return;
}
if (mode === 'create' && !modelId) {
handleError(t('lib.selectModel', { ns: 'bs' }));
return;
}
setIsSubmitting(true)
if (mode === 'create') {
await captureAndAlertRequestErrorHoc(createFileLib({
name,
description: desc,
model: modelId,
type: 0
}).then(res => {
window.libname = [name, desc]
navigate(isImport
? `/filelib/upload/${res.id}`
: `/filelib/${res.id}`
);
onOpenChange(false);
})).finally(() => {
setIsSubmitting(false)
})
} else {
const data = {
"model_id": modelId,
"model_type": "embedding",
"knowledge_id": currentLib.id,
"knowledge_name": name,
"description": desc
}
await captureAndAlertRequestErrorHoc(updateKnowledge(data).then(res => {
toast({
variant: "success",
description: t('updateSuccess')
})
onOpenChange(false);
onLoadEnd()
}).catch(error => {
toast({ variant: "error", description: error || t('updateFailed') });
onOpenChange(false);
})).finally(() => {
setIsSubmitting(false)
})
}
}
const handleError = (message) => {
toast({
variant: 'error',
description: message
});
}
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>{mode === 'create' ? t('lib.createLibrary', { ns: 'bs' }) : t('knowledgeBaseSettings')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{mode === 'edit' && currentLib && (
<div className="space-y-4">
<div className="flex items-center gap-48">
<label className="bisheng-label text-sm text-gray-500">{t('lib.knowledgeBaseId', { ns: 'bs' })}</label>
<div className="text-sm">{currentLib.id}</div>
</div>
<div className="flex items-center gap-48">
<label className="bisheng-label text-sm text-gray-500">{t('createTime', { ns: 'bs' })}</label>
<div className="text-sm">
{currentLib.create_time.replace('T', ' ')}
</div>
</div>
</div>
)}
<div className="">
<label htmlFor="name" className="bisheng-label">{t('system.libraryName', { ns: 'bs' })}</label>
<span className="text-red-500">*</span>
<Input
name="name"
ref={nameRef}
defaultValue={mode === 'edit' && currentLib ? currentLib.name : ''}
placeholder={t('lib.enterLibraryName', { ns: 'bs' })}
className={`col-span-3 ${error.name && 'border-red-400'}`}
/>
</div>
<div className="">
<label htmlFor="desc" className="bisheng-label">{t('lib.desc', { ns: 'bs' })}</label>
<Textarea
id="desc"
ref={descRef}
defaultValue={mode === 'edit' && currentLib ? currentLib.description : ''}
placeholder={t('enterKnowledgeBaseDescription')}
rows={8}
className={`col-span-3 ${error.desc && 'border-red-400'}`}
/>
</div>
<div className="">
<label htmlFor="model" className="bisheng-label">{t('lib.embeddingModelSelection', { ns: 'bs' })}</label>
{isLoading ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
<LoadIcon className="w-4 h-4 animate-spin" />
<span className="text-sm text-gray-600">{t('loadingModelList')}</span>
</div>
) : embeddings.length > 0 ? (
<ModelSelect
key={`model-select-${modelId}`}
label=""
close
value={modelId}
options={embeddings}
onChange={(modelId) => {
setModelId(modelId);
if (mode === 'edit') setIsModelChanged(true);
}}
/>
) : (
<div className="p-3 border rounded-md bg-gray-50 text-sm text-gray-600">
{t('noAvailableModels')}
</div>
)}
{mode === 'edit' && isModelChanged && (
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
<CircleAlert className="w-4 h-4" color="#ef4444" />
{t('embeddingModelChangeWarning')}
</p>
)}
</div>
</div>
<DialogFooter>
<DialogClose>
<Button variant="outline" className="px-8 h-8">{t('cancel')}</Button>
</DialogClose>
{mode === 'create' ? (
<>
<Button
variant="outline"
className="px-8 h-8 flex"
onClick={(e) => handleCreate(e, false)}
disabled={isSubmitting}
>
{isSubmitting && <LoadIcon className="mr-1" />}
{t('finishCreate')}
</Button>
<Button
type="submit"
className="px-8 h-8 flex"
onClick={(e) => handleCreate(e, true)}
disabled={isSubmitting}
>
{isSubmitting && <LoadIcon className="mr-1" />}
{t('createImport', { ns: 'bs' })}
</Button>
</>
) : (
<Button
type="submit"
className="px-8 h-8 flex"
onClick={(e) => handleCreate(e, false)}
disabled={isSubmitting}
>
{isSubmitting && <LoadIcon className="mr-1" />}
{t('confirm')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
}
const doing = {} // Record knowledge bases being copied
export default function KnowledgeFile() {
const [open, setOpen] = useState(false);
const { user } = useContext(userContext);
const { message } = useToast()
const navigate = useNavigate()
const [settingsOpen, setSettingsOpen] = useState(false);
const [currentSettingLib, setCurrentSettingLib] = useState(null);
const [copyLoadingId, setCopyLoadingId] = useState<string | null>(null);
// New: Control Select dropdown state to avoid occasional popup issues
const [selectOpenId, setSelectOpenId] = useState<string | null>(null);
const [modalKey, setModalKey] = useState(0); // New: Used to force re-render of modal
// Permission management state
const [permDialogOpen, setPermDialogOpen] = useState(false);
const [permTarget, setPermTarget] = useState<{ id: string; name: string } | null>(null);
// F027: cursor-based infinite scroll; no `total` / `page` anymore.
const { data: datalist, loading, hasMore, search, reload, loadMore } = useInfiniteCursorTable(
{ cancelLoadingWhenReload: true },
(param) =>
readFileLibDatabase({ cursor: param.cursor, pageSize: param.pageSize, name: param.keyword, permissionId: 'view_kb' }),
)
// Permission levels for badge display
// 列表已由后端 get_knowledge 按 ReBAC 过滤;勿再用批量 check 二次过滤,否则与 FGA/缓存短暂不同步时会出现「接口有数据但表格空白」。
const visibleLibs = datalist;
const hasListPermission = (el: any, permissionId: string) =>
Array.isArray(el.permission_ids) && el.permission_ids.includes(permissionId);
const canEdit = (el: any) =>
hasListPermission(el, 'edit_kb');
const canDelete = (el: any) =>
hasListPermission(el, 'delete_kb');
// PRD 3.3.3:「创建」「复制」与 ReBAC 编辑权解耦,由 WEB_MENU `create_knowledge` 控制(对齐「创建应用」+ 列表「复制」)
const canCreateLibrary =
user.role === 'admin' ||
Boolean(user.is_department_admin) ||
(user.web_menu || []).includes('create_knowledge');
const canReadRow = (el: any) =>
hasListPermission(el, 'view_kb');
/** 与 apps.tsx 一致:create_knowledge 菜单 + 对目标库具备使用/可见(can_read) */
const canUseCopy = (el: any) => canCreateLibrary && canReadRow(el);
const canManageKb = (el: any) =>
KB_MANAGE_PERMISSION_IDS.some((permissionId) => hasListPermission(el, permissionId));
const isLibraryBusy = (el: any) =>
[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state);
const canCopy = (el: any) =>
canUseCopy(el) && el.state === KnowledgeBaseStatus.Published;
const hasRowActions = (el: any) =>
canManageKb(el) || canCopy(el) || canEdit(el) || canDelete(el);
const showOperationsColumn = visibleLibs.some((el: any) => isLibraryBusy(el) || hasRowActions(el));
// Enable polling during copying
useEffect(() => {
const todos = datalist.reduce((prev, curr) => {
if (curr.state === KnowledgeBaseStatus.Copying) {
prev.push({ id: curr.id, name: curr.name })
}
return prev
}, [])
todos.map(todo => {
if (doing[todo.id]) {
const lib = datalist.find(item => item.id === todo.id);
if (lib && lib.state !== KnowledgeBaseStatus.Copying) {
message({
variant: 'success',
description: t('copyCompleted', { name: todo.name })
})
delete doing[todo.id]
}
}
})
let timer = null
if (todos.length > 0) {
timer = setTimeout(() => {
reload()
}, 5000);
}
return () => {
clearTimeout(timer)
}
}, [datalist])
const handleDelete = (id) => {
bsConfirm({
title: t('prompt'),
desc: t('lib.confirmDeleteLibrary', { ns: 'bs' }),
onOk(next) {
captureAndAlertRequestErrorHoc(deleteFileLib(id).then(res => {
reload();
}));
next()
},
})
}
const handleOpenSettings = (lib) => {
console.log("=== handleOpenSettings execution started ===");
console.log("Clicked lib ID:", lib.id);
// 1. Deep copy: Completely break reference association with original lib (solving nested property reference issues)
const newCurrentLib = JSON.parse(JSON.stringify(lib));
// 2. Inject unique identifier: Ensure currentSettingLib reference is absolutely unique even if data is identical
newCurrentLib.__updateKey = Date.now(); // Generate different timestamp for each click
setCurrentSettingLib(newCurrentLib); // Now passing a completely new object reference
setSettingsOpen(true);
setModalKey(prev => prev + 1); // Keep modalKey to ensure modal re-mounts
console.log("handleOpenSettings called with lib:", newCurrentLib); // Verify print
};
const handleSettingsClose = (isOpen) => {
console.log("handleSettingsClose called with isOpen:", isOpen);
setSettingsOpen(isOpen);
if (!isOpen) {
setCurrentSettingLib(null);
setSelectOpenId(null);
console.log("Settings modal closed and state cleared");
}
};
// Cache the active tab hint before entering detail; the parent's
// ``defaultValue`` reads ``LibPage.type`` to restore the file/qa tab.
// F027: ``page`` is now an opaque cursor token, not restorable, so we
// only persist the type marker.
const handleCachePage = () => {
window.LibPage = { type: 'file' }
}
useEffect(() => {
// F027: ``useInfiniteCursorTable`` auto-loads page 1 on mount; we no
// longer try to restore a specific page on return from detail. Just
// drop the marker so subsequent navigations get a fresh first page.
delete window.LibPage;
}, [])
const { t, i18n } = useTranslation('knowledge');
useEffect(() => {
i18n.loadNamespaces('knowledge');
}, [i18n]);
// Copy knowledge base
const handleCopy = async (elem) => {
const newName = `${elem.name}${t('copySuffix')}`;
if (newName.length > 200) {
toast({
title: t('operationFailed'),
variant: 'error',
description: t('copyNameExceedsLimit')
});
// Reset all related states
setSelectOpenId(null);
setCopyLoadingId(null);
// Force re-render of Select component
setModalKey(prev => prev + 1);
return;
}
setCopyLoadingId(elem.id);
doing[elem.id] = true;
try {
await captureAndAlertRequestErrorHoc(copyLibDatabase(elem.id, newName));
reload();
} catch (error) {
message({
variant: 'error',
description: t('copyFailed')
});
} finally {
setCopyLoadingId(null);
setSelectOpenId(null);
// Ensure Select component resets
setModalKey(prev => prev + 1);
}
}
useEffect(() => {
console.log("settingsOpen state changed:", settingsOpen);
console.log("currentSettingLib:", currentSettingLib);
console.log("modalKey:", modalKey);
}, [settingsOpen, currentSettingLib, modalKey]);
return (
<div className="relative">
{loading && <div className="absolute w-full h-full top-0 left-0 flex justify-center items-center z-10 bg-[rgba(255,255,255,0.6)] dark:bg-blur-shared">
<LoadingIcon />
</div>}
<div className="h-[calc(100vh-128px-var(--license-banner-h,0px))] overflow-y-auto pb-20">
<div className="flex justify-end gap-4 items-center absolute right-0 top-[-44px]">
<SearchInput placeholder={t('lib.searchPlaceholder', { ns: 'bs' })} onChange={(e) => search(e.target.value)} />
{canCreateLibrary && <Button className="px-8 text-[#FFFFFF]" onClick={() => setOpen(true)}>{t('create', { ns: 'bs' })}</Button>}
</div>
<Table noScroll>
<TableHeader>
<TableRow>
<TableHead>{t('lib.libraryName', { ns: 'bs' })}</TableHead>
<TableHead>{t('updateTime')}</TableHead>
<TableHead>{t('lib.createUser', { ns: 'bs' })}</TableHead>
{showOperationsColumn && (
<TableHead className="text-right">{t('operations')}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{visibleLibs.map((el: any) => (
<TableRow
key={el.id}
className=""
onClick={() => {
if (!canReadRow(el)) return;
if ([KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state)) return;
window.libname = [el.name, el.description];
navigate(`/filelib/${el.id}`);
handleCachePage();
}}
>
<TableCell
className="font-medium max-w-[200px]"
>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center size-12 text-white rounded-[4px] w-[40px] h-[40px]">
<BookIcon className="text-primary size-10" />
</div>
<div>
<div className="truncate max-w-[500px] w-[264px] text-[14px] font-medium pt-2">
{el.name}
</div>
<Tip
side="top"
content={el.description?.length > 30 ? el.description : ''}
>
<div className="truncate max-w-[500px] text-[12px] text-[#5A5A5A] pt-1">
{el.description || ''}
</div>
</Tip>
</div>
</div>
</TableCell>
<TableCell
className="text-[#5A5A5A]"
>
{el.update_time.replace('T', ' ')}
</TableCell>
<TableCell
className="max-w-[300px] break-all"
>
<div className="truncate-multiline text-[#5A5A5A]">{el.user_name || '--'}</div>
</TableCell>
{showOperationsColumn && <TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{(isLibraryBusy(el) || hasRowActions(el)) && <Select
key={`${el.id}-${modalKey}`}
open={selectOpenId === el.id}
onOpenChange={(isOpen) => {
if (isLibraryBusy(el) || !hasRowActions(el)) return;
if (copyLoadingId !== el.id) {
setSelectOpenId(isOpen ? el.id : null);
} else if (!isOpen) {
// If in copying state and about to close, allow closing
setSelectOpenId(null);
}
}}
onValueChange={(selectedValue) => {
setSelectOpenId(null);
console.log("Selected value:", selectedValue, "for lib:", el.id);
switch (selectedValue) {
case 'permission':
setPermTarget({ id: String(el.id), name: el.name });
setPermDialogOpen(true);
setModalKey(prev => prev + 1);
break;
case 'copy':
canCopy(el) && handleCopy(el);
break;
case 'set':
canEdit(el) && handleOpenSettings(el);
break;
case 'delete':
canDelete(el) && handleDelete(el.id);
break;
}
}}
>
<SelectTrigger
showIcon={false}
disabled={copyLoadingId === el.id}
onClick={(e) => {
e.stopPropagation();
}}
className="size-10 px-2 bg-transparent border-none shadow-none hover:bg-gray-300 flex items-center justify-center duration-200 relative"
>
{[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state) ? (
<>
<LoaderCircle className="animate-spin" />
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-white text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap border border-gray-300 shadow-sm">
{t('copying')}
</div>
</>
) : (
hasRowActions(el) && <Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
)}
</SelectTrigger>
{hasRowActions(el) && <SelectContent
onClick={(e) => {
e.stopPropagation();
}}
className="z-50 overflow-visible"
>
{canManageKb(el) && (
<SelectItem showIcon={false} value="permission">
<div className="flex gap-2 items-center">
<Shield className="w-4 h-4" />
{t('managePermission', { ns: 'permission' })}
</div>
</SelectItem>
)}
{canCopy(el) && (
<SelectItem
showIcon={false}
value="copy"
disabled={copyLoadingId === el.id}
>
<div className="flex gap-2 items-center" >
<Copy className="w-4 h-4" />
{t('lib.copy', { ns: 'bs' })}
</div>
</SelectItem>
)}
{canEdit(el) && (
<SelectItem
value="set"
showIcon={false}
>
<div className="flex gap-2 items-center">
<Settings className="w-4 h-4" />
{t('settings')}
</div>
</SelectItem>
)}
{canDelete(el) && (
<SelectItem
value="delete"
showIcon={false}
>
<div className="flex gap-2 items-center">
<Trash2 className="w-4 h-4" />
{t('delete')}
</div>
</SelectItem>
)}
</SelectContent>}
</Select>}
</div>
</TableCell>}
</TableRow>
))}
</TableBody>
</Table>
{/* F027: infinite-scroll trigger lives INSIDE the
`overflow-y-auto` scroll container so IntersectionObserver
tracks in-container scroll. Outside-container placement
keeps the footer always-visible and stalls pagination. */}
{hasMore && <LoadMore onScrollLoad={loadMore} />}
</div>
<div className="bisheng-table-footer px-6 bg-background-login">
<div className="flex items-center gap-2">
<p className="desc">{t('lib.libraryCollection', { ns: 'bs' })}</p>
</div>
</div>
{/* Create modal */}
<CreateModal
datalist={datalist}
open={open}
onOpenChange={setOpen}
onLoadEnd={() => { }}
mode="create"
/>
{/* Edit (Settings) modal - using key to force re-render */}
{settingsOpen && (
<CreateModal
key={`settings-modal-${modalKey}`}
datalist={datalist}
open={settingsOpen}
onOpenChange={handleSettingsClose}
onLoadEnd={reload}
mode="edit"
currentLib={currentSettingLib}
/>
)}
{/* Permission management dialog */}
{permTarget && (
<PermissionDialog
open={permDialogOpen}
onOpenChange={setPermDialogOpen}
resourceType="knowledge_library"
resourceId={permTarget.id}
resourceName={permTarget.name}
/>
)}
</div>
);
}