From 5d52ce83806df3040e0525804df730eec44a24b0 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 12 Jul 2026 18:02:24 +0800 Subject: [PATCH] Implement knowledge base page routing and enhance ComponentsKnowledgePage - Update the main knowledge page to support a list mode. - Add new pages for creating and editing knowledge bases, utilizing the ComponentsKnowledgePage component with appropriate modes. - Introduce a new API method for retrieving knowledge base details by ID. - Enhance the ComponentsKnowledgePage to handle different modes (list, create, edit) and improve user interaction with knowledge bases. --- .../app/components/knowledge/[id]/page.tsx | 10 + .../src/app/components/knowledge/new/page.tsx | 5 + .../src/app/components/knowledge/page.tsx | 2 +- .../src/components/pages/AssistantPage.tsx | 4 +- .../pages/ComponentsKnowledgePage.tsx | 1243 +++++++++++++++-- frontend/src/lib/api.ts | 1 + 6 files changed, 1138 insertions(+), 127 deletions(-) create mode 100644 frontend/src/app/components/knowledge/[id]/page.tsx create mode 100644 frontend/src/app/components/knowledge/new/page.tsx diff --git a/frontend/src/app/components/knowledge/[id]/page.tsx b/frontend/src/app/components/knowledge/[id]/page.tsx new file mode 100644 index 0000000..dc7379e --- /dev/null +++ b/frontend/src/app/components/knowledge/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/frontend/src/app/components/knowledge/new/page.tsx b/frontend/src/app/components/knowledge/new/page.tsx new file mode 100644 index 0000000..31273cb --- /dev/null +++ b/frontend/src/app/components/knowledge/new/page.tsx @@ -0,0 +1,5 @@ +import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage"; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/components/knowledge/page.tsx b/frontend/src/app/components/knowledge/page.tsx index b277f08..4afdfe0 100644 --- a/frontend/src/app/components/knowledge/page.tsx +++ b/frontend/src/app/components/knowledge/page.tsx @@ -1,5 +1,5 @@ import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage"; export default function Page() { - return ; + return ; } diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 80f9013..b02a4dd 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -3041,7 +3041,9 @@ function TextAreaField({ placeholder={placeholder} onChange={(event) => onChange(event.target.value)} rows={rows} - className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" + // Override ui/textarea's field-sizing-content so `rows` sets a real height + // instead of collapsing to min-h-16 when the value is short. + className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" /> ); diff --git a/frontend/src/components/pages/ComponentsKnowledgePage.tsx b/frontend/src/components/pages/ComponentsKnowledgePage.tsx index f8c68e0..85b50de 100644 --- a/frontend/src/components/pages/ComponentsKnowledgePage.tsx +++ b/frontend/src/components/pages/ComponentsKnowledgePage.tsx @@ -1,27 +1,74 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; import { - Database, Eye, FileText, Loader2, Pencil, Plus, RefreshCw, - Search, Trash2, Upload, + ChevronDown, + ChevronLeft, + ChevronUp, + Eye, + FileText, + Loader2, + MoreHorizontal, + Pencil, + Plus, + RefreshCw, + Rocket, + Search, + Settings, + Trash2, + Upload, } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DataList } from "@/components/ui/data-list"; import { - Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { ListToolbar } from "@/components/ui/list-toolbar"; +import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select"; -import { - knowledgeBasesApi, modelResourcesApi, type KnowledgeBase, - type KnowledgeChunk, type KnowledgeDocument, type KnowledgeSearchResult, + knowledgeBasesApi, + modelResourcesApi, + type KnowledgeBase, + type KnowledgeChunk, + type KnowledgeDocument, + type KnowledgeSearchResult, type ModelResource, } from "@/lib/api"; -type BaseDialogMode = "create" | "edit" | null; +// 路由驱动: +// /components/knowledge → list +// /components/knowledge/new → create +// /components/knowledge/[id] → edit +export type ComponentsKnowledgePageProps = + | { mode: "list" } + | { mode: "create" } + | { mode: "edit"; knowledgeBaseId: string }; + +type SortOrder = "newest" | "oldest"; function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; @@ -29,29 +76,483 @@ function formatBytes(bytes: number) { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } +function formatUpdatedAt(value?: string | null) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function updatedAtValue(value?: string | null) { + const time = value ? new Date(value).getTime() : NaN; + return Number.isNaN(time) ? 0 : time; +} + function statusLabel(status: string) { - return ({ pending: "等待处理", processing: "处理中", ready: "已完成", failed: "失败" } as Record)[status] ?? status; + return ( + ( + { + pending: "等待处理", + processing: "处理中", + ready: "已完成", + failed: "失败", + active: "可用", + } as Record + )[status] ?? status + ); } function statusClass(status: string) { - if (status === "ready") return "bg-emerald-500/10 text-emerald-600"; + if (status === "ready" || status === "active") + return "bg-emerald-500/10 text-emerald-600"; if (status === "failed") return "bg-destructive/10 text-destructive"; return "bg-amber-500/10 text-amber-600"; } -export function ComponentsKnowledgePage() { +export function ComponentsKnowledgePage(props: ComponentsKnowledgePageProps) { + if (props.mode === "list") return ; + if (props.mode === "create") return ; + return ; +} + +function KnowledgeListView() { + const router = useRouter(); const [bases, setBases] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [search, setSearch] = useState(""); + const [sortOrder, setSortOrder] = useState("newest"); + const [currentPage, setCurrentPage] = useState(1); + const [deletingId, setDeletingId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(""); + try { + setBases(await knowledgeBasesApi.list()); + } catch (cause) { + setLoadError(cause instanceof Error ? cause.message : "加载失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial remote data load + void load(); + }, [load]); + + const filtered = useMemo(() => { + const keyword = search.trim().toLowerCase(); + return bases.filter((base) => { + if (!keyword) return true; + return [base.name, base.description, base.id] + .join(" ") + .toLowerCase() + .includes(keyword); + }); + }, [bases, search]); + + const sorted = useMemo(() => { + return [...filtered].sort((a, b) => { + const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt); + if (diff !== 0) return sortOrder === "newest" ? diff : -diff; + return a.name.localeCompare(b.name, "zh-CN"); + }); + }, [filtered, sortOrder]); + + const pageSize = 5; + const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); + const safeCurrentPage = Math.min(currentPage, totalPages); + const pageStart = (safeCurrentPage - 1) * pageSize; + const pageEnd = pageStart + pageSize; + const paginated = sorted.slice(pageStart, pageEnd); + + function openEdit(base: KnowledgeBase) { + router.push(`/components/knowledge/${base.id}`); + } + + async function removeBase(base: KnowledgeBase) { + if (!window.confirm(`确定删除知识库“${base.name}”及其全部文档吗?`)) { + return; + } + setDeletingId(base.id); + setLoadError(""); + try { + await knowledgeBasesApi.remove(base.id); + await load(); + } catch (cause) { + setLoadError(cause instanceof Error ? cause.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + return ( +
+ router.push("/components/knowledge/new")} + > + + 添加知识库 + + } + /> + +
+ { + setSearch(value); + setCurrentPage(1); + }} + placeholder="搜索知识库名称、说明或 ID..." + className="lg:w-[320px]" + /> + } + /> + + + rows={paginated} + rowKey={(base) => base.id} + loading={loading} + loadingText="正在加载知识库列表…" + error={loadError || null} + onRetry={() => void load()} + onRowClick={openEdit} + empty={{ + title: bases.length === 0 ? "暂无知识库" : "未找到匹配的知识库", + description: + bases.length === 0 + ? "点击右上角「添加知识库」开始。" + : "请调整关键词后再试。", + }} + pagination={{ + page: safeCurrentPage, + totalPages, + onPageChange: setCurrentPage, + summary: + filtered.length === 0 + ? "没有数据" + : `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个知识库`, + }} + columns={[ + { + key: "name", + header: "知识库名称", + width: "md:w-[360px]", + cell: (base) => ( + <> +
+ {base.name} +
+
+ {base.description || base.id} +
+ + ), + }, + { + key: "status", + header: "状态", + width: "md:w-[128px]", + cell: (base) => ( + + {statusLabel(base.status)} + + ), + }, + { + key: "updatedAt", + width: "md:w-[176px]", + header: ( + + ), + cellClassName: + "whitespace-nowrap tabular-nums text-muted-foreground", + cell: (base) => formatUpdatedAt(base.updatedAt), + }, + { + key: "actions", + header: "操作", + align: "right", + cell: (base) => ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + + + + + + + { + // Keep the menu from stealing the next click; defer + // confirm so row onRowClick does not fire afterward. + event.preventDefault(); + window.setTimeout(() => void removeBase(base), 0); + }} + > + {deletingId === base.id ? ( + + ) : ( + + )} + 删除 + + + +
+ ), + }, + ]} + /> +
+
+ ); +} + +function KnowledgeCreateView() { + const router = useRouter(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [embeddingId, setEmbeddingId] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + useEffect(() => { + void (async () => { + try { + const resources = await modelResourcesApi.list(); + const embeddingModels = resources.filter( + (item) => item.capability === "Embedding" && item.enabled, + ); + setModels(embeddingModels); + setEmbeddingId(embeddingModels[0]?.id ?? ""); + } catch (cause) { + setCreateError(cause instanceof Error ? cause.message : "加载模型失败"); + } finally { + setLoading(false); + } + })(); + }, []); + + async function confirmCreate() { + if (!name.trim() || !embeddingId || creating) return; + setCreating(true); + setCreateError(""); + try { + const saved = await knowledgeBasesApi.create({ + name: name.trim(), + description, + embeddingModelResourceId: embeddingId, + }); + router.push(`/components/knowledge/${saved.id}`); + } catch (cause) { + setCreateError(cause instanceof Error ? cause.message : "创建失败"); + setCreating(false); + } + } + + return ( +
+
+
+

添加知识库

+

+ 填写名称、说明并选择 Embedding 模型。确认后将立即创建并进入编辑页。 +

+
+ + +
+ + {loading ? ( +
+ + 正在加载 Embedding 模型… +
+ ) : ( + <> +
+
+ + +