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:
AdministratorandClaude Opus 4.8 committed 2026-06-08 17:38:28 +08:00
1 parent 80d2db6315
commit cc99ebba81
28 files changed
+3590 -6

No files matched your search

+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 };
}