This commit is contained in:
吉鑫升 committed 2026-06-08 18:02:14 +08:00
commit fb692a0555
30 files changed
+3618 -6

No files matched your search

@@ -0,0 +1,158 @@
"use client";
import { BookMarked } from "lucide-react";
import { useState } from "react";
import { useNavigate } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip";
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
const { thread } = useThread();
const navigate = useNavigate();
const { ingestThread } = useKnowledgeActions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [status, setStatus] = useState<"approved" | "draft">("approved");
const [mode, setMode] = useState<"summary" | "full">("summary");
const [includeSources, setIncludeSources] = useState(true);
if (thread.messages.length === 0) {
return null;
}
const handleSave = async () => {
try {
const result = await ingestThread.mutateAsync({
threadId,
payload: {
title: title.trim() || null,
tags: tags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status,
mode,
include_sources: includeSources,
},
});
toast.success("已保存到知识库", {
action: {
label: "查看",
onClick: () =>
navigate(`/page/workspace/knowledge/${result.note.id}`),
},
});
setOpen(false);
setTitle("");
setTags("");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<>
<Tooltip content="保存到知识库">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => setOpen(true)}
>
<BookMarked />
保存
</Button>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>保存到知识库</DialogTitle>
<DialogDescription>
将本次对话沉淀为 obsidian-wiki 兼容的知识笔记。
</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)}
/>
<div className="flex items-center gap-2">
<Select
value={status}
onValueChange={(v) => setStatus(v as "approved" | "draft")}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">正式</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
</SelectContent>
</Select>
<Select
value={mode}
onValueChange={(v) => setMode(v as "summary" | "full")}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="summary">摘要</SelectItem>
<SelectItem value="full">完整</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSources}
onChange={(e) => setIncludeSources(e.target.checked)}
className="size-4"
/>
包含搜索 / 工具来源
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
{ingestThread.isPending ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
BatchIngestPayload,
BatchIngestResult,
GraphExportResult,
IngestThreadPayload,
KnowledgeListParams,
KnowledgeListResponse,
KnowledgeNote,
KnowledgeSearchItem,
} from "./types";
const baseUrl = () => `${getBackendBaseURL()}/api/knowledge`;
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Request failed" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("请求失败");
}
return response.json() as Promise<T>;
}
export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
const qs = new URLSearchParams();
if (params.keyword) qs.set("keyword", params.keyword);
if (params.tag) qs.set("tag", params.tag);
if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status);
qs.set("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>(
await apiFetch(`${baseUrl()}/notes?${qs.toString()}`),
);
}
export async function getKnowledgeNote(noteId: string) {
return parseResponse<KnowledgeNote>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`),
);
}
export async function createKnowledgeNote(data: {
title: string;
content_md: string;
summary?: string | null;
tags?: string[];
status?: string;
}) {
const res = await parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.note;
}
export async function updateKnowledgeNote(
noteId: string,
data: Partial<{
title: string;
content_md: string;
summary: string | null;
tags: string[];
status: string;
}>,
) {
return parseResponse<KnowledgeNote>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function deleteKnowledgeNote(noteId: string, hard = false) {
return parseResponse<{ ok: boolean; note_id: string }>(
await apiFetch(
`${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
{ method: "DELETE" },
),
);
}
export async function searchKnowledge(query: string, limit = 8) {
const res = await parseResponse<{ items: KnowledgeSearchItem[] }>(
await apiFetch(`${baseUrl()}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, mode: "keyword", limit }),
}),
);
return res.items;
}
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
return parseResponse<{ note: KnowledgeNote; created: boolean }>(
await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
return parseResponse<BatchIngestResult>(
await apiFetch(`${baseUrl()}/ingest/threads/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function exportKnowledgeGraph() {
return parseResponse<GraphExportResult>(
await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }),
);
}
/** Absolute URL of a served wiki-export file (for iframe / download). */
export function knowledgeExportFileUrl(filename: string) {
return `${baseUrl()}/export/files/${encodeURIComponent(filename)}`;
}
+89
View File
@@ -0,0 +1,89 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createKnowledgeNote,
deleteKnowledgeNote,
exportKnowledgeGraph,
getKnowledgeNote,
ingestThread,
ingestThreadsBatch,
listKnowledgeNotes,
searchKnowledge,
updateKnowledgeNote,
} from "./api";
import type { KnowledgeListParams } from "./types";
const ROOT_KEY = ["knowledge"] as const;
export function useKnowledgeNotes(params: KnowledgeListParams = {}) {
return useQuery({
queryKey: [...ROOT_KEY, "list", params],
queryFn: () => listKnowledgeNotes(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeNote(noteId: string | undefined) {
return useQuery({
queryKey: [...ROOT_KEY, "note", noteId],
queryFn: () => getKnowledgeNote(noteId as string),
enabled: !!noteId,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeSearch(query: string, enabled: boolean) {
return useQuery({
queryKey: [...ROOT_KEY, "search", query],
queryFn: () => searchKnowledge(query),
enabled: enabled && query.trim().length > 0,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ROOT_KEY });
return {
create: useMutation({ mutationFn: createKnowledgeNote, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({
noteId,
data,
}: {
noteId: string;
data: Parameters<typeof updateKnowledgeNote>[1];
}) => updateKnowledgeNote(noteId, data),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: ({ noteId, hard }: { noteId: string; hard?: boolean }) =>
deleteKnowledgeNote(noteId, hard),
onSuccess: invalidate,
}),
ingestThread: useMutation({
mutationFn: ({
threadId,
payload,
}: {
threadId: string;
payload?: Parameters<typeof ingestThread>[1];
}) => ingestThread(threadId, payload),
onSuccess: invalidate,
}),
batchIngest: useMutation({
mutationFn: ingestThreadsBatch,
onSuccess: invalidate,
}),
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
};
}
+102
View File
@@ -0,0 +1,102 @@
export type KnowledgeStatus = "draft" | "approved" | "archived";
export type KnowledgeSourceType =
| "thread"
| "search"
| "tool"
| "file"
| "manual"
| "report";
export interface KnowledgeSource {
id: string;
note_id: string;
source_type: string;
thread_id?: string | null;
message_id?: string | null;
tool_name?: string | null;
title?: string | null;
url?: string | null;
snippet?: string | null;
created_at?: string | null;
}
export interface KnowledgeNote {
id: string;
title: string;
summary?: string | null;
content_md: string;
vault_path?: string | null;
source_type: string;
source_id?: string | null;
status: KnowledgeStatus;
confidence?: number;
created_by?: string | null;
updated_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
tags: string[];
sources?: KnowledgeSource[];
}
export interface KnowledgeListResponse {
items: KnowledgeNote[];
total: number;
}
export interface KnowledgeListParams {
keyword?: string;
tag?: string;
source_type?: string;
status?: string;
limit?: number;
offset?: number;
}
export interface KnowledgeSearchItem {
note_id: string;
chunk_id?: string | null;
title?: string | null;
summary?: string | null;
snippet?: string | null;
score: number;
tags: string[];
source_type?: string | null;
updated_at?: string | null;
}
export interface IngestThreadPayload {
mode?: "summary" | "full";
title?: string | null;
status?: KnowledgeStatus;
tags?: string[];
include_sources?: boolean;
}
export interface BatchIngestPayload {
limit?: number;
offset?: number;
status?: KnowledgeStatus;
include_sources?: boolean;
skip_existing?: boolean;
mode?: "summary" | "full";
}
export interface BatchIngestResult {
ok: boolean;
processed: number;
created: number;
skipped: number;
failed: number;
items: Array<{ thread_id: string; note_id: string | null; status: string }>;
}
export interface GraphExportResult {
ok: boolean;
files: {
graph_json: string;
graph_html: string;
graph_graphml: string;
cypher: string;
};
stats: { nodes: number; edges: number };
}
@@ -9,6 +9,7 @@
*/
import type { ElementType } from "react";
import {
BookOpen,
Bot,
Brain,
CalendarClock,
@@ -262,6 +263,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-native",
label: "知识库",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
+2
View File
@@ -15,6 +15,7 @@ import {
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@@ -409,6 +410,7 @@ export default function ChatPage() {
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
@@ -0,0 +1,597 @@
import {
BookOpen,
ExternalLink,
Loader2,
Network,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
useKnowledgeActions,
useKnowledgeNote,
useKnowledgeNotes,
} from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话",
search: "检索",
tool: "工具",
file: "文件",
manual: "手动",
report: "报告",
};
const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
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 NoteListItem({
note,
active,
onClick,
}: {
note: KnowledgeNote;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border/70 hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
</div>
{note.summary ? (
<span className="text-muted-foreground line-clamp-2 text-xs">
{note.summary}
</span>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1">
{note.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
>
#{tag}
</span>
))}
<span className="text-muted-foreground ml-auto text-[10px]">
{note.updated_at?.slice(0, 10)}
</span>
</div>
</button>
);
}
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);
const handleRun = async () => {
try {
const result = await batchIngest.mutateAsync({
limit,
skip_existing: skipExisting,
status: "approved",
include_sources: true,
});
toast.success(
`批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`,
);
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>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleRun()} disabled={batchIngest.isPending}>
{batchIngest.isPending ? "导入中..." : "开始导入"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NoteDetail({ noteId }: { noteId: string }) {
const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId);
const { update, remove } = useKnowledgeActions();
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftTags, setDraftTags] = useState("");
const [draftContent, setDraftContent] = useState("");
const [draftStatus, setDraftStatus] = useState("approved");
useEffect(() => {
setEditing(false);
}, [noteId]);
useEffect(() => {
if (note) {
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
}
}, [note]);
if (isLoading || !note) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
{isLoading ? "加载中..." : "知识不存在"}
</div>
);
}
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2">
<Badge variant="outline">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
<X className="size-4" /> 取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
<Save className="size-4" /> 保存
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="size-4" /> 编辑
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => void handleArchive()}
disabled={remove.isPending}
>
<Trash2 className="size-4" /> 归档
</Button>
</>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={20}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-2 text-xl font-semibold">{note.title}</h1>
<div className="mb-3 flex flex-wrap gap-1">
{note.tags.map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs"
>
#{tag}
</span>
))}
</div>
<MarkdownRenderer content={note.content_md} />
{note.sources && note.sources.length > 0 ? (
<div className="mt-6 border-t pt-4">
<h2 className="mb-2 text-sm font-semibold">来源</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="text-primary inline-flex items-center gap-1 hover:underline"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span>{src.title || src.tool_name || "来源"}</span>
)}
{src.snippet ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
创建人 {note.created_by ?? "—"} · 更新人 {note.updated_by ?? "—"} · 更新于{" "}
{note.updated_at?.slice(0, 19).replace("T", " ")}
</div>
</>
)}
</div>
</ScrollArea>
</div>
);
}
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 50,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex flex-wrap items-center gap-2 border-b p-3">
<BookOpen className="size-5" />
<h1 className="mr-2 text-base font-semibold">知识库</h1>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 摘要 / 正文 / 标签"
className="w-64 pl-8"
/>
</div>
<Select value={sourceType} onValueChange={setSourceType}>
<SelectTrigger className="w-28">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
<SelectItem key={v} value={v}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-28">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/knowledge/graph")}
>
<Network className="size-4" /> 图谱
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> 新建
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r">
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-2 p-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : notes.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无知识。点击「新建」或在对话页「保存到知识库」。
</div>
) : (
notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
active={note.id === noteId}
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
/>
))
)}
</div>
</ScrollArea>
{data ? (
<div className="text-muted-foreground border-t p-2 text-center text-xs">
共 {data.total} 条知识
</div>
) : null}
</div>
<div className="min-h-0">
{noteId ? (
<NoteDetail noteId={noteId} />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
从左侧选择一条知识查看详情
</div>
)}
</div>
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
</div>
);
}
@@ -0,0 +1,79 @@
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { exportGraph } = useKnowledgeActions();
const [src, setSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const regenerate = useCallback(async () => {
try {
const result = await exportGraph.mutateAsync();
setStats(result.stats);
// Cache-bust the iframe so it reflects the freshly generated graph.
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成图谱失败");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void regenerate();
}, [regenerate]);
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b p-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/page/workspace/knowledge")}
>
<ArrowLeft className="size-4" /> 返回
</Button>
<h1 className="text-base font-semibold">知识图谱</h1>
{stats ? (
<span className="text-muted-foreground text-xs">
{stats.nodes} 个节点 · {stats.edges} 条关系
</span>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => void regenerate()}
disabled={exportGraph.isPending}
>
{exportGraph.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
重新生成
</Button>
</header>
<div className="min-h-0 flex-1">
{src ? (
<iframe
src={src}
title="知识图谱"
className="size-full border-0"
sandbox="allow-scripts allow-same-origin"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
</div>
)}
</div>
</div>
);
}
@@ -24,6 +24,8 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import KnowledgeBasePage from './KnowledgeBasePage';
import KnowledgeGraphPage from './KnowledgeGraphPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
@@ -335,6 +337,30 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/graph"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeGraphPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}