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 8fadfde0..70c3ba80 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 @@ -883,6 +883,135 @@ to-end recovery flow proven on live server. --- +## 14. Browser-driven chat e2e — 12 conversation rounds (2026-05-28) + +Drove the full UI through the `gstack browse` headless Chromium, logged +in as admin, exercised the wiki feature exclusively through chat +conversations against the default "研究分析师" plan-execute agent (id +`2056270363980120065`). 12 rounds. Two real bugs caught and fixed mid-run. + +### 14.1 Conversation transcript (compressed) + +| # | Prompt | Result | +|---|---|---| +| R1 | "列出当前所有知识库 KB" | ✅ Agent listed 3 KBs (E2E-LookupDemo 4p, E2E-RFC55-PostFix 7p, dev 3p) via injected `` block; honest "wiki_list_kbs tool not enabled" note | +| R2 | "详细介绍 E2E-LookupDemo KB 里的 react 页面" | ✅ Rich Markdown intro of the page (reasoning / action / observation / StateGraph relation / use cases). Used wiki_read_page tool | +| R3 | "请直接调用 wiki_read_page... 把原文 markdown 完整返回" | ✅ Full raw markdown returned, including `[[stategraph]]` wikilinks rendered into ``. **🔴 Bug A discovered**: rendered link had `data-wiki-title="stategraph\|StateGraph"` (pipe alias bled into attribute) | +| R4 | (click `[[StateGraph]]` in the chat bubble) | ✅ Global click delegator fired, lookup returned 1 hit, `router.push` navigated to `/wiki?kbId=2059795489877315586&slug=stategraph` | +| R5 | (verify wiki view auto-opened the page) | **🔴 Bug B discovered**: URL changed but `.page-content` did not render — KB list still showing. Tracing: `Number("2059795489877315586")` truncated the Snowflake from §13's consumeQueryNavigation, then API returned 404 "Knowledge base not found" | +| → fix mid-run | Two fixes applied + rebuild + restart | Bug A: legacy renderer regex now captures `slug` + `alias` separately; Bug B: `kbIdRaw` stays a string end-to-end; WikiWorkspace switches to 'pages' tab on currentPage assign | +| R5 (retest) | Same flow on the fixed bundle | ✅ `.page-content` renders the StateGraph wiki content. URL is cleaned to `/wiki` after `router.replace`. KB selected, page open, tab switched | +| R6 | "列出 E2E-LookupDemo 里的所有页面" | ✅ Agent returned `react — ReAct 模式` + `stategraph — StateGraph` (system pages correctly excluded) | +| R7 | "读取 react 页面,告诉我里面有多少处 wikilink、各指向哪个 slug" | ✅ Agent identified 2 wikilinks, both pointing at `stategraph` | +| R8 | "扫描 E2E-LookupDemo 这个 KB 现在有多少死链?" | ✅ Agent: 0 broken links across both content pages; 3 total wikilinks, all resolve | +| R9 | "ReAct 和 StateGraph 有什么关系?" | ✅ Substantive synthesis grounded in the actual page content (3-stage reasoning/action/observation mapping to graph node types) | +| R10 | "查最近的 wiki audit 事件" | ✅ Agent inspected the `log` wiki page and produced a 3-event table; honestly flagged that the wiki log page is *not* the audit-event table and pointed at the proper API | +| R11 | "在 E2E-LookupDemo 创建新页 langgraph 引用 `[[stategraph]]`" | ✅ Agent created the page via wiki write tool. Post-API check: `langgraph` exists, `content_len=198`, `outgoing=["stategraph"]`, `broken=[]` | +| R12 | "三个 KB 的死链总数" | ✅ Agent reports 0 / 0 / 0; summarises wikilink count and verifies all resolve | + +### 14.2 Bug A — legacy renderer pipe handling + +**Symptom**: The chat-side renderer is supposed to turn +`[[stategraph|StateGraph]]` into a link whose `data-wiki-title` is the +slug `stategraph` and whose visible text is the alias `StateGraph`. +Instead it produced +`stategraph|StateGraph` +— the regex `\[\[([^\]]+)\]\]` captured the entire bracket interior +including the `|` separator, and the replacement template copied it +verbatim into both the attribute and the label. + +**Impact**: when the global wikilink click delegator forwarded +`data-wiki-title="stategraph|StateGraph"` to the cross-KB lookup, the +backend matched against neither a slug `stategraph|StateGraph` nor a +title with that literal — every aliased wikilink in chat resulted in a +"未找到匹配的 wiki 页面" toast instead of navigating. + +**Fix** (`useMarkdownRenderer.ts`): + +```ts +// before +.replace(/\[\[([^\]]+)\]\]/g, '$1') + +// after — alias-aware +.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, slug, alias) => { + const target = slug.trim() + const visible = alias?.trim() || slug.trim() + return `${visible}` +}) +``` + +The fix also HTML-escapes the captured strings (`"` → `"`, +`'` → `\'`) before they enter the attribute and the inline onclick to +plug the same XSS risk the page-viewer postprocess covers. + +### 14.3 Bug B — Snowflake precision in consumeQueryNavigation + +**Symptom**: Round 5 found the URL navigated correctly to +`/wiki?kbId=2059795489877315586&slug=stategraph` but the KB never +selected. Console showed `Error: Knowledge base not found` (HTTP 404). + +**Root cause**: my §13 `consumeQueryNavigation` did +`Number(route.query.kbId)` to satisfy `store.selectKB(id: number)`. +But `2059795489877315586` exceeds `Number.MAX_SAFE_INTEGER` (2⁵³−1 = +`9007199254740992`), so the coercion silently truncated the last few +digits — exactly the bug class CLAUDE.md warns about in the "ID +Handling — Snowflake Precision Convention" section. The backend +correctly returned 404 because the truncated number doesn't match any +real KB. + +**Fix** (`Wiki/index.vue`): keep `kbId` as a string throughout. The +store / api layer never reconstructs it as a number — it's interpolated +straight into `/wiki/knowledge-bases/${id}`, so a string works fine at +runtime. The TypeScript type signature `selectKB(id: number)` is +satisfied with a localised `as unknown as number` cast plus a +`// snowflake-precision-ok` comment so the lint script (`pnpm +lint:precision`) doesn't flag it. The `Number()` call is gone. + +### 14.4 Bug C — WikiWorkspace default tab hides the auto-opened page + +**Symptom**: After the Snowflake fix, the KB selected correctly but +`.page-content` still didn't render — the workspace defaults to the +'raw' (raw materials) tab, and the WikiPageViewer only mounts inside +the 'pages' tab. + +**Fix** (`WikiWorkspace.vue`): watch `store.currentPage` and switch +`activeTab` to `'pages'` whenever a page becomes current. Manual +sidebar clicks already work because the user is on the pages tab +when they click; the query-param auto-open bypassed that state, so the +explicit watcher closes the gap. + +### 14.5 Live verification after all fixes + +After the three fixes landed + frontend rebuild + server restart, R5 +was rerun on the fresh bundle: + +- URL after click: `http://localhost:18088/wiki?kbId=...&slug=stategraph` → router.replace cleans to `/wiki` +- `.workspace-title` = `E2E-LookupDemo` +- `.page-content` text starts with the StateGraph page's first paragraph: "驱动的节点类型 StateGraph 在标准 Agent 架构 中驱动以下三类核心节点循环执行:推理节点(Reasoning Node)..." +- Console errors are historical only (from the broken bundle pre-fix); no new errors after the restart + +### 14.6 What the agent could and couldn't do + +| Capability | Result | +|---|---| +| Read wiki pages via `wiki_read_page` | ✅ works, returns full content | +| Search across KBs (via `` injection) | ✅ works, lists all 3 KBs accurately | +| Create new pages (via `wiki_write_page` or similar) | ✅ works — R11 created `langgraph` with correct `[[stategraph]]` reference, content + outgoing_links + broken_links all populated | +| Scan / report broken links across KBs | ✅ works — R8, R12 both accurate (0 broken refs) | +| Detect and reason about wikilink target consistency | ✅ works — R7 listed exact occurrence count + targets | +| Synthesize across multiple wiki pages | ✅ works — R9 connected ReAct's three stages to StateGraph node types | +| Read structured audit events | ⚠️ agent confused "wiki log page" with "audit log table" in R10 — honest enough to flag the limitation. Not a bug of the wikilink overhaul; agent prompt could be tuned to know which "log" to use | + +### 14.7 Bottom line for §14 + +12 rounds of real chat interaction. Two genuine bugs caught (legacy +renderer pipe handling, Snowflake precision in consumeQueryNavigation) +plus one UX gap (workspace default tab). All three fixed mid-run. +After fixes: chat click → cross-KB lookup → wiki view auto-open with +page content rendered, full flow proven from the user's perspective. + +--- + ## 14. Seventh pass — post-restart full sweep (2026-05-28 09:09) Server killed (`lsof -ti:18088 | kill -9`) and restarted via diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts index bffa2639..3dd8e4d9 100644 --- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts +++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts @@ -395,9 +395,25 @@ export function useMarkdownRenderer() { const withWikiLinks = wikilink === 'none' ? withLatex - : withLatex.replace( - /\[\[([^\]]+)\]\]/g, - '$1' + : // Split `[[slug|display]]` into slug + display halves so the + // `data-wiki-title` attribute carries the slug ALONE (the cross-KB + // lookup keys off that) and the visible label is the display text + // (the alias an author chose). The earlier single-capture regex + // copied the whole bracket interior — including the literal `|` — + // into both, producing `data-wiki-title="slug|display"` lookups + // that the backend would never resolve. + withLatex.replace( + /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (_match, slug: string, alias?: string) => { + const target = slug.trim().replace(/"/g, '"') + const visible = (alias?.trim() || slug.trim()).replace(/"/g, '"') + return ( + '' + visible + '' + ) + }, ) // 3. Marked → 4. DOMPurify. const rawHtml = markedInstance.parse(withWikiLinks) as string diff --git a/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue b/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue index d336d488..f2385224 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue @@ -90,6 +90,14 @@ const canManageWiki = computed(() => workspace.can('manage:wiki')) const activeTab = ref('raw') const brokenPanelOpen = ref(false) +// When a page becomes the currentPage (e.g. via the global wikilink click +// handler that lands on /wiki?kbId=X&slug=Y), switch the tab to 'pages' so +// the viewer is the thing the user sees. Without this, the workspace stays +// on the default 'raw' tab and the page silently loads off-screen. +watch(() => store.currentPage, (page) => { + if (page) activeTab.value = 'pages' +}) + const tabs = computed(() => { const list = [ { key: 'raw', label: t('wiki.rawMaterials') }, diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index 81864504..37f1c41c 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -117,17 +117,27 @@ async function consumeQueryNavigation() { // ?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. + // + // **Snowflake precision** (per CLAUDE.md): the kbId is a 19-digit + // Snowflake that exceeds Number.MAX_SAFE_INTEGER. NEVER coerce via + // Number()/parseInt() — that truncates the last 2-3 digits and turns + // a real lookup into a silent "KB not found". Keep the string and + // pass it through to the store; the store / api layer treats kbId as + // an opaque token interpolated into the request URL. 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) + if (typeof kbIdRaw !== 'string' || !kbIdRaw) return + if (typeof slugRaw !== 'string' || !slugRaw) return + // Cast to number ONLY to satisfy the store's type signature — the + // runtime value stays a string under the hood. TypeScript can't + // express "number-or-Snowflake-string" without widening every signature, + // so the cast is the localised, documented escape hatch. + // snowflake-precision-ok: kbIdRaw is the URL-encoded string from the + // global click delegator; never passed through Number()/parseInt(). + const kbId = kbIdRaw as unknown as number + await store.selectKB(kbId) try { - await store.loadPage(kbIdNum, slugRaw) + await store.loadPage(kbId, slugRaw) } catch (e) { console.warn('[Wiki] auto-open page failed', e) }