fix(wiki): 3 bugs caught by browser-driven chat e2e + 12-round trace

This commit is contained in:
matevip 2026-05-28 10:55:30 +08:00
parent 7c9db74886
commit 0ef735588a
4 changed files with 174 additions and 11 deletions

View File

@ -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 `<wiki-context>` 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 `<a class="wiki-link">`. **🔴 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
`<a data-wiki-title="stategraph|StateGraph">stategraph|StateGraph</a>`
— 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, '<a data-wiki-title="$1">$1</a>')
// after — alias-aware
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, slug, alias) => {
const target = slug.trim()
const visible = alias?.trim() || slug.trim()
return `<a data-wiki-title="${target}">${visible}</a>`
})
```
The fix also HTML-escapes the captured strings (`"` → `&quot;`,
`'``\'`) 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 `<wiki-context>` 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

View File

@ -395,9 +395,25 @@ export function useMarkdownRenderer() {
const withWikiLinks =
wikilink === 'none'
? withLatex
: withLatex.replace(
/\[\[([^\]]+)\]\]/g,
'<a class="wiki-link" href="#" data-wiki-title="$1" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'$1\'}}));return false">$1</a>'
: // 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, '&quot;')
const visible = (alias?.trim() || slug.trim()).replace(/"/g, '&quot;')
return (
'<a class="wiki-link" href="#" data-wiki-title="' + target +
'" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'' +
target.replace(/'/g, "\\'") +
'\'}}));return false">' + visible + '</a>'
)
},
)
// 3. Marked → 4. DOMPurify.
const rawHtml = markedInstance.parse(withWikiLinks) as string

View File

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

View File

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