Skip to content

Commit dda7f8e

Browse files
feat(web): render extracted images inline in the document reader (#201)
* feat(web): render extracted images inline in the document reader Follow-up to #197: long/short source docs embed `![image](sources/images/...)` references whose files were extracted at ingest but never displayed (they showed as a literal "!image"). Now they render inline in the reader. - Backend: GET /api/v1/document/image serves wiki/sources/images/** — narrowed to the images dir + raster suffixes with a traversal guard (read-only; cannot reach wiki pages/skills/arbitrary files). - MarkdownView: gain an optional image token, ENABLED ONLY when a resolveImageSrc prop is passed (the doc reader). Without it the `![](…)` token is omitted from the regex, so chat/wiki rendering is byte-for-byte unchanged. A new AuthedImage fetches the API path as a blob (the bearer token can't ride on <img src>; mirrors artifacts.ts) and revokes the object URL on unmount. - Doc reader: passes a resolver that normalizes both source path conventions (wiki-root `sources/images/...` and note-relative `images/...`) to the image endpoint; external/data URLs stay literal text. Images are now visible in the UI. (LLM answers are still image-blind — ingestion does no caption/OCR — which is a separate pipeline change.) Backend adds serve/traversal/suffix/404/auth tests (1251 passed). Verified in a browser: the Soochow Securities report's figures render inline. * fix(web): address code-review findings on inline image rendering - Image URL regex now tolerates one level of balanced parens, so an image whose path contains ')' (a legacy/cloud doc_name with ASCII parentheses, e.g. 'report (1)') is no longer truncated to a broken path + stray text [#1]. - The image endpoint sets an explicit media type per suffix instead of letting FileResponse guess: a .webp is served as image/webp even where mimetypes has no webp entry (would otherwise be text/plain → a blob-loaded <img> refuses to render it) [#2]. Adds a webp media-type test. - AuthedImage shows a muted dashed placeholder (the alt) on load failure instead of rendering nothing, so a missing image is visible rather than silently dropped [#3]. Verified: the balanced-paren regex extracts full paths for 'report (1)' and fullwidth-paren names; backend 1252 passed; frontend build green.
1 parent fc8197b commit dda7f8e

4 files changed

Lines changed: 248 additions & 19 deletions

File tree

frontend/src/components/MarkdownView.tsx

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useId, useRef, useState } from 'react'
22
import katex from 'katex'
33
import 'katex/dist/katex.min.css'
4+
import { fetchAsBlobUrl } from '@/api/client'
45
import { useTheme } from '@/lib/theme'
56

