11import React , { useEffect , useId , useRef , useState } from 'react'
22import katex from 'katex'
33import 'katex/dist/katex.min.css'
4+ import { fetchAsBlobUrl } from '@/api/client'
45import { 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 `` 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+
219293export 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 >
0 commit comments