feat: 产品内知识库(obsidian-wiki 兼容 vault)
后端:新增自包含 deerflow.knowledge 模块(models/repository/schemas/ extractor/sources/markdown_vault/manifest/obsidian_adapter/exporter/ search/service),通过 deps.py 装配为 app.state.knowledge_service, 挂载 /api/knowledge 路由;config.yaml 增加 knowledge 配置段, AppConfig 注册 KnowledgeConfig,ORM 注册 4 张 knowledge_* 表。 前端:新增知识库页面(KnowledgeBasePage/KnowledgeGraphPage)、 core/knowledge api/hooks/types、聊天侧 knowledge-save-trigger, 侧边栏增加「知识库」入口。 知识库为全局共享,不做 user_id 隔离,写入记录 created_by/updated_by 作为审计。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
80d2db6315
commit
cc99ebba81
28 files changed
+3590
-6
No files matched your search
@@ -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)}`;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user