- 知识库页拆分出 wiki 组件(侧边栏/目录树/Markdown 阅读器等) - 知识图谱页拆分出 knowledge-graph 组件并增强交互 - 新增发布广场选择弹窗 publish-square-picker-dialog - config.yaml 新增 MiniMax-M2.5 / Ling-flash-2.0 / DeepSeek-V4-Pro 模型 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
630 lines
20 KiB
TypeScript
630 lines
20 KiB
TypeScript
import { Loader2 } from "lucide-react";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useNavigate, useParams } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||
import { TocPanel } from "@/components/wiki/TocPanel";
|
||
import { WikiContent } from "@/components/wiki/WikiContent";
|
||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||
import {
|
||
buildKnowledgeTree,
|
||
downloadMarkdown,
|
||
extractToc,
|
||
findAncestorFolderIds,
|
||
} from "@/components/wiki/wikiUtils";
|
||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||
import {
|
||
useKnowledgeActions,
|
||
useKnowledgeDuplicates,
|
||
useKnowledgeNote,
|
||
useKnowledgeNotes,
|
||
useNoteWikilinks,
|
||
useVaultPage,
|
||
} from "@/core/knowledge/hooks";
|
||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||
import { cn } from "@/lib/utils";
|
||
import MarkdownRenderer, {
|
||
type WikiLinkTarget,
|
||
} from "@/strategy-components/MarkdownRenderer";
|
||
|
||
function useDebounced<T>(value: T, delay = 350): T {
|
||
const [debounced, setDebounced] = useState(value);
|
||
useEffect(() => {
|
||
const id = window.setTimeout(() => setDebounced(value), delay);
|
||
return () => window.clearTimeout(id);
|
||
}, [value, delay]);
|
||
return debounced;
|
||
}
|
||
|
||
function CreateNoteDialog({
|
||
open,
|
||
onOpenChange,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
}) {
|
||
const { create } = useKnowledgeActions();
|
||
const navigate = useNavigate();
|
||
const [title, setTitle] = useState("");
|
||
const [tags, setTags] = useState("");
|
||
const [content, setContent] = useState("");
|
||
|
||
const reset = () => {
|
||
setTitle("");
|
||
setTags("");
|
||
setContent("");
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!title.trim()) {
|
||
toast.error("请输入标题");
|
||
return;
|
||
}
|
||
try {
|
||
const note = await create.mutateAsync({
|
||
title: title.trim(),
|
||
content_md: content || `# ${title.trim()}\n\n`,
|
||
tags: tags
|
||
.split(/[,,\s]+/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean),
|
||
status: "approved",
|
||
});
|
||
toast.success("知识已创建");
|
||
onOpenChange(false);
|
||
reset();
|
||
navigate(`/page/workspace/knowledge/${note.id}`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>新建知识</DialogTitle>
|
||
<DialogDescription>手动创建一条知识笔记,写入共享知识库 vault。</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-3">
|
||
<Input
|
||
placeholder="标题"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
/>
|
||
<Input
|
||
placeholder="标签,使用逗号分隔"
|
||
value={tags}
|
||
onChange={(e) => setTags(e.target.value)}
|
||
/>
|
||
<Textarea
|
||
placeholder="Markdown 正文"
|
||
rows={12}
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
className="font-mono text-sm"
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={() => void handleSubmit()} disabled={create.isPending}>
|
||
{create.isPending ? "创建中..." : "创建"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function BatchImportDialog({
|
||
open,
|
||
onOpenChange,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
}) {
|
||
const { batchIngest } = useKnowledgeActions();
|
||
const [limit, setLimit] = useState(50);
|
||
const [skipExisting, setSkipExisting] = useState(true);
|
||
// Optional model override for extraction (null = configured default).
|
||
const [model, setModel] = useState<string | null>(null);
|
||
|
||
const handleRun = async () => {
|
||
try {
|
||
const result = await batchIngest.mutateAsync({
|
||
limit,
|
||
skip_existing: skipExisting,
|
||
status: "approved",
|
||
include_sources: true,
|
||
model_name: model,
|
||
});
|
||
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
|
||
if (result.llm_fallback && result.llm_fallback > 0) {
|
||
// The model failed on some threads — they were saved via rule-based
|
||
// extraction instead. Say so explicitly per the requirement.
|
||
toast.warning(
|
||
`${base};其中 ${result.llm_fallback} 条因大模型异常改用了规则抽取(草稿)`,
|
||
);
|
||
} else {
|
||
toast.success(base);
|
||
}
|
||
onOpenChange(false);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "批量导入失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>批量导入历史线程</DialogTitle>
|
||
<DialogDescription>
|
||
扫描你最近的对话线程并沉淀为知识库内容。已沉淀的线程可自动跳过。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-4">
|
||
<label className="flex items-center justify-between gap-2 text-sm">
|
||
处理数量上限
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={500}
|
||
value={limit}
|
||
onChange={(e) => setLimit(Number(e.target.value) || 1)}
|
||
className="w-28"
|
||
/>
|
||
</label>
|
||
<label className="flex items-center justify-between gap-2 text-sm">
|
||
跳过已沉淀线程
|
||
<input
|
||
type="checkbox"
|
||
checked={skipExisting}
|
||
onChange={(e) => setSkipExisting(e.target.checked)}
|
||
className="size-4"
|
||
/>
|
||
</label>
|
||
<label className="flex items-center justify-between gap-2 text-sm">
|
||
提炼大模型
|
||
<ModelNameSelect
|
||
value={model}
|
||
onChange={setModel}
|
||
disabled={batchIngest.isPending}
|
||
triggerClassName="h-9 w-[11rem]"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={() => void handleRun()} disabled={batchIngest.isPending}>
|
||
{batchIngest.isPending ? "导入中..." : "开始导入"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function ImportFileDialog({
|
||
open,
|
||
onOpenChange,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
}) {
|
||
const { ingestFile } = useKnowledgeActions();
|
||
const navigate = useNavigate();
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [title, setTitle] = useState("");
|
||
const [tags, setTags] = useState("");
|
||
const [model, setModel] = useState<string | null>(null);
|
||
|
||
const reset = () => {
|
||
setFile(null);
|
||
setTitle("");
|
||
setTags("");
|
||
setModel(null);
|
||
};
|
||
|
||
const handleImport = async () => {
|
||
if (!file) {
|
||
toast.error("请选择文件");
|
||
return;
|
||
}
|
||
try {
|
||
const result = await ingestFile.mutateAsync({
|
||
file,
|
||
opts: {
|
||
title: title.trim() || undefined,
|
||
tags: tags.trim() || undefined,
|
||
status: "approved",
|
||
model_name: model,
|
||
},
|
||
});
|
||
const count = result.notes?.length ?? 1;
|
||
if (result.llm_fallback) {
|
||
// The model produced nothing usable — the file was stored verbatim as a
|
||
// draft instead of distilled into a wiki note.
|
||
toast.warning(
|
||
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
|
||
);
|
||
} else {
|
||
toast.success(`已从文件生成 ${count} 条知识`);
|
||
}
|
||
onOpenChange(false);
|
||
reset();
|
||
if (result.note?.id) {
|
||
navigate(`/page/workspace/knowledge/${result.note.id}`);
|
||
}
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "文件导入失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>导入文件生成 Wiki</DialogTitle>
|
||
<DialogDescription>
|
||
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-4">
|
||
<label className="flex flex-col gap-1.5 text-sm">
|
||
选择文件
|
||
<Input
|
||
type="file"
|
||
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
|
||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||
className="cursor-pointer"
|
||
/>
|
||
{file ? (
|
||
<span className="text-muted-foreground truncate text-xs">
|
||
{file.name} · {(file.size / 1024).toFixed(0)} KB
|
||
</span>
|
||
) : null}
|
||
</label>
|
||
<Input
|
||
placeholder="标题(可选,留空则由模型生成)"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
/>
|
||
<Input
|
||
placeholder="标签,逗号分隔(可选)"
|
||
value={tags}
|
||
onChange={(e) => setTags(e.target.value)}
|
||
/>
|
||
<label className="flex items-center justify-between gap-2 text-sm">
|
||
提炼大模型
|
||
<ModelNameSelect
|
||
value={model}
|
||
onChange={setModel}
|
||
disabled={ingestFile.isPending}
|
||
triggerClassName="h-9 w-[11rem]"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
|
||
{ingestFile.isPending ? "导入中..." : "导入并生成"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>查重与合并</DialogTitle>
|
||
<DialogDescription>
|
||
基于标题/摘要相似度与内容哈希检测重复知识;合并会把重复项归档并把来源并入主条目。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="max-h-[60vh] overflow-auto">
|
||
{isLoading ? (
|
||
<div className="text-muted-foreground p-4 text-sm">检测中...</div>
|
||
) : groups.length === 0 ? (
|
||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||
未发现明显重复的知识。
|
||
</div>
|
||
) : (
|
||
<ul className="flex flex-col gap-3">
|
||
{groups.map((g) => (
|
||
<li key={g.primary.id} className="rounded-md border p-3">
|
||
<div className="mb-2 text-sm font-medium">
|
||
保留:{g.primary.title || g.primary.id}
|
||
</div>
|
||
<ul className="mb-2 flex flex-col gap-1">
|
||
{g.duplicates.map((d) => (
|
||
<li key={d.id} className="text-muted-foreground text-xs">
|
||
合并:{d.title || d.id}(相似度 {Math.round(d.score * 100)}%)
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
void handleMerge(
|
||
g.primary.id,
|
||
g.duplicates.map((d) => d.id),
|
||
)
|
||
}
|
||
disabled={merge.isPending}
|
||
>
|
||
合并到主条目
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
关闭
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function VaultPageDialog({
|
||
path,
|
||
onOpenChange,
|
||
}: {
|
||
path: string | null;
|
||
onOpenChange: (open: boolean) => void;
|
||
}) {
|
||
const { data: page, isLoading } = useVaultPage(path ?? undefined);
|
||
return (
|
||
<Dialog open={!!path} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{page?.title || "知识库页面"}</DialogTitle>
|
||
<DialogDescription>{path}</DialogDescription>
|
||
</DialogHeader>
|
||
<ScrollArea className="max-h-[60vh]">
|
||
<div className="p-1">
|
||
{isLoading ? (
|
||
<div className="text-muted-foreground p-4 text-sm">加载中...</div>
|
||
) : page ? (
|
||
<MarkdownRenderer content={page.content_md} />
|
||
) : (
|
||
<div className="text-muted-foreground p-4 text-sm">页面不存在</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
关闭
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export default function KnowledgeBasePage() {
|
||
const navigate = useNavigate();
|
||
const { noteId } = useParams<{ noteId: string }>();
|
||
|
||
// --- list / tree state ---
|
||
const [keyword, setKeyword] = useState("");
|
||
const debouncedKeyword = useDebounced(keyword);
|
||
const [sourceType, setSourceType] = useState("all");
|
||
const [status, setStatus] = useState("all");
|
||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||
const treeInitializedRef = useRef(false);
|
||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||
|
||
// --- dialogs ---
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [batchOpen, setBatchOpen] = useState(false);
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [dedupOpen, setDedupOpen] = useState(false);
|
||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||
|
||
// --- reading state ---
|
||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||
|
||
const params = useMemo(
|
||
() => ({
|
||
keyword: debouncedKeyword || undefined,
|
||
source_type: sourceType === "all" ? undefined : sourceType,
|
||
status: status === "all" ? undefined : status,
|
||
limit: 500,
|
||
}),
|
||
[debouncedKeyword, sourceType, status],
|
||
);
|
||
|
||
const { data, isLoading } = useKnowledgeNotes(params);
|
||
const notes = data?.items ?? [];
|
||
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
|
||
|
||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||
const tocItems = useMemo(
|
||
() => (note ? extractToc(note.content_md) : []),
|
||
[note],
|
||
);
|
||
|
||
// First load: expand the root-level folders so the tree reads as a directory.
|
||
useEffect(() => {
|
||
if (treeInitializedRef.current || tree.length === 0) return;
|
||
treeInitializedRef.current = true;
|
||
setExpandedIds(
|
||
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
|
||
);
|
||
}, [tree]);
|
||
|
||
// Keep the selected document's ancestor folders open (deep links included).
|
||
useEffect(() => {
|
||
if (!noteId || tree.length === 0) return;
|
||
const ancestors = findAncestorFolderIds(tree, noteId);
|
||
if (!ancestors || ancestors.length === 0) return;
|
||
setExpandedIds((prev) => {
|
||
if (ancestors.every((id) => prev.has(id))) return prev;
|
||
const next = new Set(prev);
|
||
for (const id of ancestors) next.add(id);
|
||
return next;
|
||
});
|
||
}, [noteId, tree]);
|
||
|
||
const handleToggleFolder = (id: string) => {
|
||
setExpandedIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleSelectNote = (item: KnowledgeNote) => {
|
||
setMobileSidebarOpen(false);
|
||
navigate(`/page/workspace/knowledge/${item.id}`);
|
||
};
|
||
|
||
const handleTocClick = (id: string) => {
|
||
setActiveHeadingId(id);
|
||
document
|
||
.getElementById(id)
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
};
|
||
|
||
const handleWikiLink = (link: WikiLinkTarget) => {
|
||
if (link.kind === "note" && link.note_id) {
|
||
navigate(`/page/workspace/knowledge/${link.note_id}`);
|
||
} else if (link.kind === "vault" && link.vault_path) {
|
||
setVaultPath(link.vault_path);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
|
||
<WikiHeader
|
||
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
|
||
onImportFile={() => setImportOpen(true)}
|
||
onBatchImport={() => setBatchOpen(true)}
|
||
onDedup={() => setDedupOpen(true)}
|
||
onGraph={() => navigate("/page/workspace/knowledge/graph")}
|
||
onCreate={() => setCreateOpen(true)}
|
||
/>
|
||
|
||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[300px_minmax(0,1fr)] xl:grid-cols-[300px_minmax(0,1fr)_280px]">
|
||
{mobileSidebarOpen ? (
|
||
<div
|
||
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
||
onClick={() => setMobileSidebarOpen(false)}
|
||
aria-hidden
|
||
/>
|
||
) : null}
|
||
<KnowledgeSidebar
|
||
keyword={keyword}
|
||
onKeywordChange={setKeyword}
|
||
sourceType={sourceType}
|
||
onSourceTypeChange={setSourceType}
|
||
status={status}
|
||
onStatusChange={setStatus}
|
||
tree={tree}
|
||
expandedIds={expandedIds}
|
||
onToggleFolder={handleToggleFolder}
|
||
selectedId={noteId}
|
||
onSelect={handleSelectNote}
|
||
onExportNote={downloadMarkdown}
|
||
total={data?.total ?? 0}
|
||
isLoading={isLoading}
|
||
className={cn(
|
||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
|
||
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
|
||
)}
|
||
/>
|
||
|
||
{!noteId ? (
|
||
<EmptyDocumentState />
|
||
) : noteLoading || !note ? (
|
||
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
|
||
{noteLoading ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||
</span>
|
||
) : (
|
||
"知识不存在"
|
||
)}
|
||
</div>
|
||
) : (
|
||
<WikiContent
|
||
note={note}
|
||
toc={tocItems}
|
||
wikiLinks={wikiLinks}
|
||
onWikiLinkClick={handleWikiLink}
|
||
onActiveHeadingChange={setActiveHeadingId}
|
||
/>
|
||
)}
|
||
|
||
<TocPanel
|
||
items={tocItems}
|
||
activeId={activeHeadingId}
|
||
onItemClick={handleTocClick}
|
||
className="hidden xl:block"
|
||
/>
|
||
</div>
|
||
|
||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
|
||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||
<VaultPageDialog
|
||
path={vaultPath}
|
||
onOpenChange={(open) => {
|
||
if (!open) setVaultPath(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|