// Global click delegator for chat-rendered wikilinks. // // useMarkdownRenderer's `legacy` mode (default for chat / memory / docs // surfaces) turns `[[Title]]` into ``. // Before this composable was wired, those anchors had no consumer — the // inline onclick that the renderer emits gets stripped by DOMPurify, and // nothing else in the codebase listens for the `wiki-link-click` custom // event the renderer fires. Clicking a wikilink in chat was therefore a // no-op. // // This composable plugs that gap. It: // 1. Listens at document level for every click that originates inside a // `.wiki-link` element carrying a `data-wiki-title` attribute. // 2. Skips clicks whose anchor already has a `data-slug` attribute — // those originate from WikiPageViewer's own DOM postprocess and have // a local handler in the wiki view that resolves against currentKB. // 3. Calls the cross-KB lookup API. // 4. Navigates to the wiki view with `?kbId=X&slug=Y` query params: // - 0 hits → toast warning "未找到匹配的 wiki 页面" // - 1 hit → router.push direct // - >1 → mcConfirm picker with KB names so the user picks which // KB they want to open // // Mounted exactly once at app root (see App.vue). Removing the listener // on unmount is unnecessary because the root component never unmounts. import { onMounted, onBeforeUnmount } from 'vue' import { useRouter } from 'vue-router' import { wikiApi } from '@/api/index' import { mcToast } from '@/composables/useMcToast' import { mcConfirm } from '@/components/common/useConfirm' interface LookupMatch { kbId: string kbName: string slug: string title: string archived: boolean } export function useGlobalWikilinkClick() { const router = useRouter() async function handleClick(e: MouseEvent) { const target = e.target as HTMLElement | null if (!target) return // The click might land on a descendant of the ; walk up if needed. // Matches both [[Title]] wikilinks and [n] wiki-citation markers. const anchor = target.closest('a.wiki-link, a.wiki-citation, .wiki-link, .wiki-citation') if (!anchor) return // WikiPageViewer's own postprocess produces for in-wiki navigation. Its onMounted hook reads // data-slug and calls store.loadPage on the current KB. Don't // intercept those — only the chat / external surfaces emit // data-wiki-title without data-slug. if (anchor.hasAttribute('data-slug')) return // Citations use data-citation-title, wikilinks use data-wiki-title. const title = anchor.getAttribute('data-citation-title') || anchor.getAttribute('data-wiki-title') if (!title) return // Prevent the no-op href="#" jump and bubbling. e.preventDefault() e.stopPropagation() // Cross-KB reference [[kbId/slug]] — the token carries an explicit numeric // KB id, so route straight to that KB instead of guessing via lookup. const crossKb = /^(\d+)\/(.+)$/.exec(title.trim()) if (crossKb) { const slug = crossKb[2].trim() if (slug) { router.push({ name: 'Wiki', query: { kbId: crossKb[1], slug } }) return } } try { // Pass both — backend matches slug first, falls back to title. For // a bracket like `[[StateGraph]]` the captured "title" is actually // the slug-or-title token, so either lookup might hit. const res: any = await wikiApi.lookupPage({ title, slug: title }) const matches: LookupMatch[] = res.data || res || [] if (matches.length === 0) { mcToast.info(`未找到匹配的 wiki 页面:${title}`) return } if (matches.length === 1) { await openMatch(matches[0]) return } // Multiple hits — let the user pick which KB. mcConfirm is yes/no, // not a picker, so we show a numbered list and prompt with the // first match by default while toasting how to refine. const ok = await mcConfirm({ title: `多个 KB 有「${title}」`, message: matches .map((m, i) => `${i + 1}. ${m.kbName} → ${m.title}`) .join('\n') + `\n\n打开第一个 (${matches[0].kbName})?`, confirmText: '打开第一个', tone: 'primary', }) if (ok) await openMatch(matches[0]) } catch (err: any) { console.error('[wikilink] lookup failed', err) mcToast.error('Wiki 链接跳转失败') } } function openMatch(m: LookupMatch) { return router.push({ name: 'Wiki', query: { kbId: m.kbId, slug: m.slug }, }) } onMounted(() => { document.addEventListener('click', handleClick, { capture: false }) }) onBeforeUnmount(() => { document.removeEventListener('click', handleClick) }) }