67
/** Guard for [text](url) links: only render a real anchor for http(s) or
@@ -31,11 +32,19 @@ const isSafeUrl = (u: string) => {
3132
* wikilink target is NOT recursed). Single `*`/`_` obey a minimal left/right
3233
* flanking rule so intraword runs (`a_b_c`, `2*3*4`) stay literal.
3334
*/
34-
function inline(text: string, onWikiLink?: (target: string) => void): React.ReactNode[] {
35+
function inline(
36+
text: string,
37+
onWikiLink?: (target: string) => void,
38+
resolveImageSrc?: (rawSrc: string) => string | null,
39+
): React.ReactNode[] {
3540
const parts: React.ReactNode[] = []
3641
// 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
42+
// (`**a *b* c**`); italic keeps a bounded `[^*]+`/`[^_]+` class. The image
43+
// alternative `![alt](url)` is included ONLY when a resolveImageSrc is given
44+
// (the document reader), so chat/wiki rendering stays byte-for-byte unchanged.
45+
const re = resolveImageSrc
46+
? /(\[\[[^\]]+\]\]|!\[[^\]]*\]\((?:[^()]|\([^()]*\))*\)|\\\([\s\S]+?\\\)|\*\*.+?\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g
47+
: /(\[\[[^\]]+\]\]|\\\([\s\S]+?\\\)|\*\*.+?\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g
3948
let last = 0, m: RegExpExecArray | null, k = 0
4049
while ((m = re.exec(text))) {
4150
if (m.index > last) parts.push(text.slice(last, m.index))
@@ -85,11 +94,28 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
8594
)
8695
} else if (tok.startsWith('**')) {
8796
// 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>)
97+
parts.push(<strong key={k++} className="font-semibold text-foreground">{inline(tok.slice(2, -2), onWikiLink, resolveImageSrc)}</strong>)
8998
} else if (tok.startsWith('~~')) {
90-
parts.push(<del key={k++} className="line-through">{inline(tok.slice(2, -2), onWikiLink)}</del>)
99+
parts.push(<del key={k++} className="line-through">{inline(tok.slice(2, -2), onWikiLink, resolveImageSrc)}</del>)
91100
} else if (tok.startsWith('`')) {
92101
parts.push(<code key={k++} className="font-mono2 text-[12px] bg-muted rounded px-1 py-px">{tok.slice(1, -1)}</code>)
102+
} else if (tok.startsWith('![')) {
103+
// Markdown image — only reached when resolveImageSrc is provided (the
104+
// regex omits this token otherwise). resolveImageSrc maps a KB-relative
105+
// source path to an authed API path, or null for external/data URLs
106+
// (left as literal text). Cannot collide with the link/wikilink branches:
107+
// those match tokens starting with '[', this one starts with '!['.
108+
const im = /^!\[([^\]]*)\]\(((?:[^()]|\([^()]*\))*)\)$/.exec(tok)
109+
const alt = im ? im[1] : ''
110+
const raw = im ? im[2].trim() : ''
111+
const apiPath = raw ? (resolveImageSrc?.(raw) ?? null) : null
112+
parts.push(
113+
apiPath ? (
114+
<AuthedImage key={k++} apiPath={apiPath} alt={alt} />
115+
) : (
116+
<span key={k++}>{tok}</span>
117+
),
118+
)
93119
} else if (tok.startsWith('[')) {
94120
// Inline link [text](url). `[[…]]` was already consumed above, so any
95121
// `[` reaching here is a genuine link. Only emit an anchor when the URL
@@ -109,7 +135,7 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
109135
rel="noopener noreferrer"
110136
className="text-accent-brand hover:underline"
111137
>
112-
{inline(label, onWikiLink)}
138+
{inline(label, onWikiLink, resolveImageSrc)}
113139
</a>
114140
) : (
115141
<span key={k++}>{tok}</span>
@@ -135,7 +161,7 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
135161
re.lastIndex = m.index + 1
136162
continue
137163
}
138-
parts.push(<em key={k++} className="italic">{inline(innerEm, onWikiLink)}</em>)
164+
parts.push(<em key={k++} className="italic">{inline(innerEm, onWikiLink, resolveImageSrc)}</em>)
139165
}
140166
last = m.index + tok.length
141167
}
@@ -216,14 +242,67 @@ function MermaidBlock({ code }: { code: string }) {
216242
)
217243
}
218244

