geo/frontend/app/(dashboard)/dashboard/knowledge/page.tsx

625 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
BookOpen,
Plus,
Trash2,
Search,
FileText,
Link,
Type,
AlertCircle,
Loader2,
} from "lucide-react";
import {
knowledgeApi,
type KnowledgeBase,
type KnowledgeDocument,
} from "@/lib/api";
import { useApi } from "@/lib/hooks/use-api";
import { LoadingState, ErrorState } from "@/components/ui/api-states";
// ── 状态 Badge ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
switch (status) {
case "processing":
return <Badge variant="warning"></Badge>;
case "ready":
return <Badge variant="success"></Badge>;
case "failed":
return <Badge variant="destructive"></Badge>;
case "active":
return <Badge variant="success"></Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
function SourceTypeBadge({ type }: { type: string }) {
switch (type) {
case "text":
return (
<Badge variant="outline" className="gap-1">
<Type className="h-3 w-3" />
</Badge>
);
case "url":
return (
<Badge variant="outline" className="gap-1">
<Link className="h-3 w-3" />
URL
</Badge>
);
case "markdown":
return (
<Badge variant="outline" className="gap-1">
<FileText className="h-3 w-3" />
Markdown
</Badge>
);
default:
return <Badge variant="outline">{type}</Badge>;
}
}
// ── 空状态组件 ─────────────────────────────────────────────────────────────────
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
return (
<div className="text-center py-16">
<BookOpen className="h-12 w-12 mx-auto text-gray-300" />
<h3 className="mt-4 text-base font-semibold text-gray-900"></h3>
<p className="mt-2 text-sm text-gray-500 max-w-xs mx-auto">
AI内容生产提供精准的知识支撑
</p>
<Button variant="outline" className="mt-4" onClick={onCreateClick}>
<Plus className="h-4 w-4 mr-1.5" />
</Button>
</div>
);
}
// ── 知识库卡片 + 展开文档列表 ────────────────────────────────────────────────────
function KnowledgeBaseCard({
kb,
isExpanded,
onToggle,
onDelete,
onUpload,
}: {
kb: KnowledgeBase;
isExpanded: boolean;
onToggle: () => void;
onDelete: (id: string) => void;
onUpload: (id: string) => void;
}) {
const docsUrl = isExpanded ? `/api/v1/knowledge/bases/${kb.id}/documents` : null;
const { data: documents = [], isLoading: docsLoading, error: docsApiError, refresh: refreshDocs } =
useApi<KnowledgeDocument[]>(docsUrl);
const docsError = docsApiError?.message ?? null;
const handleDeleteDoc = async (docId: string) => {
try {
await knowledgeApi.deleteDocument(undefined, kb.id, docId);
refreshDocs();
} catch (err) {
console.error("Delete doc error:", err);
}
};
return (
<div className="space-y-3">
<Card className="cursor-pointer rounded-xl border border-gray-200" onClick={onToggle}>
<CardHeader className="pb-3">
<CardTitle className="text-base text-gray-900">{kb.name}</CardTitle>
{kb.description && (
<CardDescription className="line-clamp-2 text-gray-500">{kb.description}</CardDescription>
)}
</CardHeader>
<CardFooter className="pt-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<FileText className="h-3 w-3" />
{kb.document_count}
</Badge>
<StatusBadge status={kb.status} />
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onDelete(kb.id);
}}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</CardFooter>
</Card>
{isExpanded && (
<Card className="rounded-xl border border-gray-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold"></h4>
<Button variant="outline" size="sm" onClick={() => onUpload(kb.id)}>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{docsLoading ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : docsError ? (
<div className="flex items-center gap-2 py-4 text-xs text-red-600">
<AlertCircle className="h-3.5 w-3.5" />
{docsError}
</div>
) : documents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((doc) => (
<TableRow key={doc.id}>
<TableCell className="font-medium">{doc.title}</TableCell>
<TableCell>
<SourceTypeBadge type={doc.source_type} />
</TableCell>
<TableCell>
<StatusBadge status={doc.status} />
</TableCell>
<TableCell className="text-right">{doc.chunk_count}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDeleteDoc(doc.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
}
// ── 主页面 ─────────────────────────────────────────────────────────────────────
export default function KnowledgePage() {
const [activeTab, setActiveTab] = useState("enterprise");
const [searchQuery, setSearchQuery] = useState("");
const [expandedKbId, setExpandedKbId] = useState<string | null>(null);
// SWR data fetching
const {
data: enterpriseBases = [],
isLoading: enterpriseLoading,
error: enterpriseError,
mutate: mutateEnterprise,
} = useApi<KnowledgeBase[]>("/api/v1/knowledge/bases/?type=enterprise");
const {
data: industryBases = [],
isLoading: industryLoading,
error: industryError,
} = useApi<KnowledgeBase[]>("/api/v1/knowledge/bases/?type=industry");
const loading = enterpriseLoading || industryLoading;
const error = enterpriseError?.message || industryError?.message || null;
// Create KB dialog
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newKbName, setNewKbName] = useState("");
const [newKbDescription, setNewKbDescription] = useState("");
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
// Upload doc dialog
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadKbId, setUploadKbId] = useState<string | null>(null);
const [docTitle, setDocTitle] = useState("");
const [docSourceType, setDocSourceType] = useState<"text" | "url" | "markdown">("text");
const [docContent, setDocContent] = useState("");
const [docUrl, setDocUrl] = useState("");
const [uploadLoading, setUploadLoading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fetchBases = useCallback(() => {
mutateEnterprise();
}, [mutateEnterprise]);
const currentBases = activeTab === "enterprise" ? enterpriseBases : industryBases;
const filteredBases = searchQuery
? currentBases.filter(
(kb) =>
kb.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
kb.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: currentBases;
const handleCreateKb = async () => {
if (!newKbName.trim()) return;
try {
setCreateLoading(true);
setCreateError(null);
await knowledgeApi.createBase(undefined, {
name: newKbName.trim(),
type: "enterprise",
description: newKbDescription.trim() || undefined,
});
mutateEnterprise();
setCreateDialogOpen(false);
setNewKbName("");
setNewKbDescription("");
} catch (err) {
setCreateError(err instanceof Error ? err.message : "创建失败");
} finally {
setCreateLoading(false);
}
};
const handleDeleteKb = async (kbId: string) => {
try {
await knowledgeApi.deleteBase(undefined, kbId);
mutateEnterprise();
if (expandedKbId === kbId) setExpandedKbId(null);
} catch (err) {
console.error("Delete KB error:", err);
}
};
const handleUploadDoc = async () => {
if (!uploadKbId || !docTitle.trim()) return;
try {
setUploadLoading(true);
setUploadError(null);
await knowledgeApi.uploadDocument(undefined, uploadKbId, {
title: docTitle.trim(),
source_type: docSourceType,
content: docSourceType !== "url" ? docContent : undefined,
source_url: docSourceType === "url" ? docUrl : undefined,
});
mutateEnterprise();
setUploadDialogOpen(false);
setDocTitle("");
setDocContent("");
setDocUrl("");
setDocSourceType("text");
} catch (err) {
setUploadError(err instanceof Error ? err.message : "上传失败");
} finally {
setUploadLoading(false);
}
};
const openUploadDialog = (kbId: string) => {
setUploadKbId(kbId);
setUploadError(null);
setUploadDialogOpen(true);
};
if (loading) {
return (
<div className="space-y-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">AI内容生产提供智能支撑</p>
</div>
<LoadingState rows={3} grid cols={3} rowHeight="h-40" />
</div>
);
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">
AI内容生产提供智能支撑
</p>
</div>
{/* Error Banner */}
{error && (
<ErrorState error={error} onRetry={fetchBases} />
)}
{/* Tab 切换 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="enterprise">
({enterpriseBases.length})
</TabsTrigger>
<TabsTrigger value="industry">
({industryBases.length})
</TabsTrigger>
</TabsList>
{/* ── 企业知识库 ── */}
<TabsContent value="enterprise">
<div className="flex items-center justify-between gap-4 mb-6">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索知识库..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={() => { setCreateError(null); setCreateDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-1.5" />
</Button>
</div>
{filteredBases.length === 0 ? (
<EmptyState onCreateClick={() => setCreateDialogOpen(true)} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBases.map((kb) => (
<KnowledgeBaseCard
key={kb.id}
kb={kb}
isExpanded={expandedKbId === kb.id}
onToggle={() => setExpandedKbId(expandedKbId === kb.id ? null : kb.id)}
onDelete={handleDeleteKb}
onUpload={openUploadDialog}
/>
))}
</div>
)}
</TabsContent>
{/* ── 行业知识库 ── */}
<TabsContent value="industry">
<div className="mb-4">
<p className="text-sm text-muted-foreground">
</p>
</div>
{filteredBases.length === 0 ? (
<div className="text-center py-16">
<BookOpen className="h-12 w-12 mx-auto text-gray-300" />
<p className="mt-4 text-sm text-gray-500"></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBases.map((kb) => (
<Card key={kb.id} className="rounded-xl border border-gray-200">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<CardTitle className="text-base text-gray-900">{kb.name}</CardTitle>
<Badge variant="info" className="text-[10px]"></Badge>
</div>
{kb.description && (
<CardDescription className="line-clamp-2">{kb.description}</CardDescription>
)}
</CardHeader>
<CardFooter className="pt-0">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<FileText className="h-3 w-3" />
{kb.document_count}
</Badge>
<StatusBadge status={kb.status} />
</div>
</CardFooter>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
{/* ── 创建知识库 Dialog ── */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
AI内容生产提供精准知识支撑
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{createError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{createError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="kb-name"></Label>
<Input
id="kb-name"
placeholder="输入知识库名称"
value={newKbName}
onChange={(e) => setNewKbName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="kb-desc"></Label>
<textarea
id="kb-desc"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 hover:border-primary/40 transition-colors resize-none"
placeholder="描述知识库的用途和内容范围(可选)"
value={newKbDescription}
onChange={(e) => setNewKbDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={createLoading}>
</Button>
<Button onClick={handleCreateKb} disabled={!newKbName.trim() || createLoading}>
{createLoading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />...</>
) : (
"创建"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 上传文档 Dialog ── */}
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
URL
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{uploadError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{uploadError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="doc-title"></Label>
<Input
id="doc-title"
placeholder="输入文档标题"
value={docTitle}
onChange={(e) => setDocTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={docSourceType}
onValueChange={(v) => setDocSourceType(v as "text" | "url" | "markdown")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="markdown">Markdown</SelectItem>
</SelectContent>
</Select>
</div>
{docSourceType === "url" ? (
<div className="space-y-2">
<Label htmlFor="doc-url">URL </Label>
<Input
id="doc-url"
placeholder="https://example.com/article"
value={docUrl}
onChange={(e) => setDocUrl(e.target.value)}
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="doc-content">
{docSourceType === "markdown" ? "Markdown 内容" : "文本内容"}
</Label>
<textarea
id="doc-content"
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 hover:border-primary/40 transition-colors resize-none"
placeholder={
docSourceType === "markdown"
? "输入 Markdown 格式的内容..."
: "输入纯文本内容..."
}
value={docContent}
onChange={(e) => setDocContent(e.target.value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUploadDialogOpen(false)} disabled={uploadLoading}>
</Button>
<Button
onClick={handleUploadDoc}
disabled={
!docTitle.trim() ||
(docSourceType === "url" ? !docUrl.trim() : !docContent.trim()) ||
uploadLoading
}
>
{uploadLoading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />...</>
) : (
"上传"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}