diff --git a/web/src/components/markdown-editor.tsx b/web/src/components/markdown-editor.tsx index 643e49fcbe..357320bb1c 100644 --- a/web/src/components/markdown-editor.tsx +++ b/web/src/components/markdown-editor.tsx @@ -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} />
({ 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; diff --git a/web/src/lib/editor/lexical-editor.tsx b/web/src/lib/editor/lexical-editor.tsx index f314827916..85bdaaa539 100644 --- a/web/src/lib/editor/lexical-editor.tsx +++ b/web/src/lib/editor/lexical-editor.tsx @@ -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({ + diff --git a/web/src/lib/editor/plugins/wiki-link-click-plugin.tsx b/web/src/lib/editor/plugins/wiki-link-click-plugin.tsx new file mode 100644 index 0000000000..2bccc810a1 --- /dev/null +++ b/web/src/lib/editor/plugins/wiki-link-click-plugin.tsx @@ -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; +} diff --git a/web/src/pages/dataset/compilation/hooks/use-wiki-detail-content.ts b/web/src/pages/dataset/compilation/hooks/use-wiki-detail-content.ts new file mode 100644 index 0000000000..81dc3a3dcd --- /dev/null +++ b/web/src/pages/dataset/compilation/hooks/use-wiki-detail-content.ts @@ -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(() => { + 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, + }; +} diff --git a/web/src/pages/dataset/compilation/hooks/use-wiki-link-navigation.ts b/web/src/pages/dataset/compilation/hooks/use-wiki-link-navigation.ts new file mode 100644 index 0000000000..94f4b102a8 --- /dev/null +++ b/web/src/pages/dataset/compilation/hooks/use-wiki-link-navigation.ts @@ -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({ + 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, + }; +} diff --git a/web/src/pages/dataset/compilation/index.tsx b/web/src/pages/dataset/compilation/index.tsx index c39dd4e414..e9fa3aa1a0 100644 --- a/web/src/pages/dataset/compilation/index.tsx +++ b/web/src/pages/dataset/compilation/index.tsx @@ -153,6 +153,7 @@ export default function Compilation() { selectedArtifact={selectedArtifact} selectedVersion={selectedVersion} onSelectVersion={selectVersion} + onSelectArtifact={handleSelectArtifact} /> diff --git a/web/src/pages/dataset/compilation/utils/parse-wiki-link.ts b/web/src/pages/dataset/compilation/utils/parse-wiki-link.ts new file mode 100644 index 0000000000..f802c004ca --- /dev/null +++ b/web/src/pages/dataset/compilation/utils/parse-wiki-link.ts @@ -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)); +} diff --git a/web/src/pages/dataset/compilation/wiki-detail-content.tsx b/web/src/pages/dataset/compilation/wiki-detail-content.tsx index 0165528d84..a0b35d55d5 100644 --- a/web/src/pages/dataset/compilation/wiki-detail-content.tsx +++ b/web/src/pages/dataset/compilation/wiki-detail-content.tsx @@ -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(() => { - 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 ( -
- - -
- ); - } - - return ( -
- - - - - {t('knowledgeDetails.export')} - - -
- ); - }; - - const loading = isVersionView ? commitLoading : pageLoading; + const toolbar = ( + + ); return (
{selectedArtifact ? ( <> -
-
-
-

- {title ?? selectedArtifact.title} -

-
- {isVersionView && commitDetail && ( - - {commitDetail.title} - - )} -
-
+ - {renderToolbarButtons()} -
-
- -
- - -
- {loading && !content ? ( -
- -
- ) : ( - - )} - - {referenceDocuments.length > 0 && ( -
-

- {t('knowledgeDetails.sourceDocuments')} -

- -
- )} -
-
- - {isVersionView && commitDetail && ( - <> - - - - - - )} -
-
+ void; + onWikiLinkClick: (pageType: WikiPageType, slug: string) => void; +}; + +export function WikiDetailEditorPanel({ + loading, + editedContent, + displayedContent, + referenceDocuments, + isVersionView, + commitDetail, + onContentChange, + onWikiLinkClick, +}: WikiDetailEditorPanelProps) { + const { t } = useTranslation(); + + return ( +
+ + +
+ {loading && !displayedContent ? ( +
+ +
+ ) : ( + + )} + + {referenceDocuments.length > 0 && ( +
+

+ {t('knowledgeDetails.sourceDocuments')} +

+ +
+ )} +
+
+ + {isVersionView && commitDetail && ( + <> + + + + + + )} +
+
+ ); +} diff --git a/web/src/pages/dataset/compilation/wiki-detail-header.tsx b/web/src/pages/dataset/compilation/wiki-detail-header.tsx new file mode 100644 index 0000000000..395ef856d5 --- /dev/null +++ b/web/src/pages/dataset/compilation/wiki-detail-header.tsx @@ -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 ( +
+ {canGoBack && ( +
+ +
+ )} +
+
+

+ {title ?? displayedArtifact?.title} +

+
+ {isVersionView && commitDetail && ( + + {commitDetail.title} + + )} +
+
+ + {toolbar} +
+
+ ); +} diff --git a/web/src/pages/dataset/compilation/wiki-detail-toolbar.tsx b/web/src/pages/dataset/compilation/wiki-detail-toolbar.tsx new file mode 100644 index 0000000000..950745e676 --- /dev/null +++ b/web/src/pages/dataset/compilation/wiki-detail-toolbar.tsx @@ -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 ( +
+ + +
+ ); + } + + return ( +
+ + + + + {t('knowledgeDetails.export')} + + +
+ ); +}