245+
/** Render a bearer-authed KB image. The API token can't ride on `<img src>`,
246+
* so we fetch the API path as a blob (mirrors `artifacts.ts`), show it, and
247+
* revoke the object URL on unmount / src change. While loading it shows a muted
248+
* placeholder; on failure it shows a small dashed chip (the alt) so a missing
249+
* image is visible, not silently dropped. */
250+
function AuthedImage({ apiPath, alt }: { apiPath: string; alt: string }) {
251+
const [url, setUrl] = useState<string | null>(null)
252+
const [failed, setFailed] = useState(false)
253+
useEffect(() => {
254+
let cancelled = false
255+
let objectUrl: string | null = null
256+
setUrl(null)
257+
setFailed(false)
258+
fetchAsBlobUrl(apiPath)
259+
.then((u) => {
260+
if (cancelled) {
261+
URL.revokeObjectURL(u)
262+
return
263+
}
264+
objectUrl = u
265+
setUrl(u)
266+
})
267+
.catch(() => {
268+
if (!cancelled) setFailed(true)
269+
})
270+
return () => {
271+
cancelled = true
272+
if (objectUrl) URL.revokeObjectURL(objectUrl)
273+
}
274+
}, [apiPath])
275+
// Surface a failed load (missing file, network, expired token) with a muted
276+
// dashed placeholder showing the alt, rather than silently rendering nothing.
277+
if (failed)
278+
return (
279+
<span className="my-3 inline-block rounded-lg border border-dashed border-[hsl(var(--glass-border))] px-2.5 py-1 text-[12px] text-muted-foreground">
280+
{alt}
281+
</span>
282+
)
283+
if (!url) return <span className="my-3 block h-32 animate-pulse rounded-lg bg-muted" aria-hidden />
284+
return (
285+
<img
286+
src={url}
287+
alt={alt}
288+
className="my-3 max-w-full rounded-lg border border-[hsl(var(--glass-border))]"
289+
/>
290+
)
291+
}
292+
219293
export default function MarkdownView({
220294
source,
221295
onWikiLink,
296+
resolveImageSrc,
222297
}: {
223298
source: string
224299
/** Navigate to a `[[target]]` wikilink's page. Omit to render plain,
225300
* non-interactive tokens (no `cursor-pointer` implying a dead click). */
226301
onWikiLink?: (target: string) => void
302+
/** Map a Markdown image's raw src to an authed API path (or null to leave it
303+
* as text). Only the document reader passes this; without it, `![](…)` is
304+
* not tokenized as an image, so chat/wiki rendering is unchanged. */
305+
resolveImageSrc?: (rawSrc: string) => string | null
227306
}) {
228307
const lines = source.split('\n')
229308
const out: React.ReactNode[] = []
@@ -240,7 +319,7 @@ export default function MarkdownView({
240319
{list.map((li, i) => (
241320
<li key={i} className="flex gap-2 text-[14px] leading-relaxed text-muted-foreground">
242321
<span className="mt-[9px] w-1 h-1 rounded-full bg-muted-foreground shrink-0" />
243-
<span>{inline(li, onWikiLink)}</span>
322+
<span>{inline(li, onWikiLink, resolveImageSrc)}</span>
244323
</li>
245324
))}
246325
</ul>,
@@ -254,7 +333,7 @@ export default function MarkdownView({
254333
<ol key={key++} start={olistStart} className="my-2.5 space-y-1.5 pl-6 list-decimal">
255334
{olist.map((li, i) => (
256335
<li key={i} className="pl-1 text-[14px] leading-relaxed text-muted-foreground marker:text-muted-foreground">
257-
<span>{inline(li, onWikiLink)}</span>
336+
<span>{inline(li, onWikiLink, resolveImageSrc)}</span>
258337
</li>
259338
))}
260339
</ol>,
@@ -382,7 +461,7 @@ export default function MarkdownView({
382461
key={c}
383462
className={`border border-[hsl(var(--glass-border))] bg-muted/50 px-3 py-1.5 font-semibold text-foreground ${alignOf(c)}`}
384463
>
385-
{inline(h, onWikiLink)}
464+
{inline(h, onWikiLink, resolveImageSrc)}
386465
</th>
387466
))}
388467
</tr>
@@ -395,7 +474,7 @@ export default function MarkdownView({
395474
key={c}
396475
className={`border border-[hsl(var(--glass-border))] px-3 py-1.5 text-muted-foreground ${alignOf(c)}`}
397476
>
398-
{inline(r[c] ?? '', onWikiLink)}
477+
{inline(r[c] ?? '', onWikiLink, resolveImageSrc)}
399478
</td>
400479
))}
401480
</tr>
@@ -422,11 +501,11 @@ export default function MarkdownView({
422501
}
423502
flushBlocks()
424503
if (!line.trim()) { out.push(<div key={key++} className="h-2" />); continue }
425-
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>)
426-
else if (line.startsWith('## ')) out.push(<h2 key={key++} className="mt-5 mb-2 text-[16px] font-bold text-foreground">{inline(line.slice(3), onWikiLink)}</h2>)
427-
else if (line.startsWith('# ')) out.push(<h1 key={key++} className="mb-3 text-[22px] font-extrabold tracking-tight text-foreground">{inline(line.slice(2), onWikiLink)}</h1>)
428-
else if (line.startsWith('> ')) out.push(<div key={key++} className="my-2.5 border-l-2 border-amber-400/70 bg-amber-400/10 rounded-r-lg px-3 py-2 text-[13px] text-muted-foreground">{inline(line.slice(2), onWikiLink)}</div>)
429-
else out.push(<p key={key++} className="my-1.5 text-[14px] leading-relaxed text-muted-foreground">{inline(line, onWikiLink)}</p>)
504+
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, resolveImageSrc)}</h3>)
505+
else if (line.startsWith('## ')) out.push(<h2 key={key++} className="mt-5 mb-2 text-[16px] font-bold text-foreground">{inline(line.slice(3), onWikiLink, resolveImageSrc)}</h2>)
506+
else if (line.startsWith('# ')) out.push(<h1 key={key++} className="mb-3 text-[22px] font-extrabold tracking-tight text-foreground">{inline(line.slice(2), onWikiLink, resolveImageSrc)}</h1>)
507+
else if (line.startsWith('> ')) out.push(<div key={key++} className="my-2.5 border-l-2 border-amber-400/70 bg-amber-400/10 rounded-r-lg px-3 py-2 text-[13px] text-muted-foreground">{inline(line.slice(2), onWikiLink, resolveImageSrc)}</div>)
508+
else out.push(<p key={key++} className="my-1.5 text-[14px] leading-relaxed text-muted-foreground">{inline(line, onWikiLink, resolveImageSrc)}</p>)
430509
}
431510
flushBlocks()
432511
return <div>{out}</div>

