feat: add AI knowledge analysis MVP

This commit is contained in:
2026-07-05 00:47:52 -07:00
parent 6aa72d3b43
commit e204974b57
32 changed files with 8705 additions and 4 deletions

View File

@@ -0,0 +1,353 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Edit3, Plus, Search, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { AiKnowledgeScope, AiKnowledgeStatus, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import type { Permission } from "@/modules/core/types";
type KnowledgeManagementClientProps = {
initialEntries: KnowledgeEntry[];
permissions: Permission[];
};
type KnowledgeFormState = {
scope: AiKnowledgeScope;
title: string;
category: string;
tags: string;
body: string;
status: AiKnowledgeStatus;
};
const emptyForm: KnowledgeFormState = {
scope: "organization",
title: "",
category: "",
tags: "",
body: "",
status: "enabled",
};
const scopeOptions = [
{ value: "organization", label: "机构私有" },
{ value: "platform", label: "平台共享" },
];
const statusOptions = [
{ value: "enabled", label: "启用" },
{ value: "disabled", label: "停用" },
];
function entryToForm(entry: KnowledgeEntry): KnowledgeFormState {
return {
scope: entry.scope,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
};
}
function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return true;
}
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
}
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
const [entries, setEntries] = useState(initialEntries);
const [query, setQuery] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<KnowledgeEntry | null>(null);
const [form, setForm] = useState<KnowledgeFormState>(emptyForm);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [message, setMessage] = useState("");
const canManage = permissions.includes("knowledge:manage");
const filteredEntries = useMemo(() => entries.filter((entry) => matchesEntry(entry, query)), [entries, query]);
const isEditing = editingId !== null;
function updateField<K extends keyof KnowledgeFormState>(key: K, value: KnowledgeFormState[K]): void {
setForm((current) => ({ ...current, [key]: value }));
}
function beginCreate(): void {
setEditingId(null);
setForm(emptyForm);
setMessage("");
setIsDialogOpen(true);
}
function beginEdit(entry: KnowledgeEntry): void {
setEditingId(entry.id);
setForm(entryToForm(entry));
setMessage("");
setIsDialogOpen(true);
}
function closeDialog(): void {
if (isPending) {
return;
}
setIsDialogOpen(false);
setEditingId(null);
setForm(emptyForm);
}
async function refreshEntries(): Promise<void> {
const response = await fetch("/api/ai/knowledge");
const result = (await response.json()) as ApiResult<{ entries: KnowledgeEntry[] }>;
if (result.success) {
setEntries(result.entries);
}
}
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage) {
setMessage("当前角色无权维护知识库");
return;
}
setIsPending(true);
setMessage("");
const payload: KnowledgeEntryInput = {
...form,
};
const response = await fetch(isEditing ? `/api/ai/knowledge/${editingId}` : "/api/ai/knowledge", {
method: isEditing ? "PATCH" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
closeDialog();
setMessage(result.reason);
}
async function remove(): Promise<void> {
if (!deleteTarget || !canManage) {
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/ai/knowledge/${deleteTarget.id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
setDeleteTarget(null);
setMessage(result.reason);
}
async function toggleStatus(entry: KnowledgeEntry): Promise<void> {
const nextStatus: AiKnowledgeStatus = entry.status === "enabled" ? "disabled" : "enabled";
const response = await fetch(`/api/ai/knowledge/${entry.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...entry, status: nextStatus }),
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<Button disabled={!canManage} onClick={beginCreate} type="button">
<Plus className="size-4" aria-hidden="true" />
</Button>
</section>
<Card>
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription> {filteredEntries.length} </CardDescription>
</div>
<label className="relative block w-full md:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
</label>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[860px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredEntries.map((entry) => (
<Table.Row key={entry.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{entry.title}</p>
<p className="max-w-md truncate text-xs text-muted-foreground">{entry.body}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3">{entry.scope === "platform" ? "平台共享" : "机构私有"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.category || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.tags || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={entry.status === "enabled" ? "success" : "secondary"}>
{entry.status === "enabled" ? "启用" : "停用"}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button disabled={!canManage} onClick={() => void toggleStatus(entry)} size="sm" type="button" variant="outline">
{entry.status === "enabled" ? "停用" : "启用"}
</Button>
<Button disabled={!canManage} onClick={() => beginEdit(entry)} size="sm" type="button" variant="outline">
<Edit3 className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canManage} onClick={() => setDeleteTarget(entry)} size="sm" type="button" variant="destructive">
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{filteredEntries.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
<Dialog
description="保存时会写入数据库、重建分块和 embedding。"
onClose={closeDialog}
open={isDialogOpen}
title={isEditing ? "编辑知识" : "新增知识"}
width="lg"
>
<form className="grid gap-3" onSubmit={submit}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="知识范围"
onValueChange={(value) => updateField("scope", value as AiKnowledgeScope)}
options={scopeOptions}
value={form.scope}
/>
</div>
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="知识状态"
onValueChange={(value) => updateField("status", value as AiKnowledgeStatus)}
options={statusOptions}
value={form.status}
/>
</div>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("title", event.target.value)} required value={form.title} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("category", event.target.value)} value={form.category} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("tags", event.target.value)} placeholder="逗号分隔" value={form.tags} />
</label>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Textarea className="min-h-56" onChange={(event) => updateField("body", event.target.value)} required value={form.body} />
</label>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canManage} type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
</Dialog>
<Dialog
description={deleteTarget ? `确认删除「${deleteTarget.title}」?对应分块也会删除。` : undefined}
onClose={() => setDeleteTarget(null)}
open={deleteTarget !== null}
title="删除确认"
width="sm"
>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canManage} onClick={() => void remove()} type="button" variant="destructive">
</Button>
</div>
</Dialog>
</div>
);
}