Skip to content

Commit 9a3a577

Browse files
committed
fix issue in review
1 parent 341732d commit 9a3a577

20 files changed

Lines changed: 1023 additions & 198 deletions

frontend/src/App.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,6 @@ function KbDetailRoute() {
3131
return <KbDetail key={id} />
3232
}
3333

34-
/** Remount ChatSession per session id so its restore effect (which guards on a
35-
* module-instance restoredRef and only depends on [id]) re-runs on a genuine
36-
* navigation between sessions — otherwise back/forward or a hash edit between
37-
* /chat/A and /chat/B changes the id without a remount and shows the stale
38-
* session. */
39-
function ChatSessionRoute() {
40-
const { id = "" } = useParams()
41-
return <ChatSession key={id} />
42-
}
43-
4434
const inputCls =
4535
"mt-1.5 w-full h-9 rounded-md border border-input bg-transparent px-3 text-[13px] font-mono2 outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-accent-brand"
4636

@@ -175,7 +165,14 @@ export default function App() {
175165
</div>
176166
<Routes>
177167
<Route path="/" element={<Home />} />
178-
<Route path="/chat/:id" element={<ChatSessionRoute />} />
168+
{/* No key={id}: ChatSession must NOT remount when runTurn adopts a
169+
real session id mid-turn (the new→/chat/<sid> self-navigate) —
170+
a remount there would abort the live stream and drop the
171+
just-finished turn's artifact cards. Its restore effect instead
172+
re-runs on an `id` change and reloads only when navigating to a
173+
DIFFERENT saved session, so switching /chat/A→/chat/B still
174+
shows the right one. */}
175+
<Route path="/chat/:id" element={<ChatSession />} />
179176
<Route path="/kb" element={<KbList />} />
180177
<Route path="/kb/:id" element={<KbDetailRoute />} />
181178
<Route path="/settings" element={<Settings />} />

frontend/src/api/artifacts.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import { apiStream, fetchAsBlobUrl } from "./client"
66
* event's data is `{ name, status, path }` (see `_iter_deck`/`_iter_skill` in
77
* `openkb/api_helpers.py`). NOTE: this `final` shape differs from chat/query's
88
* `final` — do not fold these through `mapSseEvent`/`foldSseEvent`.
9+
*
10+
* Pass an optional `signal` (threaded to the underlying `apiStream` fetch) to
11+
* make the stream cancellable: aborting it disconnects the client, which the
12+
* backend `_stream_deck`/`_stream_skill` handlers now honor. Backward-compatible
13+
* — callers that omit it get an uncancellable stream exactly as before.
914
*/
10-
export function runDeckCommand(kb: string, name: string, intent: string) {
11-
return apiStream("/api/v1/deck", { kb, name, intent, stream: true })
15+
export function runDeckCommand(kb: string, name: string, intent: string, signal?: AbortSignal) {
16+
return apiStream("/api/v1/deck", { kb, name, intent, stream: true }, signal)
1217
}
1318

14-
export function runSkillCommand(kb: string, name: string, intent: string) {
15-
return apiStream("/api/v1/skill", { kb, name, intent, stream: true })
19+
export function runSkillCommand(kb: string, name: string, intent: string, signal?: AbortSignal) {
20+
return apiStream("/api/v1/skill", { kb, name, intent, stream: true }, signal)
1621
}
1722

1823
/**

frontend/src/api/client.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export function onUnauthorized(cb: () => void): void {
5555

5656
export class ApiError extends Error {
5757
status: number
58+
/** Structured `detail` payload when the backend returns an object rather than
59+
* a plain string — e.g. the 409 multiple-match `{ message, candidates }` from
60+
* `POST /api/v1/remove`. `message` is lifted onto `.message`; the whole object
61+
* is kept here so callers can read the structured fields (candidates, etc.).
62+
* `undefined` for string-detail errors (404) and network failures. */
63+
detail?: unknown
5864
constructor(status: number, message: string) {
5965
super(message)
6066
this.status = status
@@ -82,13 +88,28 @@ export async function apiFetch<T>(path: string, opts: FetchOpts = {}): Promise<T
8288
if (res.status === 401) unauthorizedHandler?.()
8389
if (!res.ok) {
8490
let detail = `${res.status} ${res.statusText}`
91+
let structured: unknown
8592
try {
8693
const j = await res.json()
87-
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail ?? j)
94+
const d = j.detail
95+
if (typeof d === "string") {
96+
// Plain-string detail (e.g. 404 "Document not found.") — unchanged path.
97+
detail = d
98+
} else if (d && typeof d === "object" && typeof (d as { message?: unknown }).message === "string") {
99+
// Structured detail (e.g. 409 multiple-match `{ message, candidates }`):
100+
// surface the human message, and keep the object so callers can read the
101+
// structured fields instead of seeing a raw JSON blob.
102+
detail = (d as { message: string }).message
103+
structured = d
104+
} else {
105+
detail = JSON.stringify(d ?? j)
106+
}
88107
} catch {
89108
// keep default message
90109
}
91-
throw new ApiError(res.status, detail)
110+
const err = new ApiError(res.status, detail)
111+
err.detail = structured
112+
throw err
92113
}
93114

94115
const ct = res.headers.get("content-type") || ""

frontend/src/api/maintenance.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export interface WatchStatus {
4646
* per-file UI signal, so they are collapsed away here.
4747
*/
4848
export type UploadEvent =
49-
| { type: "file_start"; original_name: string }
50-
| { type: "file_done"; file: AddFileItem }
49+
| { type: "file_start"; index: number; original_name: string }
50+
| { type: "file_done"; index: number; file: AddFileItem }
5151
| { type: "final"; result: AddResult }
5252
| { type: "error"; message: string }
5353

@@ -63,11 +63,22 @@ export type UploadEvent =
6363
* `file_done` → that file's `AddFileItem` (status added/skipped/failed),
6464
* `final` → the summary `AddResponse`,
6565
* `error` → a stream-level failure message.
66+
*
67+
* The backend emits per-file events strictly in upload order (one `file_start`
68+
* then one `file_done` per file, `saved_uploads` order == `files` order), so
69+
* `file_start`/`file_done` carry a running `index` the caller uses to correlate
70+
* an event to its seeded row by position — robust to duplicate basenames, which
71+
* name-matching would collide.
72+
*
73+
* Pass an optional `signal` to make the upload cancellable — aborting it rejects
74+
* the in-flight `fetch`/`reader.read()` with an `AbortError` that propagates out
75+
* (mirrors `apiStream`/`runRecompile`), so navigating away mid-upload cancels it.
6676
*/
6777
export async function streamUpload(
6878
kb: string,
6979
files: File[],
7080
onEvent: (ev: UploadEvent) => void,
81+
signal?: AbortSignal,
7182
): Promise<void> {
7283
const form = new FormData()
7384
form.append("kb", kb)
@@ -78,6 +89,7 @@ export async function streamUpload(
7889
method: "POST",
7990
headers: token ? { Authorization: `Bearer ${token}` } : {},
8091
body: form,
92+
signal,
8193
})
8294
if (!res.ok || !res.body) {
8395
let detail = `${res.status} ${res.statusText}`
@@ -93,6 +105,9 @@ export async function streamUpload(
93105
const reader = res.body.getReader()
94106
const decoder = new TextDecoder()
95107
let buf = ""
108+
// Running cursor over the seeded rows: `file_start` advances it, the matching
109+
// `file_done` reuses it. See the ordering note above.
110+
let fileCursor = -1
96111
while (true) {
97112
const { done, value } = await reader.read()
98113
if (done) break
@@ -105,13 +120,21 @@ export async function streamUpload(
105120
const dataLine = lines.find((l) => l.startsWith("data: "))
106121
if (!eventLine || !dataLine) continue
107122
const event = eventLine.slice("event: ".length)
108-
const data = JSON.parse(dataLine.slice("data: ".length))
123+
let data: any
124+
try {
125+
data = JSON.parse(dataLine.slice("data: ".length))
126+
} catch {
127+
// Malformed / non-JSON data frame — skip it like a robust SSE reader
128+
// instead of throwing out of the whole read loop.
129+
continue
130+
}
109131
switch (event) {
110132
case "file_start":
111-
onEvent({ type: "file_start", original_name: data.original_name })
133+
fileCursor += 1
134+
onEvent({ type: "file_start", index: fileCursor, original_name: data.original_name })
112135
break
113136
case "file_done":
114-
onEvent({ type: "file_done", file: data as AddFileItem })
137+
onEvent({ type: "file_done", index: fileCursor, file: data as AddFileItem })
115138
break
116139
case "final":
117140
onEvent({ type: "final", result: data as AddResult })
@@ -125,11 +148,15 @@ export async function streamUpload(
125148
}
126149
}
127150

128-
/** `/api/v1/remove` response (`RemoveResponse`) — the subset the UI reads. */
151+
/** `/api/v1/remove` response (`RemoveResponse`) — the subset the UI reads.
152+
* `status` is `removed` on full success or `partial` when the local wiki files
153+
* were removed but PageIndex cleanup failed (both are HTTP 200); on `partial`,
154+
* `pageindex_error` carries why the remote cleanup failed. */
129155
export interface RemoveResult {
130156
status: string
131157
name?: string | null
132158
doc_name?: string | null
159+
pageindex_error?: string | null
133160
message?: string | null
134161
}
135162

frontend/src/components/MarkdownView.tsx

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,37 @@ import { useTheme } from '@/lib/theme'
55

66
/** Guard for [text](url) links: only render a real anchor for http(s) or
77
* scheme-less (relative) URLs. Anything with another scheme (javascript:,
8-
* data:, vbscript:, …) is treated as literal text, never an anchor. */
9-
const isSafeUrl = (u: string) => /^https?:\/\//i.test(u) || !/^[a-z][\w+.-]*:/i.test(u)
8+
* data:, vbscript:, …) is treated as literal text, never an anchor.
9+
*
10+
* Browsers strip leading/embedded control chars & whitespace from a URL before
11+
* resolving it, so ` javascript:x` and `java\tscript:x` both execute. We mirror
12+
* that: collapse ALL whitespace/control chars, then only permit an explicit
13+
* `scheme:` when it is http(s). The caller also trims the raw URL for the href. */
14+
const isSafeUrl = (u: string) => {
15+
const s = u.replace(/[\u0000-\u0020\u007f-\u009f]/g, '')
16+
const scheme = /^[a-z][a-z0-9+.-]*:/i.exec(s)
17+
return scheme ? /^https?:/i.test(scheme[0]) : true
18+
}
1019

1120
/**
1221
* Minimal inline Markdown renderer. Tokens (in match-priority order):
1322
* [[wikilink|alias]] · \( math \) · **bold** · ~~strike~~ · `code` ·
1423
* [text](url) · *italic* / _italic_.
1524
* ORDER IS LOAD-BEARING inside the alternation: wikilink must precede the link
1625
* pattern (so `[[x]]` is not parsed as a link), and bold must precede italic
17-
* (so `**x**` is not parsed as `*`-italic-`*`). Every alternative uses a bounded
18-
* `[^…]+` class (no nested quantifiers) to avoid catastrophic backtracking.
26+
* (so `**x**` is not parsed as `*`-italic-`*`). Alternatives use bounded `[^…]+`
27+
* classes or a single non-greedy `.+?` (bold/math) — no nested quantifiers — so
28+
* there is no catastrophic backtracking. The inner text of bold/italic/strike
29+
* and a link's LABEL are re-run through inline() so nested markup resolves; the
30+
* recursed slice is always strictly shorter, which bounds the recursion (the
31+
* wikilink target is NOT recursed). Single `*`/`_` obey a minimal left/right
32+
* flanking rule so intraword runs (`a_b_c`, `2*3*4`) stay literal.
1933
*/
2034
function inline(text: string, onWikiLink?: (target: string) => void): React.ReactNode[] {
2135
const parts: React.ReactNode[] = []
22-
const re = /(\[\[[^\]]+\]\]|\\\([\s\S]+?\\\)|\*\*[^*]+\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g
36+
// Bold is non-greedy (`.+?`) so it tolerates an inner opposite-emphasis
37+
// (`**a *b* c**`); italic keeps a bounded `[^*]+`/`[^_]+` class.
38+
const re = /(\[\[[^\]]+\]\]|\\\([\s\S]+?\\\)|\*\*.+?\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g
2339
let last = 0, m: RegExpExecArray | null, k = 0
2440
while ((m = re.exec(text))) {
2541
if (m.index > last) parts.push(text.slice(last, m.index))
@@ -68,9 +84,10 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
6884
),
6985
)
7086
} else if (tok.startsWith('**')) {
71-
parts.push(<strong key={k++} className="font-semibold text-foreground">{tok.slice(2, -2)}</strong>)
87+
// Recurse so nested markup (e.g. `**[docs](url)**`) resolves. slice is strictly shorter.
88+
parts.push(<strong key={k++} className="font-semibold text-foreground">{inline(tok.slice(2, -2), onWikiLink)}</strong>)
7289
} else if (tok.startsWith('~~')) {
73-
parts.push(<del key={k++} className="line-through">{tok.slice(2, -2)}</del>)
90+
parts.push(<del key={k++} className="line-through">{inline(tok.slice(2, -2), onWikiLink)}</del>)
7491
} else if (tok.startsWith('`')) {
7592
parts.push(<code key={k++} className="font-mono2 text-[12px] bg-muted rounded px-1 py-px">{tok.slice(1, -1)}</code>)
7693
} else if (tok.startsWith('[')) {
@@ -79,7 +96,10 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
7996
// passes the scheme guard; otherwise render the raw token as literal text.
8097
const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
8198
const label = lm ? lm[1] : tok
82-
const url = lm ? lm[2] : ''
99+
// Trim the captured URL BEFORE the guard so ` javascript:x` can't slip
100+
// past the scheme test (browsers trim & would run it). Same trimmed
101+
// value is used for href. The label is recursed so `[**x**](url)` works.
102+
const url = lm ? lm[2].trim() : ''
83103
parts.push(
84104
lm && isSafeUrl(url) ? (
85105
<a
@@ -89,15 +109,33 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
89109
rel="noopener noreferrer"
90110
className="text-accent-brand hover:underline"
91111
>
92-
{label}
112+
{inline(label, onWikiLink)}
93113
</a>
94114
) : (
95115
<span key={k++}>{tok}</span>
96116
),
97117
)
98118
} else {
99-
// *italic* or _italic_ (bold `**…**` was consumed above).
100-
parts.push(<em key={k++} className="italic">{tok.slice(1, -1)}</em>)
119+
// *italic* / _italic_ (bold `**…**` was consumed above). Enforce a
120+
// minimal CommonMark-ish flanking rule so intraword runs stay literal:
121+
// the delimiter may only OPEN when the preceding char isn't a word char
122+
// (and the run doesn't start with whitespace), and only CLOSE when the
123+
// following char isn't a word char (and the run doesn't end with
124+
// whitespace). This keeps `a_b_c` / `2*3*4` as plain text.
125+
const before = m.index > 0 ? text[m.index - 1] : ''
126+
const after = text[m.index + tok.length] ?? ''
127+
const innerEm = tok.slice(1, -1)
128+
const openable = !/\w/.test(before) && !/^\s/.test(innerEm)
129+
const closable = !/\w/.test(after) && !/\s$/.test(innerEm)
130+
if (!openable || !closable) {
131+
// Not a real emphasis run: emit the opening delimiter literally and
132+
// resume scanning right after it (inner text may still hold tokens).
133+
parts.push(tok[0])
134+
last = m.index + 1
135+
re.lastIndex = m.index + 1
136+
continue
137+
}
138+
parts.push(<em key={k++} className="italic">{inline(innerEm, onWikiLink)}</em>)
101139
}
102140
last = m.index + tok.length
103141
}
@@ -191,6 +229,8 @@ export default function MarkdownView({
191229
const out: React.ReactNode[] = []
192230
let list: string[] = []
193231
let olist: string[] = []
232+
// Source number of the first `<ol>` item, so a list starting at N≠1 keeps it.
233+
let olistStart = 1
194234
let key = 0
195235

196236
const flushList = () => {
@@ -211,7 +251,7 @@ export default function MarkdownView({
211251
const flushOList = () => {
212252
if (!olist.length) return
213253
out.push(
214-
<ol key={key++} className="my-2.5 space-y-1.5 pl-6 list-decimal">
254+
<ol key={key++} start={olistStart} className="my-2.5 space-y-1.5 pl-6 list-decimal">
215255
{olist.map((li, i) => (
216256
<li key={i} className="pl-1 text-[14px] leading-relaxed text-muted-foreground marker:text-muted-foreground">
217257
<span>{inline(li, onWikiLink)}</span>
@@ -220,6 +260,7 @@ export default function MarkdownView({
220260
</ol>,
221261
)
222262
olist = []
263+
olistStart = 1
223264
}
224265

225266
// Block boundary: any non-list line closes BOTH pending lists.
@@ -279,10 +320,23 @@ export default function MarkdownView({
279320
/^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/.test(tblNext)
280321
) {
281322
flushBlocks()
282-
// Split a row into trimmed cells, dropping the empty cell an optional
283-
// leading/trailing edge pipe produces (`| a | b |` → ['a','b']).
323+
// Split a row into trimmed cells on unescaped, non-code `|`. A naive
324+
// `split('|')` tears apart an escaped `\|` or a `|` inside an inline code
325+
// span; we walk the string so `` `a|b` `` and `a \| b` survive as one
326+
// cell. Drops the empty cell an optional leading/trailing edge pipe makes.
284327
const cells = (row: string) => {
285-
const c = row.trim().split('|').map((s) => s.trim())
328+
const t = row.trim()
329+
const c: string[] = []
330+
let cur = ''
331+
let inCode = false
332+
for (let j = 0; j < t.length; j++) {
333+
const ch = t[j]
334+
if (ch === '\\' && t[j + 1] === '|') { cur += '|'; j++; continue }
335+
if (ch === '`') { inCode = !inCode; cur += ch; continue }
336+
if (ch === '|' && !inCode) { c.push(cur.trim()); cur = ''; continue }
337+
cur += ch
338+
}
339+
c.push(cur.trim())
286340
if (c.length && c[0] === '') c.shift()
287341
if (c.length && c[c.length - 1] === '') c.pop()
288342
return c
@@ -295,10 +349,25 @@ export default function MarkdownView({
295349
return l && r ? 'text-center' : r ? 'text-right' : l ? 'text-left' : ''
296350
})
297351
const alignOf = (col: number) => aligns[col] ?? ''
298-
// Consume header + delimiter, then all contiguous non-blank `|` rows.
352+
// A continuation line is a body row only when it is table-SHAPED, not
353+
// merely a line that happens to contain a `|`. We key off the header's
354+
// edge-pipe style: if the header had a leading/trailing `|`, body rows
355+
// must match — so a trailing prose/list line with a stray `|` (and no
356+
// blank separator) is NOT swallowed as a phantom row.
357+
const hTrim = line.trim()
358+
const edgeLead = hTrim.startsWith('|')
359+
const edgeTrail = hTrim.endsWith('|')
360+
const isRow = (l: string) => {
361+
const t = l.trim()
362+
if (!t.includes('|')) return false
363+
if (edgeLead && !t.startsWith('|')) return false
364+
if (edgeTrail && !t.endsWith('|')) return false
365+
return true
366+
}
367+
// Consume header + delimiter, then all contiguous non-blank table rows.
299368
const body: string[][] = []
300369
i += 2
301-
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
370+
while (i < lines.length && lines[i].trim() && isRow(lines[i])) {
302371
body.push(cells(lines[i]))
303372
i++
304373
}
@@ -341,9 +410,16 @@ export default function MarkdownView({
341410
// Unordered item: close any open ordered list, keep accumulating the ul.
342411
if (line.startsWith('- ')) { flushOList(); list.push(line.slice(2)); continue }
343412
// Ordered item `N. …`: close any open unordered list, strip the marker, and
344-
// accumulate into a real <ol> (mirrors the ul buffer/flush pattern).
345-
const om = /^\d+\.\s+/.exec(line)
346-
if (om) { flushList(); olist.push(line.slice(om[0].length)); continue }
413+
// accumulate into a real <ol> (mirrors the ul buffer/flush pattern). The
414+
// first item's source number seeds `<ol start>` so a list beginning at N≠1
415+
// keeps its numbering (a blank line still ends the list).
416+
const om = /^(\d+)\.\s+/.exec(line)
417+
if (om) {
418+
flushList()
419+
if (!olist.length) olistStart = parseInt(om[1], 10)
420+
olist.push(line.slice(om[0].length))
421+
continue
422+
}
347423
flushBlocks()
348424
if (!line.trim()) { out.push(<div key={key++} className="h-2" />); continue }
349425
if (line.startsWith('### ')) out.push(<h3 key={key++} className="mt-4 mb-1.5 text-[14px] font-semibold text-foreground">{inline(line.slice(4), onWikiLink)}</h3>)

0 commit comments

Comments
 (0)