diff --git a/frontend-web/src/components/workspace/knowledge-save-trigger.tsx b/frontend-web/src/components/workspace/knowledge-save-trigger.tsx new file mode 100644 index 00000000..b3ea6f48 --- /dev/null +++ b/frontend-web/src/components/workspace/knowledge-save-trigger.tsx @@ -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 ( + <> + + + + + + + + 保存到知识库 + + 将本次对话沉淀为 obsidian-wiki 兼容的知识笔记。 + + +
+ setTitle(e.target.value)} + /> + setTags(e.target.value)} + /> +
+ + +
+ +
+ + + + +
+
+ + ); +} diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts new file mode 100644 index 00000000..7dfe9d20 --- /dev/null +++ b/frontend-web/src/core/knowledge/api.ts @@ -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(response: Response): Promise { + 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; +} + +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( + await apiFetch(`${baseUrl()}/notes?${qs.toString()}`), + ); +} + +export async function getKnowledgeNote(noteId: string) { + return parseResponse( + 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( + 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( + await apiFetch(`${baseUrl()}/ingest/threads/batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + ); +} + +export async function exportKnowledgeGraph() { + return parseResponse( + 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)}`; +} diff --git a/frontend-web/src/core/knowledge/hooks.ts b/frontend-web/src/core/knowledge/hooks.ts new file mode 100644 index 00000000..5e304a60 --- /dev/null +++ b/frontend-web/src/core/knowledge/hooks.ts @@ -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[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[1]; + }) => ingestThread(threadId, payload), + onSuccess: invalidate, + }), + batchIngest: useMutation({ + mutationFn: ingestThreadsBatch, + onSuccess: invalidate, + }), + exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }), + }; +} diff --git a/frontend-web/src/core/knowledge/types.ts b/frontend-web/src/core/knowledge/types.ts new file mode 100644 index 00000000..d8cf9ed8 --- /dev/null +++ b/frontend-web/src/core/knowledge/types.ts @@ -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 }; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 0f1782c2..1b358936 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -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: "知识库资源管理", diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index c0c1290c..8199283c 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -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} /> + diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx new file mode 100644 index 00000000..aa7c24a3 --- /dev/null +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -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 = { + thread: "对话", + search: "检索", + tool: "工具", + file: "文件", + manual: "手动", + report: "报告", +}; + +const STATUS_LABELS: Record = { + draft: "草稿", + approved: "已批准", + archived: "已归档", +}; + +function useDebounced(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 ( + + ); +} + +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 ( + + + + 新建知识 + 手动创建一条知识笔记,写入共享知识库 vault。 + +
+ setTitle(e.target.value)} + /> + setTags(e.target.value)} + /> +