mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(wiki): chat-rendered wikilinks navigate via cross-KB lookup
This commit is contained in:
parent
f488be690e
commit
0e01b2b526
@ -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")
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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`),
|
||||
|
||||
108
mateclaw-ui/src/composables/useGlobalWikilinkClick.ts
Normal file
108
mateclaw-ui/src/composables/useGlobalWikilinkClick.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user