mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-18 21:57:27 +08:00
Feat: Enable the wiki's Markdown editor to navigate to a new Markdown file when a link is clicked. (#17019)
This commit is contained in:
@@ -7,12 +7,14 @@ interface MarkdownEditorProps {
|
||||
content: string;
|
||||
onChange?: (content: string) => void;
|
||||
readOnly?: boolean;
|
||||
onWikiLinkClick?: (pageType: 'concept' | 'entity', slug: string) => void;
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
onWikiLinkClick,
|
||||
}: MarkdownEditorProps) {
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [rawContent, setRawContent] = useState(content);
|
||||
@@ -61,6 +63,7 @@ export default function MarkdownEditor({
|
||||
placeholder={readOnly ? '' : 'Start writing...'}
|
||||
onToggleSource={toggleSource}
|
||||
showSource={showSource}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -591,15 +591,17 @@ export const useFetchArtifactTopicList = (
|
||||
};
|
||||
};
|
||||
|
||||
export function useFetchArtifactPage(artifact: IArtifact | null) {
|
||||
export function useFetchArtifactPage(
|
||||
artifact: IArtifact | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const knowledgeBaseId = useKnowledgeBaseId();
|
||||
const pageType = artifact?.page_type ?? '';
|
||||
const slug = artifact?.slug ?? '';
|
||||
|
||||
const { data, isFetching: loading } = useQuery<IArtifactPage | null>({
|
||||
queryKey: ArtifactKeys.detail(knowledgeBaseId, pageType, slug),
|
||||
enabled: !!knowledgeBaseId && !!artifact && !!pageType && !!slug,
|
||||
gcTime: 0,
|
||||
enabled: !!knowledgeBaseId && !!artifact && !!pageType && !!slug && enabled,
|
||||
queryFn: async () => {
|
||||
const { data } = await getArtifactPage(knowledgeBaseId, pageType, slug);
|
||||
return data?.data ?? null;
|
||||
|
||||
@@ -40,6 +40,7 @@ interface Props {
|
||||
placeholder?: string;
|
||||
onToggleSource?: () => void;
|
||||
showSource?: boolean;
|
||||
onWikiLinkClick?: (pageType: 'concept' | 'entity', slug: string) => void;
|
||||
}
|
||||
|
||||
function InitialContentPlugin({
|
||||
@@ -133,6 +134,7 @@ function ChangeListener({
|
||||
import FloatingSelectionToolbar from './plugins/floating-selection-toolbar';
|
||||
import TableActionsPlugin from './plugins/table-actions-plugin';
|
||||
import ToolbarPlugin from './plugins/toolbar-plugin';
|
||||
import { WikiLinkClickPlugin } from './plugins/wiki-link-click-plugin';
|
||||
|
||||
export default function LexicalEditor({
|
||||
content,
|
||||
@@ -141,6 +143,7 @@ export default function LexicalEditor({
|
||||
placeholder = 'Start writing...',
|
||||
onToggleSource,
|
||||
showSource = false,
|
||||
onWikiLinkClick,
|
||||
}: Props): JSX.Element {
|
||||
const transformers = useMemo(() => CORE_TRANSFORMERS, []);
|
||||
|
||||
@@ -187,6 +190,7 @@ export default function LexicalEditor({
|
||||
<MarkdownShortcutPlugin transformers={transformers} />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<WikiLinkClickPlugin onWikiLinkClick={onWikiLinkClick} />
|
||||
<HashtagPlugin />
|
||||
<TablePlugin />
|
||||
<TableActionsPlugin />
|
||||
|
||||
50
web/src/lib/editor/plugins/wiki-link-click-plugin.tsx
Normal file
50
web/src/lib/editor/plugins/wiki-link-click-plugin.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
parseWikiLinkHref,
|
||||
type WikiPageType,
|
||||
} from '@/pages/dataset/compilation/utils/parse-wiki-link';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type WikiLinkClickPluginProps = {
|
||||
onWikiLinkClick?: (pageType: WikiPageType, slug: string) => void;
|
||||
};
|
||||
|
||||
function findAnchorElement(
|
||||
target: EventTarget | null,
|
||||
): HTMLAnchorElement | null {
|
||||
if (!(target instanceof HTMLElement)) return null;
|
||||
return target.closest('a[href]') as HTMLAnchorElement | null;
|
||||
}
|
||||
|
||||
export function WikiLinkClickPlugin({
|
||||
onWikiLinkClick,
|
||||
}: WikiLinkClickPluginProps) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!onWikiLinkClick) return;
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
if (!rootElement) return;
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const anchor = findAnchorElement(event.target);
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href) return;
|
||||
|
||||
const parsed = parseWikiLinkHref(href);
|
||||
if (!parsed) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onWikiLinkClick(parsed.pageType, parsed.slug);
|
||||
};
|
||||
|
||||
rootElement.addEventListener('click', handleClick);
|
||||
return () => rootElement.removeEventListener('click', handleClick);
|
||||
}, [editor, onWikiLinkClick]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useFetchDocumentsByIds } from '@/hooks/use-document-request';
|
||||
import {
|
||||
useFetchArtifactPage,
|
||||
useFetchWikiCommit,
|
||||
} from '@/hooks/use-knowledge-request';
|
||||
import { Docagg } from '@/interfaces/database/chat';
|
||||
import { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
|
||||
import { downloadMarkdownFile } from '@/utils/file-util';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import type { WikiPageType } from '../utils/parse-wiki-link';
|
||||
import { useCommitArtifact } from './use-commit-artifact';
|
||||
import { useWikiEditor } from './use-wiki-editor';
|
||||
import { useWikiLinkNavigation } from './use-wiki-link-navigation';
|
||||
|
||||
type UseWikiDetailContentOptions = {
|
||||
selectedArtifact: IArtifact | null;
|
||||
selectedVersion: IWikiCommit | null;
|
||||
onSelectVersion: (version: IWikiCommit | null) => void;
|
||||
onSelectArtifact: (artifact: IArtifact) => void;
|
||||
};
|
||||
|
||||
export function useWikiDetailContent({
|
||||
selectedArtifact,
|
||||
selectedVersion,
|
||||
onSelectVersion,
|
||||
onSelectArtifact,
|
||||
}: UseWikiDetailContentOptions) {
|
||||
const isVersionView = !!selectedVersion;
|
||||
|
||||
const { data: pageData, loading: pageLoading } = useFetchArtifactPage(
|
||||
isVersionView ? null : selectedArtifact,
|
||||
);
|
||||
const { data: commitDetail, loading: commitLoading } = useFetchWikiCommit(
|
||||
selectedVersion?.id ?? null,
|
||||
);
|
||||
|
||||
const {
|
||||
currentEntry,
|
||||
previousEntry,
|
||||
canGoBack,
|
||||
push,
|
||||
goBack,
|
||||
reset,
|
||||
updateCurrentTitle,
|
||||
} = useWikiLinkNavigation();
|
||||
|
||||
// Keep the current stack entry's title in sync with pageData.
|
||||
useEffect(() => {
|
||||
if (!currentEntry || !pageData) return;
|
||||
if (currentEntry.slug !== pageData.slug) return;
|
||||
updateCurrentTitle(pageData.title);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentEntry?.slug, pageData?.slug, pageData?.title, updateCurrentTitle]);
|
||||
|
||||
// When selectedArtifact changes from the left panel (not from our own
|
||||
// wiki-link navigation), reset the stack. Wiki-link clicks call push()
|
||||
// before onSelectArtifact(), so currentEntry.slug already matches by the
|
||||
// time this effect runs and we bail out.
|
||||
useEffect(() => {
|
||||
if (isVersionView || !selectedArtifact) return;
|
||||
if (currentEntry?.slug === selectedArtifact.slug) return;
|
||||
|
||||
reset({
|
||||
slug: selectedArtifact.slug,
|
||||
title: selectedArtifact.title,
|
||||
pageType: selectedArtifact.page_type ?? '',
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isVersionView,
|
||||
selectedArtifact?.slug,
|
||||
selectedArtifact?.title,
|
||||
selectedArtifact?.page_type,
|
||||
currentEntry?.slug,
|
||||
reset,
|
||||
]);
|
||||
|
||||
const content = isVersionView
|
||||
? (commitDetail?.content_after ?? '')
|
||||
: (pageData?.content_md_rendered ?? '');
|
||||
|
||||
const title =
|
||||
currentEntry?.title ||
|
||||
pageData?.title ||
|
||||
selectedArtifact?.title ||
|
||||
currentEntry?.slug ||
|
||||
'';
|
||||
|
||||
const displayedArtifact = currentEntry
|
||||
? { slug: currentEntry.slug, title: currentEntry.title, page_type: currentEntry.pageType }
|
||||
: selectedArtifact;
|
||||
|
||||
const previousEntryTitle = previousEntry?.title || previousEntry?.slug;
|
||||
|
||||
const editorKey = isVersionView
|
||||
? `${selectedArtifact?.slug}@${selectedVersion?.id}`
|
||||
: (selectedArtifact?.slug ?? '');
|
||||
|
||||
const handleMarkdownLinkClick = useCallback(
|
||||
(pageType: WikiPageType, slug: string) => {
|
||||
if (isVersionView) return;
|
||||
if (currentEntry?.slug === slug && currentEntry?.pageType === pageType)
|
||||
return;
|
||||
|
||||
// Sync the current entry's title from pageData before pushing a new
|
||||
// entry, so the title is preserved when this entry becomes the
|
||||
// "previous" entry for the back button.
|
||||
if (
|
||||
currentEntry &&
|
||||
pageData &&
|
||||
pageData.slug === currentEntry.slug &&
|
||||
pageData.title
|
||||
) {
|
||||
updateCurrentTitle(pageData.title);
|
||||
}
|
||||
|
||||
push({ slug, title: '', pageType });
|
||||
onSelectArtifact({ slug, page_type: pageType, title: '' });
|
||||
},
|
||||
[
|
||||
push,
|
||||
onSelectArtifact,
|
||||
isVersionView,
|
||||
currentEntry,
|
||||
pageData,
|
||||
updateCurrentTitle,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (!previousEntry) return;
|
||||
goBack();
|
||||
onSelectArtifact({
|
||||
slug: previousEntry.slug,
|
||||
page_type: previousEntry.pageType,
|
||||
title: previousEntry.title,
|
||||
});
|
||||
}, [goBack, previousEntry, onSelectArtifact]);
|
||||
|
||||
const {
|
||||
editedContent,
|
||||
isDirty,
|
||||
handleContentChange,
|
||||
handleCancelEdit,
|
||||
handleMarkAsSaved,
|
||||
} = useWikiEditor({
|
||||
content,
|
||||
artifactSlug: editorKey,
|
||||
});
|
||||
|
||||
const handleCommitSuccess = useCallback(() => {
|
||||
handleMarkAsSaved();
|
||||
if (isVersionView) {
|
||||
onSelectVersion(null);
|
||||
}
|
||||
}, [handleMarkAsSaved, isVersionView, onSelectVersion]);
|
||||
|
||||
const { isOpen, open, close, form, handleConfirm, isUpdating } =
|
||||
useCommitArtifact({
|
||||
editedContent,
|
||||
pageType: currentEntry?.pageType ?? selectedArtifact?.page_type ?? '',
|
||||
slug: currentEntry?.slug ?? selectedArtifact?.slug ?? '',
|
||||
onSuccess: handleCommitSuccess,
|
||||
});
|
||||
|
||||
const { documents } = useFetchDocumentsByIds(
|
||||
pageData?.source_doc_ids ?? [],
|
||||
);
|
||||
|
||||
const referenceDocuments = useMemo<Docagg[]>(() => {
|
||||
return documents.map(
|
||||
(doc): Docagg => ({
|
||||
doc_id: doc.id,
|
||||
doc_name: doc.name,
|
||||
count: 0,
|
||||
}),
|
||||
);
|
||||
}, [documents]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const filename = `${title ?? 'document'}.md`;
|
||||
downloadMarkdownFile(editedContent, filename);
|
||||
}, [title, editedContent]);
|
||||
|
||||
const loading = isVersionView ? commitLoading : pageLoading;
|
||||
|
||||
return {
|
||||
isVersionView,
|
||||
title,
|
||||
displayedArtifact,
|
||||
displayedContent: content,
|
||||
displayedPageType: currentEntry?.pageType ?? pageData?.page_type ?? '',
|
||||
displayedSlug: currentEntry?.slug ?? pageData?.slug ?? '',
|
||||
selectedArtifact,
|
||||
selectedVersion,
|
||||
commitDetail,
|
||||
canGoBack,
|
||||
previousEntry,
|
||||
previousEntryTitle,
|
||||
linkNavLoading: false,
|
||||
loading,
|
||||
editedContent,
|
||||
isDirty,
|
||||
referenceDocuments,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
form,
|
||||
handleConfirm,
|
||||
isUpdating,
|
||||
handleCancelEdit,
|
||||
handleContentChange,
|
||||
handleMarkdownLinkClick,
|
||||
handleBack,
|
||||
handleExport,
|
||||
onSelectVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type NavEntry = {
|
||||
slug: string;
|
||||
title: string;
|
||||
pageType: string;
|
||||
};
|
||||
|
||||
interface NavState {
|
||||
stack: NavEntry[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
export function useWikiLinkNavigation() {
|
||||
const [state, setState] = useState<NavState>({
|
||||
stack: [],
|
||||
currentIndex: -1,
|
||||
});
|
||||
|
||||
const push = useCallback((entry: NavEntry) => {
|
||||
setState((prev) => ({
|
||||
stack: [...prev.stack.slice(0, prev.currentIndex + 1), entry],
|
||||
currentIndex: prev.currentIndex + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentIndex: Math.max(0, prev.currentIndex - 1),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((entry: NavEntry) => {
|
||||
setState({ stack: [entry], currentIndex: 0 });
|
||||
}, []);
|
||||
|
||||
const updateCurrentTitle = useCallback((title: string) => {
|
||||
setState((prev) => {
|
||||
if (prev.currentIndex < 0) return prev;
|
||||
const entry = prev.stack[prev.currentIndex];
|
||||
if (!entry || entry.title === title) return prev;
|
||||
const newStack = [...prev.stack];
|
||||
newStack[prev.currentIndex] = { ...entry, title };
|
||||
return { ...prev, stack: newStack };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentEntry =
|
||||
state.currentIndex >= 0 ? state.stack[state.currentIndex] : null;
|
||||
const previousEntry =
|
||||
state.currentIndex > 0 ? state.stack[state.currentIndex - 1] : null;
|
||||
const canGoBack = state.currentIndex > 0;
|
||||
|
||||
return {
|
||||
currentEntry,
|
||||
previousEntry,
|
||||
canGoBack,
|
||||
push,
|
||||
goBack,
|
||||
reset,
|
||||
updateCurrentTitle,
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,7 @@ export default function Compilation() {
|
||||
selectedArtifact={selectedArtifact}
|
||||
selectedVersion={selectedVersion}
|
||||
onSelectVersion={selectVersion}
|
||||
onSelectArtifact={handleSelectArtifact}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
50
web/src/pages/dataset/compilation/utils/parse-wiki-link.ts
Normal file
50
web/src/pages/dataset/compilation/utils/parse-wiki-link.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type WikiPageType = 'concept' | 'entity';
|
||||
|
||||
/**
|
||||
* Parse an internal wiki link href into pageType and slug.
|
||||
*
|
||||
* Supports the common rendered forms:
|
||||
* artifact/{datasetId}/{pageType}/{slug}
|
||||
* /artifact/{datasetId}/{pageType}/{slug}
|
||||
* {pageType}/{slug}
|
||||
* /{pageType}/{slug}
|
||||
*
|
||||
* Only entity/ and concept/ links are considered wiki navigation links.
|
||||
*/
|
||||
export function parseWikiLinkHref(
|
||||
href: string,
|
||||
): { pageType: WikiPageType; slug: string } | null {
|
||||
const normalized = href.trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
// Prefer the artifact/{datasetId}/{pageType}/{slug} form.
|
||||
const artifactMatch = normalized.match(
|
||||
/(?:^|\/)artifact\/[^/]+\/(entity|concept)\/([^/\s"']+)/,
|
||||
);
|
||||
if (artifactMatch) {
|
||||
return {
|
||||
pageType: artifactMatch[1] as WikiPageType,
|
||||
slug: artifactMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to a plain {pageType}/{slug} form.
|
||||
const simpleMatch = normalized.match(
|
||||
/(?:^|\/)(entity|concept)\/([^/\s"']+)/,
|
||||
);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
pageType: simpleMatch[1] as WikiPageType,
|
||||
slug: simpleMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a link href looks like an internal wiki navigation link.
|
||||
*/
|
||||
export function isWikiLinkHref(href?: string): boolean {
|
||||
return Boolean(href && parseWikiLinkHref(href));
|
||||
}
|
||||
@@ -1,221 +1,97 @@
|
||||
import Empty from '@/components/empty/empty';
|
||||
import MarkdownEditor from '@/components/markdown-editor';
|
||||
import { ReferenceDocumentList } from '@/components/next-message-item/reference-document-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable';
|
||||
import { Spin } from '@/components/ui/spin';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useFetchDocumentsByIds } from '@/hooks/use-document-request';
|
||||
import {
|
||||
useFetchArtifactPage,
|
||||
useFetchWikiCommit,
|
||||
} from '@/hooks/use-knowledge-request';
|
||||
import { Docagg } from '@/interfaces/database/chat';
|
||||
import { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
|
||||
import { downloadMarkdownFile } from '@/utils/file-util';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCommitArtifact } from './hooks/use-commit-artifact';
|
||||
import { useWikiEditor } from './hooks/use-wiki-editor';
|
||||
import { VersionHistorySheet } from './version-history-sheet';
|
||||
|
||||
import Empty from '@/components/empty/empty';
|
||||
import { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
|
||||
|
||||
import { useWikiDetailContent } from './hooks/use-wiki-detail-content';
|
||||
import { WikiCommitModal } from './wiki-commit-modal';
|
||||
import { WikiVersionDiffPanel } from './wiki-version-diff-panel';
|
||||
import { WikiDetailEditorPanel } from './wiki-detail-editor-panel';
|
||||
import { WikiDetailHeader } from './wiki-detail-header';
|
||||
import { WikiDetailToolbar } from './wiki-detail-toolbar';
|
||||
|
||||
type WikiDetailContentProps = {
|
||||
selectedArtifact: IArtifact | null;
|
||||
selectedVersion: IWikiCommit | null;
|
||||
onSelectVersion: (version: IWikiCommit | null) => void;
|
||||
onSelectArtifact: (artifact: IArtifact) => void;
|
||||
};
|
||||
|
||||
export function WikiDetailContent({
|
||||
selectedArtifact,
|
||||
selectedVersion,
|
||||
onSelectVersion,
|
||||
onSelectArtifact,
|
||||
}: WikiDetailContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const isVersionView = !!selectedVersion;
|
||||
|
||||
const { data: pageData, loading: pageLoading } = useFetchArtifactPage(
|
||||
isVersionView ? null : selectedArtifact,
|
||||
);
|
||||
const { data: commitDetail, loading: commitLoading } = useFetchWikiCommit(
|
||||
selectedVersion?.id ?? null,
|
||||
);
|
||||
|
||||
const title = pageData?.title ?? selectedArtifact?.title;
|
||||
|
||||
const pageType = isVersionView
|
||||
? selectedArtifact?.page_type
|
||||
: pageData?.page_type;
|
||||
const slug = isVersionView ? selectedArtifact?.slug : pageData?.slug;
|
||||
const content = isVersionView
|
||||
? (commitDetail?.content_after ?? '')
|
||||
: (pageData?.content_md_rendered ?? '');
|
||||
const sourceDocIds = isVersionView ? [] : (pageData?.source_doc_ids ?? []);
|
||||
|
||||
const editorKey = isVersionView
|
||||
? `${selectedArtifact?.slug}@${selectedVersion?.id}`
|
||||
: selectedArtifact?.slug;
|
||||
|
||||
const {
|
||||
isVersionView,
|
||||
title,
|
||||
displayedArtifact,
|
||||
commitDetail,
|
||||
canGoBack,
|
||||
previousEntryTitle,
|
||||
linkNavLoading,
|
||||
loading,
|
||||
editedContent,
|
||||
displayedContent,
|
||||
referenceDocuments,
|
||||
isDirty,
|
||||
handleContentChange,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
form,
|
||||
handleConfirm,
|
||||
isUpdating,
|
||||
handleCancelEdit,
|
||||
handleMarkAsSaved,
|
||||
} = useWikiEditor({
|
||||
content,
|
||||
artifactSlug: editorKey,
|
||||
handleContentChange,
|
||||
handleMarkdownLinkClick,
|
||||
handleBack,
|
||||
handleExport,
|
||||
} = useWikiDetailContent({
|
||||
selectedArtifact,
|
||||
selectedVersion,
|
||||
onSelectVersion,
|
||||
onSelectArtifact,
|
||||
});
|
||||
|
||||
const handleCommitSuccess = useCallback(() => {
|
||||
handleMarkAsSaved();
|
||||
if (isVersionView) {
|
||||
onSelectVersion(null);
|
||||
}
|
||||
}, [handleMarkAsSaved, isVersionView, onSelectVersion]);
|
||||
|
||||
const { isOpen, open, close, form, handleConfirm, isUpdating } =
|
||||
useCommitArtifact({
|
||||
editedContent,
|
||||
pageType: pageType ?? '',
|
||||
slug: slug ?? '',
|
||||
onSuccess: handleCommitSuccess,
|
||||
});
|
||||
|
||||
const { documents } = useFetchDocumentsByIds(sourceDocIds);
|
||||
|
||||
const referenceDocuments = useMemo<Docagg[]>(() => {
|
||||
return documents.map(
|
||||
(doc): Docagg => ({
|
||||
doc_id: doc.id,
|
||||
doc_name: doc.name,
|
||||
count: 0,
|
||||
}),
|
||||
);
|
||||
}, [documents]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const filename = `${title ?? 'document'}.md`;
|
||||
downloadMarkdownFile(editedContent, filename);
|
||||
}, [title, editedContent]);
|
||||
|
||||
const renderToolbarButtons = () => {
|
||||
if (isDirty) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={open}>
|
||||
{t('knowledgeDetails.commit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('knowledgeDetails.export')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<VersionHistorySheet
|
||||
selectedArtifact={selectedArtifact}
|
||||
selectedVersion={selectedVersion}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const loading = isVersionView ? commitLoading : pageLoading;
|
||||
const toolbar = (
|
||||
<WikiDetailToolbar
|
||||
isDirty={isDirty}
|
||||
selectedArtifact={selectedArtifact}
|
||||
selectedVersion={selectedVersion}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCommitClick={open}
|
||||
onExport={handleExport}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="size-full min-w-0 flex flex-col">
|
||||
{selectedArtifact ? (
|
||||
<>
|
||||
<header className="shrink-0 px-8 pt-8 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-semibold text-text-primary">
|
||||
{title ?? selectedArtifact.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isVersionView && commitDetail && (
|
||||
<span className="text-sm text-accent-primary bg-accent-primary-5 px-2 py-0.5 rounded">
|
||||
{commitDetail.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<WikiDetailHeader
|
||||
title={title}
|
||||
displayedArtifact={displayedArtifact}
|
||||
commitDetail={commitDetail}
|
||||
isVersionView={isVersionView}
|
||||
toolbar={toolbar}
|
||||
canGoBack={canGoBack}
|
||||
previousEntryTitle={previousEntryTitle}
|
||||
linkNavLoading={linkNavLoading}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
{renderToolbarButtons()}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col border-t border-border-button">
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||
<ResizablePanel minSize={30}>
|
||||
<div className="h-full min-w-0 overflow-y-auto px-8 pb-8 flex flex-col">
|
||||
{loading && !content ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
content={editedContent}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{referenceDocuments.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-medium text-text-secondary mb-3">
|
||||
{t('knowledgeDetails.sourceDocuments')}
|
||||
</h3>
|
||||
<ReferenceDocumentList list={referenceDocuments} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{isVersionView && commitDetail && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<WikiVersionDiffPanel
|
||||
diff={commitDetail.diff}
|
||||
title={commitDetail.comments || commitDetail.title}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<WikiDetailEditorPanel
|
||||
loading={loading}
|
||||
editedContent={editedContent}
|
||||
displayedContent={displayedContent}
|
||||
referenceDocuments={referenceDocuments}
|
||||
isVersionView={isVersionView}
|
||||
commitDetail={commitDetail}
|
||||
onContentChange={handleContentChange}
|
||||
onWikiLinkClick={handleMarkdownLinkClick}
|
||||
/>
|
||||
|
||||
<WikiCommitModal
|
||||
open={isOpen}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import MarkdownEditor from '@/components/markdown-editor';
|
||||
import { ReferenceDocumentList } from '@/components/next-message-item/reference-document-list';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable';
|
||||
import { Spin } from '@/components/ui/spin';
|
||||
import type { Docagg } from '@/interfaces/database/chat';
|
||||
import type { IWikiCommitDetail } from '@/interfaces/database/dataset';
|
||||
|
||||
import type { WikiPageType } from './utils/parse-wiki-link';
|
||||
import { WikiVersionDiffPanel } from './wiki-version-diff-panel';
|
||||
|
||||
type WikiDetailEditorPanelProps = {
|
||||
loading: boolean;
|
||||
editedContent: string;
|
||||
displayedContent: string | undefined;
|
||||
referenceDocuments: Docagg[];
|
||||
isVersionView: boolean;
|
||||
commitDetail: IWikiCommitDetail | null | undefined;
|
||||
onContentChange: (value: string) => void;
|
||||
onWikiLinkClick: (pageType: WikiPageType, slug: string) => void;
|
||||
};
|
||||
|
||||
export function WikiDetailEditorPanel({
|
||||
loading,
|
||||
editedContent,
|
||||
displayedContent,
|
||||
referenceDocuments,
|
||||
isVersionView,
|
||||
commitDetail,
|
||||
onContentChange,
|
||||
onWikiLinkClick,
|
||||
}: WikiDetailEditorPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col border-t border-border-button">
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||
<ResizablePanel minSize={30}>
|
||||
<div className="h-full min-w-0 overflow-y-auto px-8 pb-8 flex flex-col">
|
||||
{loading && !displayedContent ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
content={editedContent}
|
||||
onChange={onContentChange}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{referenceDocuments.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-medium text-text-secondary mb-3">
|
||||
{t('knowledgeDetails.sourceDocuments')}
|
||||
</h3>
|
||||
<ReferenceDocumentList list={referenceDocuments} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{isVersionView && commitDetail && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<WikiVersionDiffPanel
|
||||
diff={commitDetail.diff}
|
||||
title={commitDetail.comments || commitDetail.title}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
web/src/pages/dataset/compilation/wiki-detail-header.tsx
Normal file
68
web/src/pages/dataset/compilation/wiki-detail-header.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
|
||||
import { MoveLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type WikiDetailHeaderProps = {
|
||||
title: string | undefined;
|
||||
displayedArtifact: IArtifact | null | undefined;
|
||||
commitDetail: IWikiCommit | null | undefined;
|
||||
isVersionView: boolean;
|
||||
toolbar: ReactNode;
|
||||
canGoBack: boolean;
|
||||
previousEntryTitle: string | undefined;
|
||||
linkNavLoading: boolean;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function WikiDetailHeader({
|
||||
title,
|
||||
displayedArtifact,
|
||||
commitDetail,
|
||||
isVersionView,
|
||||
toolbar,
|
||||
canGoBack,
|
||||
previousEntryTitle,
|
||||
linkNavLoading,
|
||||
onBack,
|
||||
}: WikiDetailHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className="shrink-0 px-5 pt-2 pb-4">
|
||||
{canGoBack && (
|
||||
<div className="shrink-0 pt-2 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
disabled={linkNavLoading}
|
||||
className="p-0 bg-transparent hover:bg-transparent text-text-secondary"
|
||||
>
|
||||
<MoveLeft className="size-4 mr-1" />
|
||||
{previousEntryTitle ?? t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-semibold text-text-primary">
|
||||
{title ?? displayedArtifact?.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isVersionView && commitDetail && (
|
||||
<span className="text-sm text-accent-primary bg-accent-primary-5 px-2 py-0.5 rounded">
|
||||
{commitDetail.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolbar}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
75
web/src/pages/dataset/compilation/wiki-detail-toolbar.tsx
Normal file
75
web/src/pages/dataset/compilation/wiki-detail-toolbar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { VersionHistorySheet } from './version-history-sheet';
|
||||
|
||||
type WikiDetailToolbarProps = {
|
||||
isDirty: boolean;
|
||||
selectedArtifact: IArtifact | null;
|
||||
selectedVersion: IWikiCommit | null;
|
||||
onCancelEdit: () => void;
|
||||
onCommitClick: () => void;
|
||||
onExport: () => void;
|
||||
onSelectVersion: (version: IWikiCommit | null) => void;
|
||||
};
|
||||
|
||||
export function WikiDetailToolbar({
|
||||
isDirty,
|
||||
selectedArtifact,
|
||||
selectedVersion,
|
||||
onCancelEdit,
|
||||
onCommitClick,
|
||||
onExport,
|
||||
onSelectVersion,
|
||||
}: WikiDetailToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isDirty) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancelEdit}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onCommitClick}>
|
||||
{t('knowledgeDetails.commit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={onExport}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('knowledgeDetails.export')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<VersionHistorySheet
|
||||
selectedArtifact={selectedArtifact}
|
||||
selectedVersion={selectedVersion}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user