feat(wiki): chat-rendered wikilinks navigate via cross-KB lookup

This commit is contained in:
matevip 2026-05-28 09:25:58 +08:00
parent f488be690e
commit 0e01b2b526
6 changed files with 313 additions and 2 deletions

View File

@ -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.
* <p>
* Lookup precedence:
* <ul>
* <li>If {@code slug} is provided, match against {@code page.slug}
* (case-insensitive exact).</li>
* <li>Else if {@code title} is provided, match against
* {@code page.title} (case-insensitive exact, trimmed).</li>
* </ul>
* 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<List<Map<String, Object>>> 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<Map<String, Object>> 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<String, Object> 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")

View File

@ -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
`<a class="wiki-link" href="#" data-wiki-title="..." onclick="...">`
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 `<a class="wiki-link">` 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 页面:<title>")`
- 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`.

View File

@ -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(() => {

View File

@ -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`),

View File

@ -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)
})
}

View File

@ -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>