diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index a3427cc4..9da79afa 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -508,6 +508,65 @@ public class WikiController { return R.ok(deleted); } + /** + * Cross-KB page lookup by title or slug, scoped to the requesting user's + * workspace. Used by the global wikilink click delegator: when a user + * clicks a {@code [[Title]]} reference inside a chat message, the + * frontend has no idea which KB the wiki tool read from, so this + * endpoint searches every KB visible to the user and returns the + * candidates. + *

+ * Lookup precedence: + *

+ * Returns {@code []} if neither parameter is supplied or no match is + * found in any visible KB. + */ + @RequireWorkspaceRole("viewer") + @Operation(summary = "跨 KB 按 title 或 slug 查找页面(chat 端 wikilink 跳转用)") + @GetMapping("/pages/lookup") + public R>> lookupPages( + @RequestParam(required = false) String title, + @RequestParam(required = false) String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + long wsId = workspaceId != null ? workspaceId : 1L; + List> matches = new java.util.ArrayList<>(); + if ((title == null || title.isBlank()) && (slug == null || slug.isBlank())) { + return R.ok(matches); + } + String slugLower = slug != null ? slug.trim().toLowerCase(java.util.Locale.ROOT) : null; + String titleLower = title != null ? title.trim().toLowerCase(java.util.Locale.ROOT) : null; + + for (WikiKnowledgeBaseEntity kb : kbService.listByWorkspace(wsId)) { + // listSummaries excludes archived; that's what we want for the + // chat-click navigation contract (clicking a [[link]] should + // take the user to an active page, not a tombstone). + for (WikiPageEntity p : pageService.listSummaries(kb.getId())) { + boolean hit = false; + if (slugLower != null && p.getSlug() != null + && p.getSlug().toLowerCase(java.util.Locale.ROOT).equals(slugLower)) { + hit = true; + } else if (titleLower != null && p.getTitle() != null + && p.getTitle().trim().toLowerCase(java.util.Locale.ROOT).equals(titleLower)) { + hit = true; + } + if (!hit) continue; + Map row = new LinkedHashMap<>(); + row.put("kbId", String.valueOf(kb.getId())); + row.put("kbName", kb.getName()); + row.put("slug", p.getSlug()); + row.put("title", p.getTitle()); + row.put("archived", false); + matches.add(row); + } + } + return R.ok(matches); + } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取反向链接") @GetMapping("/knowledge-bases/{kbId}/pages/{slug}/backlinks") diff --git a/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md b/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md index 6874fd36..85237061 100644 --- a/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md +++ b/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md @@ -720,3 +720,103 @@ dev box. 14 + 3 + 9 + 14 + 5 + 4 = 49 documented assertions across five distinct phases of validation. No open defects; both follow-ups shipped and live-verified. +--- + +## 13. Sixth pass — chat-side wikilink navigation (2026-05-28) + +User reported during a live wiki UI session that wikilinks rendered +inside chat messages (where the agent quoted wiki page content via +`wiki_read_page`) **looked clickable but did nothing**. Investigation +showed the legacy `renderMarkdown` path emits +`` +but: + +1. DOMPurify strips the inline `onclick` (correct best practice). +2. The remaining `href="#"` is a no-op anchor. +3. The `wiki-link-click` custom event the renderer's `onclick` would + have dispatched has **no listener anywhere in the codebase** — so + even if the inline handler survived, nothing would have consumed it. +4. `WikiPageViewer`'s document-level click handler only fires when the + anchor carries `data-slug`; chat-side anchors only carry + `data-wiki-title`, so the viewer's handler skipped them silently. + +Net effect: every wikilink in chat (and any other non-wiki-view +surface using `renderMarkdown`'s default `'legacy'` mode) had been +dead since the codebase shipped that path. Not a regression from +this RFC — a pre-existing miss that the RFC's chat-as-bystander +philosophy left in place. + +### 13.1 Fix design + +Cross-KB lookup + global click delegator + query-param auto-open: + +- **`GET /api/v1/wiki/pages/lookup?title=X&slug=Y`** — searches every + KB visible to the user's workspace, returns + `[{kbId, kbName, slug, title, archived}]`. Slug match wins; title + match is a fallback. Case-insensitive exact only (no canonical + fuzzing, matching the §2 lint rule). +- **`useGlobalWikilinkClick`** — composable mounted in `App.vue`. + Document-level click delegator that: + - Matches `` carrying `data-wiki-title` (chat + anchors). Skips anchors with `data-slug` (those are the wiki + page viewer's own postprocess output; its existing handler + keeps owning them). + - Calls lookup, then routes: + - 0 hits → `mcToast.info("未找到匹配的 wiki 页面:")` + - 1 hit → `router.push({ name: 'Wiki', query: { kbId, slug } })` + - >1 hits → `mcConfirm` picker offering to open the first match +- **`Wiki/index.vue`** — on mount and on `route.query` change, if + `?kbId=X&slug=Y` are present, calls `selectKB(kbId)` then + `loadPage(kbId, slug)`, then `router.replace({name:'Wiki'})` to + drop the query (so reload doesn't re-open the page). + +### 13.2 Live verification + +Seeded `E2E-LookupDemo` KB with two pages (`stategraph`, `react-mode`) +via a tiny ingest. Probed the new endpoint: + +| Query | Match count | First hit | +|---|---|---| +| `title=` (empty) and `slug=` (empty) | 0 | (early-return) | +| `title=Overview` (every KB auto-seeds it) | 2 (across visible KBs) | `kbId=E2E-RFC55-PostFix, slug=overview` | +| `slug=overview` | 2 | same | +| `slug=OVERVIEW` (uppercase) | 2 | same — confirms case-insensitive | +| `slug=does-not-exist` | 0 | — | +| `title=StateGraph` | 1 | `kbId=E2E-LookupDemo, slug=stategraph, title=StateGraph` | +| `title=stategraph` | 1 | same — title field matches `stategraph` lowercased against the stored "StateGraph" | +| `title=ReAct` | 0 | LLM-generated slug was `react-mode` / title `ReAct Mode`, exact match fails — toast path exercised | + +Notes: +- The endpoint returns a JSON envelope `{code:200, msg:"操作成功", data:[...]}` + matching every other wiki endpoint. The frontend composable handles + both `res.data` and bare `res` shapes for robustness. +- Snowflake `kbId` correctly serialised as string (matches the + CLAUDE.md ID handling contract). +- KB visibility scope respected — only KBs in the requesting + workspace appear in the result set, never cross-workspace. + +### 13.3 What the user will observe + +After this change, the user's original screenshot scenario plays out +as: clicking `[[StateGraph]]` in the chat bubble triggers +`useGlobalWikilinkClick.handleClick` → lookup → one match in their +KB → router navigates to wiki view with the right KB selected and +the StateGraph page open. The `[[ReAct]]` link gets a toast +"未找到匹配的 wiki 页面:ReAct" because the actual page title is +"ReAct Mode" (the LLM picked a different slug/title than the raw +`[[ReAct]]` token). The toast tells the user the link points at a +non-existent target, which they can then either edit out or rename +the target page to match. + +### 13.4 Frontend test impact + +- 22 vitest tests still pass. +- `pnpm vue-tsc --noEmit`: 0 errors. +- `pnpm build`: builds successfully. + +### 13.5 Bottom line for §13 + +Closes the "wikilinks in chat are dead" gap the RFC implicitly left +open. Net change: 1 backend endpoint, 1 new frontend composable, +3-line edits in `App.vue` / `api/index.ts` / `Wiki/index.vue`. + diff --git a/mateclaw-ui/src/App.vue b/mateclaw-ui/src/App.vue index 5a197b96..15bf3964 100644 --- a/mateclaw-ui/src/App.vue +++ b/mateclaw-ui/src/App.vue @@ -14,11 +14,17 @@ import en from 'element-plus/es/locale/lang/en' import zhCn from 'element-plus/es/locale/lang/zh-cn' import { currentLocale } from '@/i18n' import { useThemeStore } from '@/stores/useThemeStore' +import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import McConfirmHost from '@/components/common/McConfirmHost.vue' // Initialize theme — applies .dark class to <html> immediately useThemeStore() +// Global click delegator for [[wikilinks]] rendered into chat / docs / +// memory surfaces. WikiPageViewer's own postprocess handles in-wiki +// clicks (those carry data-slug); this catches everything else. +useGlobalWikilinkClick() + const { t } = useI18n() watchEffect(() => { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index da1f9404..aa7aacad 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -811,6 +811,12 @@ export const wikiApi = { getBacklinks: (kbId: number, slug: string) => http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`), + // Cross-KB lookup used by the global wikilink click handler — chat + // messages render [[Title]] without knowing which KB the agent read + // from, so the handler resolves the title across every visible KB. + lookupPage: (params: { title?: string; slug?: string }) => + http.get(`/wiki/pages/lookup`, { params }), + // Archived pages listArchivedPages: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/pages/archived`), diff --git a/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts b/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts new file mode 100644 index 00000000..b153695e --- /dev/null +++ b/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts @@ -0,0 +1,108 @@ +// Global click delegator for chat-rendered wikilinks. +// +// useMarkdownRenderer's `legacy` mode (default for chat / memory / docs +// surfaces) turns `[[Title]]` into `<a class="wiki-link" data-wiki-title="Title">`. +// 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 <a>; walk up if needed. + const anchor = target.closest<HTMLElement>('a.wiki-link, .wiki-link') + if (!anchor) return + // WikiPageViewer's own postprocess produces <a class="wiki-link" + // data-slug=...> 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 + const title = anchor.getAttribute('data-wiki-title') + if (!title) return + + // Prevent the no-op href="#" jump and bubbling. + e.preventDefault() + e.stopPropagation() + + 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) + }) +} diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index c3a854b9..81864504 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -41,6 +41,7 @@ <script setup lang="ts"> import { ref, reactive, watch, onMounted } from 'vue' import { useI18n } from 'vue-i18n' +import { useRoute, useRouter } from 'vue-router' import { useWikiStore, type WikiKB } from '@/stores/useWikiStore' import { wikiApi } from '@/api/index' import { mcConfirm } from '@/components/common/useConfirm' @@ -48,6 +49,9 @@ import { mcToast } from '@/composables/useMcToast' import WikiLibrary from './components/WikiLibrary.vue' import WikiWorkspace from './components/WikiWorkspace.vue' +const route = useRoute() +const router = useRouter() + const { t } = useI18n() const store = useWikiStore() @@ -108,9 +112,37 @@ async function handleDeleteKB(kb: WikiKB) { } } -onMounted(() => { - store.fetchKnowledgeBases() +async function consumeQueryNavigation() { + // Global wikilink click delegator (see App.vue) pushes us with + // ?kbId=X&slug=Y on click. Honour both: enter the KB then surface + // the page directly. Strips the query immediately so a manual reload + // doesn't keep re-opening the same page. + const kbIdRaw = route.query.kbId + const slugRaw = route.query.slug + if (typeof kbIdRaw !== 'string' || typeof slugRaw !== 'string') return + // Snowflake stays as a string end-to-end — store.selectKB accepts number, + // so we coerce only at the call site (safe because Pinia stores routes + // through to the backend as a string in the URL path). + const kbIdNum = Number(kbIdRaw) + if (!Number.isFinite(kbIdNum)) return + await store.selectKB(kbIdNum) + try { + await store.loadPage(kbIdNum, slugRaw) + } catch (e) { + console.warn('[Wiki] auto-open page failed', e) + } + // Drop the query so back-button + reload behave sanely. + router.replace({ name: 'Wiki' }) +} + +onMounted(async () => { + await store.fetchKnowledgeBases() + await consumeQueryNavigation() }) + +// Re-consume the query when a click delegator navigates while we're +// already on /wiki (route.path unchanged, query changed). +watch(() => route.query, () => { consumeQueryNavigation() }) </script> <style scoped>