feat(knowledge): 可跳转 wikilink + 真实相关 note + 一对话多页
- [[wikilink]] 解析器:note id / vault_path / 标题 / vault 元页 / missing
- 前端 MarkdownRenderer 把非数字 [[...]] 渲染成可点链接,元页弹窗查看,缺失置灰
- vault 脚手架补建项目 hub 页,三个结构性链接(index/taxonomy/hub)始终可跳
- capture 时按标题+摘要+区分性标签检索同主题 note,注入真实 [[标题]] 到 Related + 记 related 图谱边
- LLM 提取支持 notes[] 数组,一次对话拆 1~4 页并按标题互链(multi_note_enabled 开关)
- 新增接口 POST /resolve、GET /notes/{id}/wikilinks、GET /vault-page
- 新增配置 related_notes_enabled / related_notes_limit / multi_note_enabled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
92a46003f8
commit
df3fac1032
13 files changed
+668
-72
No files matched your search
@@ -10,6 +10,8 @@ import type {
|
||||
KnowledgeListResponse,
|
||||
KnowledgeNote,
|
||||
KnowledgeSearchItem,
|
||||
VaultPage,
|
||||
WikilinkResolutionMap,
|
||||
} from "./types";
|
||||
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/knowledge`;
|
||||
@@ -125,6 +127,35 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
|
||||
export async function resolveWikilinks(targets: string[]) {
|
||||
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
|
||||
await apiFetch(`${baseUrl()}/resolve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targets }),
|
||||
}),
|
||||
);
|
||||
return res.resolved;
|
||||
}
|
||||
|
||||
/** Resolve every `[[wikilink]]` inside a note's body in one call. */
|
||||
export async function resolveNoteWikilinks(noteId: string) {
|
||||
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
|
||||
await apiFetch(
|
||||
`${baseUrl()}/notes/${encodeURIComponent(noteId)}/wikilinks`,
|
||||
),
|
||||
);
|
||||
return res.resolved;
|
||||
}
|
||||
|
||||
/** Fetch a raw vault meta/hub Markdown page (no DB note) by its path. */
|
||||
export async function fetchVaultPage(path: string) {
|
||||
return parseResponse<VaultPage>(
|
||||
await apiFetch(`${baseUrl()}/vault-page?path=${encodeURIComponent(path)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportKnowledgeGraph() {
|
||||
return parseResponse<GraphExportResult>(
|
||||
await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }),
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
createKnowledgeNote,
|
||||
deleteKnowledgeNote,
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
getKnowledgeNote,
|
||||
ingestThread,
|
||||
ingestThreadsBatch,
|
||||
listDuplicates,
|
||||
listKnowledgeNotes,
|
||||
mergeKnowledgeNotes,
|
||||
resolveNoteWikilinks,
|
||||
searchKnowledge,
|
||||
updateKnowledgeNote,
|
||||
} from "./api";
|
||||
@@ -40,6 +42,24 @@ export function useKnowledgeNote(noteId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useNoteWikilinks(noteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "wikilinks", noteId],
|
||||
queryFn: () => resolveNoteWikilinks(noteId as string),
|
||||
enabled: !!noteId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVaultPage(path: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "vault-page", path],
|
||||
queryFn: () => fetchVaultPage(path as string),
|
||||
enabled: !!path,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeSearch(query: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "search", query],
|
||||
|
||||
@@ -52,6 +52,27 @@ export interface KnowledgeListParams {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export type WikilinkKind = "note" | "vault" | "missing";
|
||||
|
||||
export interface WikilinkResolution {
|
||||
target: string;
|
||||
normalized?: string;
|
||||
kind: WikilinkKind;
|
||||
exists: boolean;
|
||||
note_id?: string;
|
||||
vault_path?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Map of original `[[target]]` string → its resolution. */
|
||||
export type WikilinkResolutionMap = Record<string, WikilinkResolution>;
|
||||
|
||||
export interface VaultPage {
|
||||
path: string;
|
||||
title: string;
|
||||
content_md: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchItem {
|
||||
note_id: string;
|
||||
chunk_id?: string | null;
|
||||
|
||||
@@ -41,10 +41,14 @@ import {
|
||||
useKnowledgeDuplicates,
|
||||
useKnowledgeNote,
|
||||
useKnowledgeNotes,
|
||||
useNoteWikilinks,
|
||||
useVaultPage,
|
||||
} from "@/core/knowledge/hooks";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
|
||||
import MarkdownRenderer, {
|
||||
type WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
thread: "对话",
|
||||
@@ -349,10 +353,56 @@ function DedupDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function VaultPageDialog({
|
||||
path,
|
||||
onOpenChange,
|
||||
}: {
|
||||
path: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { data: page, isLoading } = useVaultPage(path ?? undefined);
|
||||
return (
|
||||
<Dialog open={!!path} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{page?.title || "知识库页面"}</DialogTitle>
|
||||
<DialogDescription>{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="p-1">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground p-4 text-sm">加载中...</div>
|
||||
) : page ? (
|
||||
<MarkdownRenderer content={page.content_md} />
|
||||
) : (
|
||||
<div className="text-muted-foreground p-4 text-sm">页面不存在</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteDetail({ noteId }: { noteId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
const { update, remove } = useKnowledgeActions();
|
||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||
|
||||
const handleWikiLink = (link: WikiLinkTarget) => {
|
||||
if (link.kind === "note" && link.note_id) {
|
||||
navigate(`/page/workspace/knowledge/${link.note_id}`);
|
||||
} else if (link.kind === "vault" && link.vault_path) {
|
||||
setVaultPath(link.vault_path);
|
||||
}
|
||||
};
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
@@ -361,6 +411,7 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setVaultPath(null);
|
||||
}, [noteId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -495,7 +546,11 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<MarkdownRenderer content={note.content_md} />
|
||||
<MarkdownRenderer
|
||||
content={note.content_md}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={handleWikiLink}
|
||||
/>
|
||||
|
||||
{note.sources && note.sources.length > 0 ? (
|
||||
<div className="mt-6 border-t pt-4">
|
||||
@@ -545,6 +600,12 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<VaultPageDialog
|
||||
path={vaultPath}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVaultPath(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,33 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
export interface WikiLinkTarget {
|
||||
target: string;
|
||||
kind: 'note' | 'vault' | 'missing';
|
||||
exists: boolean;
|
||||
note_id?: string;
|
||||
vault_path?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type WikiLinkMap = Record<string, WikiLinkTarget>;
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
onCitationClick?: (index: number) => void;
|
||||
/** Resolved `[[wikilink]]` targets; when provided, non-numeric `[[...]]` render as links. */
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onWikiLinkClick?: (link: WikiLinkTarget) => void;
|
||||
}
|
||||
|
||||
const escapeHtmlAttr = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('css', css);
|
||||
SyntaxHighlighter.registerLanguage('html', markup);
|
||||
@@ -126,8 +147,35 @@ const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = (
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
|
||||
const contentWithCitationBadge = content.replace(
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
className = '',
|
||||
onCitationClick,
|
||||
wikiLinks,
|
||||
onWikiLinkClick,
|
||||
}) => {
|
||||
// 1) Non-numeric [[wikilinks]] → clickable (or greyed-out) links, driven by the
|
||||
// resolver map. Numeric [[123]] are left untouched here for the citation pass.
|
||||
let processed = content;
|
||||
if (wikiLinks) {
|
||||
processed = processed.replace(
|
||||
/\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]/g,
|
||||
(match, rawTarget: string, alias?: string) => {
|
||||
const target = String(rawTarget).trim();
|
||||
if (/^\d+$/.test(target)) return match; // citation badge, handled below
|
||||
const resolved = wikiLinks[target];
|
||||
const label = (alias?.trim() || resolved?.title || target);
|
||||
const safeLabel = escapeHtmlAttr(label);
|
||||
const safeTarget = escapeHtmlAttr(target);
|
||||
if (resolved && resolved.exists) {
|
||||
return `<button type="button" data-wikilink-target="${safeTarget}" class="inline items-center font-medium text-blue-600 dark:text-blue-400 hover:underline cursor-pointer align-baseline">${safeLabel}</button>`;
|
||||
}
|
||||
return `<span data-wikilink-missing="${safeTarget}" title="页面不存在" class="text-muted-foreground underline decoration-dashed decoration-from-font cursor-not-allowed">${safeLabel}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
// 2) Numeric [[123]] citation badges.
|
||||
const contentWithCitationBadge = processed.replace(
|
||||
/\[\[(\d+)\]\]/g,
|
||||
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
|
||||
);
|
||||
@@ -136,8 +184,17 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className
|
||||
<div
|
||||
className={`markdown-content ${className}`}
|
||||
onClick={(event) => {
|
||||
if (!onCitationClick) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const wikiButton = target.closest('[data-wikilink-target]') as HTMLElement | null;
|
||||
if (wikiButton && onWikiLinkClick && wikiLinks) {
|
||||
const key = wikiButton.dataset.wikilinkTarget || '';
|
||||
const link = wikiLinks[key];
|
||||
if (link) {
|
||||
onWikiLinkClick(link);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!onCitationClick) return;
|
||||
const button = target.closest('[data-citation-index]') as HTMLElement | null;
|
||||
if (!button) return;
|
||||
const index = Number(button.dataset.citationIndex || '0');
|
||||
|
||||
Reference in new issue
Block a user