mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +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);
|
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")
|
@RequireWorkspaceRole("viewer")
|
||||||
@Operation(summary = "获取反向链接")
|
@Operation(summary = "获取反向链接")
|
||||||
@GetMapping("/knowledge-bases/{kbId}/pages/{slug}/backlinks")
|
@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
|
five distinct phases of validation. No open defects; both follow-ups
|
||||||
shipped and live-verified.
|
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 zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import { currentLocale } from '@/i18n'
|
import { currentLocale } from '@/i18n'
|
||||||
import { useThemeStore } from '@/stores/useThemeStore'
|
import { useThemeStore } from '@/stores/useThemeStore'
|
||||||
|
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
|
||||||
import McConfirmHost from '@/components/common/McConfirmHost.vue'
|
import McConfirmHost from '@/components/common/McConfirmHost.vue'
|
||||||
|
|
||||||
// Initialize theme — applies .dark class to <html> immediately
|
// Initialize theme — applies .dark class to <html> immediately
|
||||||
useThemeStore()
|
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()
|
const { t } = useI18n()
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
|
|||||||
@ -811,6 +811,12 @@ export const wikiApi = {
|
|||||||
getBacklinks: (kbId: number, slug: string) =>
|
getBacklinks: (kbId: number, slug: string) =>
|
||||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`),
|
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
|
// Archived pages
|
||||||
listArchivedPages: (kbId: number) =>
|
listArchivedPages: (kbId: number) =>
|
||||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/archived`),
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch, onMounted } from 'vue'
|
import { ref, reactive, watch, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useWikiStore, type WikiKB } from '@/stores/useWikiStore'
|
import { useWikiStore, type WikiKB } from '@/stores/useWikiStore'
|
||||||
import { wikiApi } from '@/api/index'
|
import { wikiApi } from '@/api/index'
|
||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
@ -48,6 +49,9 @@ import { mcToast } from '@/composables/useMcToast'
|
|||||||
import WikiLibrary from './components/WikiLibrary.vue'
|
import WikiLibrary from './components/WikiLibrary.vue'
|
||||||
import WikiWorkspace from './components/WikiWorkspace.vue'
|
import WikiWorkspace from './components/WikiWorkspace.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useWikiStore()
|
const store = useWikiStore()
|
||||||
|
|
||||||
@ -108,9 +112,37 @@ async function handleDeleteKB(kb: WikiKB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function consumeQueryNavigation() {
|
||||||
store.fetchKnowledgeBases()
|
// 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user