frontend/src/pages/KbDetail.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,11 +1081,27 @@ function DocumentsPane({
10811081
}
10821082
}, [kb, openHash, docReloadSeq])
10831083

1084+
// Map a source image ref to the authed image endpoint. Long-doc JSON stores
1085+
// wiki-root-relative `sources/images/...`; short-doc MD uses note-relative
1086+
// `images/...` — normalize both to a wiki-relative path. Non-matching refs
1087+
// (external / data URLs) return null and render as plain text.
1088+
const resolveDocImageSrc = useCallback(
1089+
(rawSrc: string): string | null => {
1090+
let rel: string | null = null
1091+
if (rawSrc.startsWith('sources/images/')) rel = rawSrc
1092+
else if (rawSrc.startsWith('images/')) rel = `sources/${rawSrc}`
1093+
if (!rel) return null
1094+
return `/api/v1/document/image?kb=${encodeURIComponent(kb)}&path=${encodeURIComponent(rel)}`
1095+
},
1096+
[kb],
1097+
)
10841098
// Parse Markdown once per fetched source (stable cache ref → no re-parse).
10851099
const readerBody = useMemo(
10861100
() =>
1087-
docSource && docSource.content.trim() ? <MarkdownView source={docSource.content} /> : null,
1088-
[docSource],
1101+
docSource && docSource.content.trim() ? (
1102+
<MarkdownView source={docSource.content} resolveImageSrc={resolveDocImageSrc} />
1103+
) : null,
1104+
[docSource, resolveDocImageSrc],
10891105
)
10901106
const readerEmpty = docSource != null && docSource.content.trim().length === 0
10911107

openkb/api_documents_router.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from __future__ import annotations
1010

11-
from fastapi import APIRouter, Depends, HTTPException
11+
from fastapi import APIRouter, Depends, HTTPException, Query
12+
from fastapi.responses import FileResponse
1213
from starlette.concurrency import run_in_threadpool
1314

1415
from openkb.api_helpers import _resolve_kb, require_bearer_token
@@ -17,6 +18,19 @@
1718

1819
documents_router = APIRouter()
1920

21+
# Raster types the image extractor produces, mapped to explicit media types.
22+
# SVG is excluded (inline-script risk). We set the media type ourselves rather
23+
# than let FileResponse guess it: mimetypes has no ``.webp`` entry on some
24+
# Python versions and would fall back to ``text/plain``, which a blob-loaded
25+
# ``<img>`` then refuses to render.
26+
_IMAGE_MEDIA_TYPES = {
27+
".png": "image/png",
28+
".jpg": "image/jpeg",
29+
".jpeg": "image/jpeg",
30+
".gif": "image/gif",
31+
".webp": "image/webp",
32+
}
33+
2034

