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,59 @@ 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. On failure it renders nothing
248+ * rather than a broken-image glyph; while loading it shows a muted placeholder. */
249+ function AuthedImage ( { apiPath, alt } : { apiPath : string ; alt : string } ) {
250+ const [ url , setUrl ] = useState < string | null > ( null )
251+ const [ failed , setFailed ] = useState ( false )
252+ useEffect ( ( ) => {
253+ let cancelled = false
254+ let objectUrl : string | null = null
255+ setUrl ( null )
256+ setFailed ( false )
257+ fetchAsBlobUrl ( apiPath )
258+ . then ( ( u ) => {
259+ if ( cancelled ) {
260+ URL . revokeObjectURL ( u )
261+ return
262+ }
263+ objectUrl = u
264+ setUrl ( u )
265+ } )
266+ . catch ( ( ) => {
267+ if ( ! cancelled ) setFailed ( true )
268+ } )
269+ return ( ) => {
270+ cancelled = true
271+ if ( objectUrl ) URL . revokeObjectURL ( objectUrl )
272+ }
273+ } , [ apiPath ] )
274+ if ( failed ) return null
275+ if ( ! url ) return < span className = "my-3 block h-32 animate-pulse rounded-lg bg-muted" aria-hidden />
276+ return (
277+ < img
278+ src = { url }
279+ alt = { alt }
280+ className = "my-3 max-w-full rounded-lg border border-[hsl(var(--glass-border))]"
281+ />
282+ )
283+ }
284+
219285export default function MarkdownView ( {
220286 source,
221287 onWikiLink,
288+ resolveImageSrc,
222289} : {
223290 source : string
224291 /** Navigate to a `[[target]]` wikilink's page. Omit to render plain,
225292 * non-interactive tokens (no `cursor-pointer` implying a dead click). */
226293 onWikiLink ?: ( target : string ) => void
294+ /** Map a Markdown image's raw src to an authed API path (or null to leave it
295+ * as text). Only the document reader passes this; without it, `` is
296+ * not tokenized as an image, so chat/wiki rendering is unchanged. */
297+ resolveImageSrc ?: ( rawSrc : string ) => string | null
227298} ) {
228299 const lines = source . split ( '\n' )
229300 const out : React . ReactNode [ ] = [ ]
@@ -240,7 +311,7 @@ export default function MarkdownView({
240311 { list . map ( ( li , i ) => (
241312 < li key = { i } className = "flex gap-2 text-[14px] leading-relaxed text-muted-foreground" >
242313 < span className = "mt-[9px] w-1 h-1 rounded-full bg-muted-foreground shrink-0" />
243- < span > { inline ( li , onWikiLink ) } </ span >
314+ < span > { inline ( li , onWikiLink , resolveImageSrc ) } </ span >
244315 </ li >
245316 ) ) }
246317 </ ul > ,
@@ -254,7 +325,7 @@ export default function MarkdownView({
254325 < ol key = { key ++ } start = { olistStart } className = "my-2.5 space-y-1.5 pl-6 list-decimal" >
255326 { olist . map ( ( li , i ) => (
256327 < li key = { i } className = "pl-1 text-[14px] leading-relaxed text-muted-foreground marker:text-muted-foreground" >
257- < span > { inline ( li , onWikiLink ) } </ span >
328+ < span > { inline ( li , onWikiLink , resolveImageSrc ) } </ span >
258329 </ li >
259330 ) ) }
260331 </ ol > ,
@@ -382,7 +453,7 @@ export default function MarkdownView({
382453 key = { c }
383454 className = { `border border-[hsl(var(--glass-border))] bg-muted/50 px-3 py-1.5 font-semibold text-foreground ${ alignOf ( c ) } ` }
384455 >
385- { inline ( h , onWikiLink ) }
456+ { inline ( h , onWikiLink , resolveImageSrc ) }
386457 </ th >
387458 ) ) }
388459 </ tr >
@@ -395,7 +466,7 @@ export default function MarkdownView({
395466 key = { c }
396467 className = { `border border-[hsl(var(--glass-border))] px-3 py-1.5 text-muted-foreground ${ alignOf ( c ) } ` }
397468 >
398- { inline ( r [ c ] ?? '' , onWikiLink ) }
469+ { inline ( r [ c ] ?? '' , onWikiLink , resolveImageSrc ) }
399470 </ td >
400471 ) ) }
401472 </ tr >
@@ -422,11 +493,11 @@ export default function MarkdownView({
422493 }
423494 flushBlocks ( )
424495 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 > )
496+ 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 > )
497+ 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 > )
498+ 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 > )
499+ 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 > )
500+ else out . push ( < p key = { key ++ } className = "my-1.5 text-[14px] leading-relaxed text-muted-foreground" > { inline ( line , onWikiLink , resolveImageSrc ) } </ p > )
430501 }
431502 flushBlocks ( )
432503 return < div > { out } </ div >
0 commit comments