2135
@documents_router.post("/api/v1/document/source", response_model=DocumentSourceResponse)
2236
async def document_source_endpoint(
@@ -33,3 +47,29 @@ async def document_source_endpoint(
3347
if result is None:
3448
raise HTTPException(status_code=404, detail="Document source not found.")
3549
return DocumentSourceResponse(**result)
50+
51+
52+
@documents_router.get("/api/v1/document/image")
53+
async def document_image_endpoint(
54+
kb: str = Query(...),
55+
path: str = Query(..., min_length=1),
56+
_: None = Depends(require_bearer_token),
57+
) -> FileResponse:
58+
"""Serve an extracted document image (``wiki/sources/images/**``).
59+
60+
``path`` is the image reference from the source text, resolved relative to
61+
the KB's ``wiki/`` dir (source text stores ``sources/images/<doc>/...``).
62+
Narrowed to the images dir + raster suffixes with a traversal guard, so this
63+
read-only sink can never serve wiki pages, skills, or arbitrary files.
64+
"""
65+
kb_dir = _resolve_kb(kb)
66+
images_root = (kb_dir / "wiki" / "sources" / "images").resolve()
67+
full = (kb_dir / "wiki" / path).resolve()
68+
if not full.is_relative_to(images_root):
69+
raise HTTPException(status_code=400, detail="Invalid image path.")
70+
media_type = _IMAGE_MEDIA_TYPES.get(full.suffix.lower())
71+
if media_type is None:
72+
raise HTTPException(status_code=400, detail="Only extracted images are served.")
73+
if not full.is_file():
74+
raise HTTPException(status_code=404, detail="Image not found.")
75+
return FileResponse(full, media_type=media_type)

tests/test_api_documents.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,97 @@ def test_document_source_requires_auth(monkeypatch, kb_dir):
186186
resp = client.post("/api/v1/document/source", json={"kb": "test-kb", "hash": "h1"})
187187

188188
assert resp.status_code == 401
189+
190+
191+
def test_document_image_serves_extracted_image(monkeypatch, kb_dir):
192+
client = _client(monkeypatch)
193+
kb = _use_named_kb(monkeypatch, kb_dir)
194+
img_dir = kb_dir / "wiki" / "sources" / "images" / "doc"
195+
img_dir.mkdir(parents=True)
196+
(img_dir / "p1_img1.png").write_bytes(b"\x89PNG\r\n\x1a\nfake-bytes")
197+
198+
resp = client.get(
199+
"/api/v1/document/image",
200+
params={"kb": kb, "path": "sources/images/doc/p1_img1.png"},
201+
headers=_auth(),
202+
)
203+
204+
assert resp.status_code == 200
205+
assert resp.headers["content-type"].startswith("image/")
206+
assert resp.content == b"\x89PNG\r\n\x1a\nfake-bytes"
207+
208+
209+
def test_document_image_sets_explicit_media_type(monkeypatch, kb_dir):
210+
"""Media type is set from the suffix (not guessed), so a .webp is served as
211+
image/webp even on Pythons whose mimetypes lacks a webp entry (which would
212+
otherwise degrade to text/plain and break a blob-loaded <img>)."""
213+
client = _client(monkeypatch)
214+
kb = _use_named_kb(monkeypatch, kb_dir)
215+
img_dir = kb_dir / "wiki" / "sources" / "images" / "doc"
216+
img_dir.mkdir(parents=True)
217+
(img_dir / "p1_img1.webp").write_bytes(b"RIFFfake")
218+
219+
resp = client.get(
220+
"/api/v1/document/image",
221+
params={"kb": kb, "path": "sources/images/doc/p1_img1.webp"},
222+
headers=_auth(),
223+
)
224+
225+
assert resp.status_code == 200
226+
assert resp.headers["content-type"] == "image/webp"
227+
228+
229+
def test_document_image_rejects_traversal(monkeypatch, kb_dir):
230+
"""A path escaping wiki/sources/images (even to another .png) is rejected by
231+
the containment guard before the suffix check."""
232+
client = _client(monkeypatch)
233+
kb = _use_named_kb(monkeypatch, kb_dir)
234+
(kb_dir / "wiki" / "sources" / "evil.png").write_bytes(b"x") # inside sources/, outside images/
235+
236+
resp = client.get(
237+
"/api/v1/document/image",
238+
params={"kb": kb, "path": "sources/images/../evil.png"},
239+
headers=_auth(),
240+
)
241+
242+
assert resp.status_code == 400
243+
244+
245+
def test_document_image_rejects_non_image_suffix(monkeypatch, kb_dir):
246+
client = _client(monkeypatch)
247+
kb = _use_named_kb(monkeypatch, kb_dir)
248+
d = kb_dir / "wiki" / "sources" / "images" / "doc"
249+
d.mkdir(parents=True)
250+
(d / "notes.md").write_text("secret", encoding="utf-8")
251+
252+
resp = client.get(
253+
"/api/v1/document/image",
254+
params={"kb": kb, "path": "sources/images/doc/notes.md"},
255+
headers=_auth(),
256+
)
257+
258+
assert resp.status_code == 400
259+
260+
261+
def test_document_image_missing_404(monkeypatch, kb_dir):
262+
client = _client(monkeypatch)
263+
kb = _use_named_kb(monkeypatch, kb_dir)
264+
265+
resp = client.get(
266+
"/api/v1/document/image",
267+
params={"kb": kb, "path": "sources/images/doc/nope.png"},
268+
headers=_auth(),
269+
)
270+
271+
assert resp.status_code == 404
272+
273+
274+
def test_document_image_requires_auth(monkeypatch, kb_dir):
275+
client = _client(monkeypatch)
276+
_use_named_kb(monkeypatch, kb_dir)
277+
278+
resp = client.get(
279+
"/api/v1/document/image", params={"kb": "test-kb", "path": "sources/images/doc/p.png"}
280+
)
281+
282+
assert resp.status_code == 401

0 commit comments

Comments
 (0)