@changfenhuang/dsh-genui 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +12 -4
- package/README.zh-CN.md +12 -4
- package/SKILL.md +2 -2
- package/lib/assets/mermaid.js +325 -325
- package/lib/assets/standalone-runtime.js +9 -9
- package/lib/client.js +30 -30
- package/lib/index.js +101 -14
- package/lib/types/client/dom-fence.d.ts +2 -1
- package/lib/types/client/file-address.d.ts +4 -0
- package/lib/types/client/file-link-context.d.ts +41 -0
- package/lib/types/client/file-link.d.ts +17 -0
- package/lib/types/client/i18n/en.d.ts +5 -1
- package/lib/types/client/inline.d.ts +1 -1
- package/lib/types/client/mermaid-colors.d.ts +2 -0
- package/lib/types/client/panel.d.ts +6 -1
- package/lib/types/plugin/fence-feedback.d.ts +6 -2
- package/lib/types/plugin/index.d.ts +4 -0
- package/lib/types/shared/fence-repair.d.ts +1 -0
- package/package.json +1 -12
- package/src/client/GenuiBlock.module.css +35 -3
- package/src/client/artifact/ArtifactExport.module.css +34 -3
- package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
- package/src/client/blocks/charts.tsx +22 -6
- package/src/client/blocks/render-node.tsx +10 -7
- package/src/client/dom-fence.tsx +97 -30
- package/src/client/file-address.ts +37 -0
- package/src/client/file-link-context.tsx +114 -0
- package/src/client/file-link.ts +46 -0
- package/src/client/guard.ts +5 -2
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.tsx +23 -5
- package/src/client/inline.ts +17 -6
- package/src/client/mermaid-colors.ts +39 -0
- package/src/client/mermaid-core.ts +7 -3
- package/src/client/panel.tsx +18 -5
- package/src/plugin/fence-feedback.ts +17 -5
- package/src/plugin/index.ts +14 -4
- package/src/plugin/tool.ts +8 -2
- package/src/shared/fence-repair.ts +36 -0
- package/tsdown.config.ts +3 -7
- package/lib/invariant.js +0 -19
- package/lib/types/plugin/invariant.d.ts +0 -15
- package/src/plugin/invariant.ts +0 -30
|
@@ -13,6 +13,17 @@ interface ArtifactExportMenuProps {
|
|
|
13
13
|
getArtifact: () => ReturnType<typeof createGenuiArtifact>
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** 导出入口的图标字形:托盘 + 下箭头(“导出为文件”),14px 描边、随文字取色。 */
|
|
17
|
+
function ExportGlyph() {
|
|
18
|
+
return (
|
|
19
|
+
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
|
20
|
+
<path d="M8 2.2v7.1" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
21
|
+
<path d="M5 6.4 8 9.4l3-3" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
|
22
|
+
<path d="M2.9 10.9v1.5a1 1 0 0 0 1 1h8.2a1 1 0 0 0 1-1v-1.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
23
|
+
</svg>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
/** 提供 JSON 和 HTML 的本地化导出菜单。 */
|
|
17
28
|
function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
|
|
18
29
|
const t = useT()
|
|
@@ -65,8 +76,18 @@ function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
|
|
|
65
76
|
<div className={css.chrome} ref={rootRef} data-genui-export>
|
|
66
77
|
{report.externalMedia.length > 0 && <span className={css.notice}>{t('artifact.externalMediaNotice')}</span>}
|
|
67
78
|
{errorMessage !== '' && <span className={css.statusError} aria-live="polite">{errorMessage}</span>}
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
{/* 无边框图标入口:完成态内容不再多出一个描边按钮。图标按钮的 aria-label
|
|
80
|
+
+ title 组合与项目里的 panelTpl / panelToggle 一致。 */}
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className={css.trigger}
|
|
84
|
+
aria-label={t('artifact.export')}
|
|
85
|
+
title={t('artifact.export')}
|
|
86
|
+
aria-haspopup="menu"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
onClick={() => setOpen(value => !value)}
|
|
89
|
+
>
|
|
90
|
+
<ExportGlyph />
|
|
70
91
|
</button>
|
|
71
92
|
{open && (
|
|
72
93
|
<div className={css.menu} role="menu">
|
|
@@ -345,7 +345,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
345
345
|
.join(' ') || undefined}
|
|
346
346
|
>
|
|
347
347
|
{type === 'badge'
|
|
348
|
-
? <span className={css.cellBadge}>{renderInline(String(cell),
|
|
348
|
+
? <span className={css.cellBadge}>{renderInline(String(cell), 'file')}</span>
|
|
349
349
|
: type === 'bar'
|
|
350
350
|
? <CellBar cell={cell} />
|
|
351
351
|
: type === 'spark'
|
|
@@ -356,8 +356,8 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
356
356
|
? <span className={css.cellIndex}>{rowIndex + 1}</span>
|
|
357
357
|
: tone === null
|
|
358
358
|
// Format displayed text without changing sorting or exported data.
|
|
359
|
-
? renderInline(String(cell),
|
|
360
|
-
: <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell),
|
|
359
|
+
? renderInline(String(cell), 'file')
|
|
360
|
+
: <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), 'file')}</span>}
|
|
361
361
|
</td>
|
|
362
362
|
)
|
|
363
363
|
}
|
|
@@ -396,13 +396,21 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
396
396
|
<button
|
|
397
397
|
type="button"
|
|
398
398
|
className={css.groupToggle}
|
|
399
|
+
aria-label={tr(isCollapsed ? 'block.table.expandGroup' : 'block.table.collapseGroup', {
|
|
400
|
+
label: String(section.header.row[0]).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
|
|
401
|
+
})}
|
|
399
402
|
aria-expanded={!isCollapsed}
|
|
400
403
|
onClick={() => toggleSection(headerIndex)}
|
|
401
404
|
>
|
|
402
405
|
<span className={css.groupChevron} aria-hidden>{isCollapsed ? '▸' : '▾'}</span>
|
|
403
|
-
{renderInline(String(section.header.row[0]), false)}
|
|
404
|
-
<span className={css.groupCount}>{section.children.length}</span>
|
|
405
406
|
</button>
|
|
407
|
+
<span className={css.groupLabel} onClick={event => {
|
|
408
|
+
if (event.target instanceof Element && event.target.closest('button')) return
|
|
409
|
+
toggleSection(headerIndex)
|
|
410
|
+
}}>
|
|
411
|
+
{renderInline(String(section.header.row[0]), 'file')}
|
|
412
|
+
<span className={css.groupCount}>{section.children.length}</span>
|
|
413
|
+
</span>
|
|
406
414
|
</td>
|
|
407
415
|
</tr>
|
|
408
416
|
)}
|
|
@@ -419,12 +427,20 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
419
427
|
<button
|
|
420
428
|
type="button"
|
|
421
429
|
className={css.detailToggle}
|
|
430
|
+
aria-label={tr(open ? 'block.table.collapseDetails' : 'block.table.expandDetails', {
|
|
431
|
+
label: String(cell).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
|
|
432
|
+
})}
|
|
422
433
|
aria-expanded={open}
|
|
423
434
|
onClick={() => toggleDetail(child.index)}
|
|
424
435
|
>
|
|
425
436
|
<span className={css.detailChevron} data-open={open} aria-hidden>▸</span>
|
|
426
|
-
{renderInline(String(cell), false)}
|
|
427
437
|
</button>
|
|
438
|
+
<span className={css.detailContent} onClick={event => {
|
|
439
|
+
if (event.target instanceof Element && event.target.closest('button')) return
|
|
440
|
+
toggleDetail(child.index)
|
|
441
|
+
}}>
|
|
442
|
+
{renderInline(String(cell), 'file')}
|
|
443
|
+
</span>
|
|
428
444
|
</td>
|
|
429
445
|
)
|
|
430
446
|
: renderCell(cell, j, child.index)
|
|
@@ -9,6 +9,8 @@ import * as primitives from '../primitive-adapter.ts'
|
|
|
9
9
|
import css from '../GenuiBlock.module.css'
|
|
10
10
|
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
11
11
|
import { renderInline } from '../inline.ts'
|
|
12
|
+
import { classifyLinkTarget } from '../file-link.ts'
|
|
13
|
+
import { FileLink } from '../file-link-context.tsx'
|
|
12
14
|
import { t } from '../i18n/index.ts'
|
|
13
15
|
import type { GenuiList, GenuiNode } from '../spec.ts'
|
|
14
16
|
import type { AnswersState, GenuiBlockProps } from './state.ts'
|
|
@@ -283,13 +285,14 @@ export function renderNode(
|
|
|
283
285
|
case 'select': return <SelectNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
284
286
|
case 'checkbox': return <CheckboxNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
285
287
|
case 'link': {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
288
|
+
const target = classifyLinkTarget(node.href)
|
|
289
|
+
if (target.type === 'external') {
|
|
290
|
+
return <a key={key} className={css.link} href={target.href} target="_blank" rel="noopener noreferrer">{renderInline(node.label, false)}</a>
|
|
291
|
+
}
|
|
292
|
+
if (target.type === 'file') {
|
|
293
|
+
return <FileLink key={key} path={target.file.path} label={node.label} {...(target.file.line === undefined ? {} : { line: target.file.line })}>{renderInline(node.label, false)}</FileLink>
|
|
294
|
+
}
|
|
295
|
+
return <span key={key} className={css.linkText}>{renderInline(node.label, false)}</span>
|
|
293
296
|
}
|
|
294
297
|
case 'image': return <ImageNode key={`${key}:${node.src}`} node={node} />
|
|
295
298
|
case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -58,6 +58,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
58
58
|
import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
|
|
59
59
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
60
60
|
import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
|
|
61
|
+
import { withFileLinkContext, type FileLinkAvailability, type FileLinkCordisContext } from './file-link-context.tsx'
|
|
61
62
|
import css from './GenuiBlock.module.css'
|
|
62
63
|
import { renderSvgFence } from './svg-fence.tsx'
|
|
63
64
|
import { repairFenceJson, trimToBalancedRoot } from '../shared/fence-repair.ts'
|
|
@@ -68,13 +69,27 @@ import { diagnoseUnknownGenuiFields } from './genui-runtime/diagnostics.ts'
|
|
|
68
69
|
import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
|
|
69
70
|
import { sourceFencesOfAssistant, sourceLanguageAt } from './source-fence.ts'
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
* (`.md-code-block`) and the deepsuite-style surfaces some host builds
|
|
74
|
-
* render instead (`.code-block` / `.code-block-small`). Surfaces with an
|
|
75
|
-
* unlisted class are still found by the structural backstop (label + `<pre>`),
|
|
76
|
-
* so this list is an optimization, not a hard contract. */
|
|
72
|
+
/** 优先用宿主代码块内容的语义标记定位包装节点;隐藏语言或改变类名不能阻断
|
|
73
|
+
* 原始消息的围栏识别。类名也用于仍以这些表面呈现的代码块。 */
|
|
77
74
|
const CODE_BLOCK_SELECTORS = '.md-code-block, .code-block, .code-block-small'
|
|
75
|
+
|
|
76
|
+
/** 在同一处收集代码块包装节点,供候选扫描和原始围栏序号共用。 */
|
|
77
|
+
function codeBlockSurfaces(scope: ParentNode): HTMLElement[] {
|
|
78
|
+
const surfaces = new Set(scope.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTORS))
|
|
79
|
+
for (const content of scope.querySelectorAll<HTMLElement>('[data-code-block-content]')) {
|
|
80
|
+
if (content.parentElement !== null) surfaces.add(content.parentElement)
|
|
81
|
+
}
|
|
82
|
+
return [...surfaces].sort((left, right) => left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 返回拥有代码内容语义节点、或已知代码块类名的最近包装节点。 */
|
|
86
|
+
function closestCodeBlockSurface(element: Element | null): Element | null {
|
|
87
|
+
for (let current = element; current !== null; current = current.parentElement) {
|
|
88
|
+
if (current.matches(CODE_BLOCK_SELECTORS)
|
|
89
|
+
|| [...current.children].some(child => child.hasAttribute('data-code-block-content'))) return current
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
78
93
|
/** Marker attribute set on blocks this channel has taken over. */
|
|
79
94
|
const PROCESSED = 'data-genui-rendered'
|
|
80
95
|
/** The settled marker on AssistantMarkdown (absent = settled). */
|
|
@@ -86,6 +101,10 @@ const DIAGNOSTIC_CLASS = 'genui-dom-fence-diagnostic'
|
|
|
86
101
|
/** Slow sweep interval: the observer catches everything, this is the 1s
|
|
87
102
|
* belt-and-braces pass (history loads, missed attribute batches). */
|
|
88
103
|
const SWEEP_MS = 1000
|
|
104
|
+
const NO_FILE_LINK_AVAILABILITY: FileLinkAvailability = {
|
|
105
|
+
subscribe: () => () => {},
|
|
106
|
+
getSnapshot: () => false,
|
|
107
|
+
}
|
|
89
108
|
|
|
90
109
|
/** Max ancestors walked from a `<pre>` to its fence surface root (banner +
|
|
91
110
|
* pre holder). Most hosts put the pre directly under the surface; some wrap
|
|
@@ -196,7 +215,7 @@ function infostringOf(block: Element): 'dsh-ui' | 'svg' | null {
|
|
|
196
215
|
// surface's banner, not `block`'s own banner. Only accept labels whose
|
|
197
216
|
// nearest known surface is `block` itself (or none — unknown surfaces
|
|
198
217
|
// stay supported by the structural backstop).
|
|
199
|
-
const owner = el
|
|
218
|
+
const owner = closestCodeBlockSurface(el)
|
|
200
219
|
if (owner !== null && owner !== block) continue
|
|
201
220
|
return lang
|
|
202
221
|
}
|
|
@@ -350,11 +369,11 @@ function implausibleLabeledAncestorOf(pre: HTMLElement, scope: ParentNode = docu
|
|
|
350
369
|
function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
|
|
351
370
|
const seen = new Set<HTMLElement>()
|
|
352
371
|
const out: HTMLElement[] = []
|
|
353
|
-
for (const el of scope
|
|
372
|
+
for (const el of codeBlockSurfaces(scope)) {
|
|
354
373
|
// Modifier classes can sit inside a surface (e.g. a `code-block-small`
|
|
355
374
|
// child of `code-block`): only the outermost matching element is a
|
|
356
375
|
// candidate, so a fence is never double-counted or taken over twice.
|
|
357
|
-
if (el.parentElement
|
|
376
|
+
if (closestCodeBlockSurface(el.parentElement) !== null) continue
|
|
358
377
|
if (el.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui-svg-fence]`) !== null) continue
|
|
359
378
|
if (seen.has(el)) continue
|
|
360
379
|
// Message-level containers that happen to carry a surface class must
|
|
@@ -373,7 +392,8 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
|
|
|
373
392
|
// markdown root holding both a dsh-ui fence and a python block — and
|
|
374
393
|
// the backstop would mislabel that whole container as a fence, hiding
|
|
375
394
|
// every other code block with it (issue #13).
|
|
376
|
-
if (pre
|
|
395
|
+
if (closestCodeBlockSurface(pre) !== null
|
|
396
|
+
|| pre.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui-svg-fence]`) !== null) continue
|
|
377
397
|
const surface = surfaceOf(pre, scope)
|
|
378
398
|
if (surface === null) {
|
|
379
399
|
// Diagnose the issue #19 guard: a labeled ancestor that is NOT a code
|
|
@@ -394,7 +414,8 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
|
|
|
394
414
|
seen.add(surface)
|
|
395
415
|
}
|
|
396
416
|
for (const span of scope.querySelectorAll<HTMLElement>('span')) {
|
|
397
|
-
if (span
|
|
417
|
+
if (closestCodeBlockSurface(span) !== null
|
|
418
|
+
|| span.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]`) !== null) continue
|
|
398
419
|
if (tagTextBodyOf(span) === null) continue
|
|
399
420
|
out.push(span)
|
|
400
421
|
}
|
|
@@ -476,8 +497,8 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
|
|
|
476
497
|
|
|
477
498
|
/** Return outer host Markdown code surfaces in one assistant row's DOM order. */
|
|
478
499
|
function hostFenceBlocksOf(row: Element): HTMLElement[] {
|
|
479
|
-
return
|
|
480
|
-
if (candidate.parentElement
|
|
500
|
+
return codeBlockSurfaces(row).filter(candidate => {
|
|
501
|
+
if (closestCodeBlockSurface(candidate.parentElement) !== null) return false
|
|
481
502
|
if (candidate.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, [data-genui], [data-genui-svg-fence]`) !== null) return false
|
|
482
503
|
if (candidate.closest('[data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]') !== null) return false
|
|
483
504
|
if (candidate.closest(ASSISTANT_FLOW_ROW) !== row) return false
|
|
@@ -504,13 +525,18 @@ export function sourceLanguageOf(ctx: Context, block: Element): string | null |
|
|
|
504
525
|
}
|
|
505
526
|
|
|
506
527
|
/** Cache each row's Markdown parse for one sweep and retain confirmed opening-line languages per host block. */
|
|
507
|
-
function createSourceLanguageResolver(ctx: Context): {
|
|
528
|
+
function createSourceLanguageResolver(ctx: Context): {
|
|
529
|
+
beginSweep: () => void
|
|
530
|
+
get: (block: Element) => string | null | undefined
|
|
531
|
+
isSessionUnavailable: () => boolean
|
|
532
|
+
} {
|
|
508
533
|
const stableLanguages = new WeakMap<Element, { sessionId: SessionId; nodeKey: string; language: string | null }>()
|
|
509
534
|
let sweepLanguages = new Map<Element, string | null | undefined>()
|
|
510
535
|
let parsedNodes = new Map<string, ReturnType<typeof sourceFencesOfAssistant>>()
|
|
511
536
|
let sessionId: SessionId | undefined
|
|
512
537
|
let chat: ChatSnapshot | undefined
|
|
513
538
|
let chatRead = false
|
|
539
|
+
let sessionUnavailable = false
|
|
514
540
|
|
|
515
541
|
return {
|
|
516
542
|
beginSweep() {
|
|
@@ -519,13 +545,23 @@ function createSourceLanguageResolver(ctx: Context): { beginSweep: () => void; g
|
|
|
519
545
|
sessionId = undefined
|
|
520
546
|
chat = undefined
|
|
521
547
|
chatRead = false
|
|
548
|
+
sessionUnavailable = false
|
|
549
|
+
},
|
|
550
|
+
isSessionUnavailable() {
|
|
551
|
+
return sessionUnavailable
|
|
522
552
|
},
|
|
523
553
|
get(block) {
|
|
524
554
|
if (sweepLanguages.has(block)) return sweepLanguages.get(block)
|
|
525
555
|
const row = block.closest<HTMLElement>(`${ASSISTANT_FLOW_ROW}[data-chat-node-key]`)
|
|
526
556
|
if (row === null || row.dataset.chatGroupPart === 'reasoning') return undefined
|
|
527
557
|
const nodeKey = row.dataset.chatNodeKey
|
|
528
|
-
const
|
|
558
|
+
const sourceSession = sourceSessionOf(ctx)
|
|
559
|
+
if (!sourceSession.available) {
|
|
560
|
+
sessionUnavailable = true
|
|
561
|
+
sweepLanguages.set(block, undefined)
|
|
562
|
+
return undefined
|
|
563
|
+
}
|
|
564
|
+
const activeSessionId = sourceSession.sessionId
|
|
529
565
|
if (!nodeKey || activeSessionId === undefined) return undefined
|
|
530
566
|
|
|
531
567
|
const stable = stableLanguages.get(block)
|
|
@@ -570,9 +606,19 @@ export function hostFenceIndexOf(row: Element, block: Element): number {
|
|
|
570
606
|
return hostFenceBlocksOf(row).indexOf(block as HTMLElement)
|
|
571
607
|
}
|
|
572
608
|
|
|
609
|
+
/** Read the viewed session and report when Cordis rejects access to its Context. */
|
|
610
|
+
function sourceSessionOf(ctx: Context): { available: true; sessionId: SessionId | undefined } | { available: false } {
|
|
611
|
+
try {
|
|
612
|
+
return { available: true, sessionId: resolveViewedSessionId(ctx.sessions.list.getSnapshot()) }
|
|
613
|
+
} catch {
|
|
614
|
+
return { available: false }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
573
618
|
/** Read the currently viewed session for source Markdown lookup. */
|
|
574
619
|
function sessionIdOfForSource(ctx: Context): SessionId | undefined {
|
|
575
|
-
|
|
620
|
+
const sourceSession = sourceSessionOf(ctx)
|
|
621
|
+
return sourceSession.available ? sourceSession.sessionId : undefined
|
|
576
622
|
}
|
|
577
623
|
|
|
578
624
|
/** Read uiConversation as an optional service so Cordis does not require a hard inject. */
|
|
@@ -650,6 +696,7 @@ function anchorSeqOf(row: Element): number {
|
|
|
650
696
|
export function installDomFenceRenderer(
|
|
651
697
|
ctx: Context,
|
|
652
698
|
sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void,
|
|
699
|
+
fileLinkAvailability: FileLinkAvailability = NO_FILE_LINK_AVAILABILITY,
|
|
653
700
|
): () => void {
|
|
654
701
|
if (typeof document === 'undefined') return () => {}
|
|
655
702
|
driftWarned = false
|
|
@@ -665,12 +712,27 @@ export function installDomFenceRenderer(
|
|
|
665
712
|
let unsubscribeChat: (() => void) | undefined
|
|
666
713
|
const sourceLanguages = createSourceLanguageResolver(ctx)
|
|
667
714
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
715
|
+
/** Apply both plugin contexts to every actual GenUI root render. */
|
|
716
|
+
function wrapFenceNode(node: ReactNode, context: GenuiFenceContext, handler: GenuiActionHandler, streaming: boolean): ReactNode {
|
|
717
|
+
const actionTree = <GenuiActionContext.Provider value={handler}>{node}</GenuiActionContext.Provider>
|
|
718
|
+
return withFileLinkContext(
|
|
719
|
+
ctx as unknown as FileLinkCordisContext,
|
|
720
|
+
fileLinkAvailability,
|
|
721
|
+
context.sessionId,
|
|
722
|
+
streaming,
|
|
723
|
+
actionTree,
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const sessionIdOf = (): SessionId | undefined => sessionIdOfForSource(ctx)
|
|
728
|
+
let lastAvailableSessionId = sessionIdOf()
|
|
729
|
+
|
|
730
|
+
/** Keep a mounted fence's React identity stable while Cordis temporarily denies session access. */
|
|
731
|
+
function sessionIdForRender(preserveWhenUnavailable: boolean): SessionId | undefined {
|
|
732
|
+
const sourceSession = sourceSessionOf(ctx)
|
|
733
|
+
if (!sourceSession.available) return preserveWhenUnavailable ? lastAvailableSessionId : undefined
|
|
734
|
+
lastAvailableSessionId = sourceSession.sessionId
|
|
735
|
+
return sourceSession.sessionId
|
|
674
736
|
}
|
|
675
737
|
|
|
676
738
|
/** Retry ChatSnapshot subscription while the active session binding is unavailable. */
|
|
@@ -688,7 +750,7 @@ export function installDomFenceRenderer(
|
|
|
688
750
|
/** Render context for a block: session always; the stable source identity
|
|
689
751
|
* only once settled — streaming renders are identity-less (no panel
|
|
690
752
|
* publish, no durable state), mirroring the registry channel. */
|
|
691
|
-
function contextOf(row: Element, block: Element, settled: boolean): { key: Key; context: GenuiFenceContext } {
|
|
753
|
+
function contextOf(row: Element, block: Element, settled: boolean, preserveSessionIdentity = false): { key: Key; context: GenuiFenceContext } {
|
|
692
754
|
if (settled && row.getAttribute('data-chat-anchor-key') === null) {
|
|
693
755
|
// Safari / fallback render path: the host omitted the row anchor (the
|
|
694
756
|
// attribute is a React key that React drops when undefined). Fences
|
|
@@ -699,7 +761,7 @@ export function installDomFenceRenderer(
|
|
|
699
761
|
const fenceIndex = fenceIndexOf(ctx, row, block, sourceLanguages.get)
|
|
700
762
|
const anchorKey = row.getAttribute('data-chat-anchor-key') ?? 'unknown'
|
|
701
763
|
const key = `dom:${anchorKey}:${fenceIndex}` as Key
|
|
702
|
-
const sessionId =
|
|
764
|
+
const sessionId = sessionIdForRender(preserveSessionIdentity)
|
|
703
765
|
const context: GenuiFenceContext = {
|
|
704
766
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
705
767
|
...(settled ? { source: { id: key as string, order: [anchorSeqOf(row), 0, fenceIndex] as const } } : {}),
|
|
@@ -868,7 +930,7 @@ export function installDomFenceRenderer(
|
|
|
868
930
|
}
|
|
869
931
|
try {
|
|
870
932
|
const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
|
|
871
|
-
root.render(
|
|
933
|
+
root.render(wrapFenceNode(payload, context, handler, !settled))
|
|
872
934
|
} catch (error) {
|
|
873
935
|
try {
|
|
874
936
|
root.unmount()
|
|
@@ -941,7 +1003,9 @@ export function installDomFenceRenderer(
|
|
|
941
1003
|
function sweep(): void {
|
|
942
1004
|
if (disposed) return
|
|
943
1005
|
sourceLanguages.beginSweep()
|
|
944
|
-
const
|
|
1006
|
+
const sourceSession = sourceSessionOf(ctx)
|
|
1007
|
+
const sessionId = sourceSession.available ? sourceSession.sessionId : undefined
|
|
1008
|
+
if (sourceSession.available) lastAvailableSessionId = sourceSession.sessionId
|
|
945
1009
|
syncChatSubscription(sessionId)
|
|
946
1010
|
for (const [block, mount] of mounts) {
|
|
947
1011
|
if (!block.isConnected) {
|
|
@@ -953,13 +1017,15 @@ export function installDomFenceRenderer(
|
|
|
953
1017
|
const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
|
|
954
1018
|
const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
|
|
955
1019
|
const language = domLanguage ?? sourceLanguage
|
|
1020
|
+
const sourceUnavailable = domLanguage === null
|
|
1021
|
+
&& (!sourceSession.available || sourceLanguages.isSessionUnavailable())
|
|
956
1022
|
const validGenui = language === 'dsh-ui'
|
|
957
1023
|
|| (language === undefined && settled && isGenericGenuiFence(block, raw))
|
|
958
1024
|
if (mount.language === 'svg' && language !== 'svg') {
|
|
959
1025
|
unmountBlock(block)
|
|
960
1026
|
continue
|
|
961
1027
|
}
|
|
962
|
-
if (mount.language === 'dsh-ui' && !validGenui) {
|
|
1028
|
+
if (mount.language === 'dsh-ui' && !validGenui && !sourceUnavailable) {
|
|
963
1029
|
unmountBlock(block)
|
|
964
1030
|
continue
|
|
965
1031
|
}
|
|
@@ -970,7 +1036,7 @@ export function installDomFenceRenderer(
|
|
|
970
1036
|
const contentWiped = !isPanelRoot(mount.lastNode) && mount.container.childElementCount === 0
|
|
971
1037
|
if (mount.lastRaw !== raw || mount.lastSettled !== settled || contentWiped) {
|
|
972
1038
|
const anchor = rowOf(block)
|
|
973
|
-
const { key, context } = contextOf(anchor, block, settled)
|
|
1039
|
+
const { key, context } = contextOf(anchor, block, settled, true)
|
|
974
1040
|
const node = mount.language === 'svg' ? renderSvgFence(raw, key) : renderResolvedFenceNode(raw, key, context)
|
|
975
1041
|
if (node === null) {
|
|
976
1042
|
if (mount.skeleton && !settled) {
|
|
@@ -1002,7 +1068,7 @@ export function installDomFenceRenderer(
|
|
|
1002
1068
|
try {
|
|
1003
1069
|
const freshRoot = domRootFactory(fresh)
|
|
1004
1070
|
const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
|
|
1005
|
-
freshRoot.render(
|
|
1071
|
+
freshRoot.render(wrapFenceNode(node, context, handler, !settled))
|
|
1006
1072
|
mount.root = freshRoot
|
|
1007
1073
|
mount.container = fresh
|
|
1008
1074
|
} catch (error) {
|
|
@@ -1017,7 +1083,8 @@ export function installDomFenceRenderer(
|
|
|
1017
1083
|
}
|
|
1018
1084
|
} else {
|
|
1019
1085
|
try {
|
|
1020
|
-
|
|
1086
|
+
const handler: GenuiActionHandler = (action, payload) => sendActionForBlock(block, action, payload)
|
|
1087
|
+
mount.root.render(wrapFenceNode(node, context, handler, !settled))
|
|
1021
1088
|
} catch (error) {
|
|
1022
1089
|
// Never leave the stock block hidden behind a broken root: restore
|
|
1023
1090
|
// the raw code block and drop the mount (issue #19).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
|
|
2
|
+
|
|
3
|
+
/** Encode one address segment while keeping a Windows drive colon readable. */
|
|
4
|
+
function encodeSegment(segment: string): string {
|
|
5
|
+
return encodeURIComponent(segment).replace(/%3A/gi, ':')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Encode each path segment independently so separators retain their structure. */
|
|
9
|
+
function encodePath(path: string): string {
|
|
10
|
+
return path.split('/').map(encodeSegment).join('/')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Check whether a path uses a Windows drive or UNC spelling. */
|
|
14
|
+
function isWindowsStylePath(value: string): boolean {
|
|
15
|
+
return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Check whether a path is absolute in a spelling accepted by the host. */
|
|
19
|
+
function isAbsoluteWorkspacePath(path: string): boolean {
|
|
20
|
+
return path.startsWith('/') || isWindowsStylePath(path)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Build a session resource address while preserving encoded path segments. */
|
|
24
|
+
export function sessionFileAddress(sessionId: string, path: string): string {
|
|
25
|
+
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
|
|
26
|
+
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolve workspace absolute paths to session-relative resource addresses. */
|
|
30
|
+
export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string {
|
|
31
|
+
const normalized = path.replace(/\\/g, '/')
|
|
32
|
+
if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized)
|
|
33
|
+
const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
34
|
+
if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
|
|
35
|
+
if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
|
|
36
|
+
return sessionFileAddress(sessionId, normalized)
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useMemo, useSyncExternalStore, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
2
|
+
import { fileAddressFor } from './file-address.ts'
|
|
3
|
+
import { resolveViewedSessionId } from './session-resolver.ts'
|
|
4
|
+
import css from './GenuiBlock.module.css'
|
|
5
|
+
|
|
6
|
+
interface SidebarRightService {
|
|
7
|
+
openResource(address: string, options?: { params: { line: number } }): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FileLinkHostContext {
|
|
11
|
+
get(name: 'sidebarRight', strict?: false): SidebarRightService | undefined
|
|
12
|
+
sessions: {
|
|
13
|
+
list: { getSnapshot(): unknown }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FileLinkScope {
|
|
18
|
+
get(name: 'sidebarRight'): SidebarRightService | undefined
|
|
19
|
+
effect(callback: () => void | (() => void), name?: string): unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileLinkCordisContext extends FileLinkHostContext {
|
|
23
|
+
inject(services: ['sidebarRight'], callback: (scope: FileLinkScope) => void): unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FileLinkAvailability {
|
|
27
|
+
subscribe(listener: () => void): () => void
|
|
28
|
+
getSnapshot(): boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FileLinkOpenContext = createContext<((path: string, line?: number) => void) | undefined>(undefined)
|
|
32
|
+
|
|
33
|
+
/** Track an optional host service through arrival, removal, and plugin unload. */
|
|
34
|
+
export function createFileLinkAvailability(ctx: FileLinkCordisContext): FileLinkAvailability {
|
|
35
|
+
let available = ctx.get('sidebarRight', false)?.openResource !== undefined
|
|
36
|
+
const listeners = new Set<() => void>()
|
|
37
|
+
const notify = (): void => {
|
|
38
|
+
for (const listener of listeners) listener()
|
|
39
|
+
}
|
|
40
|
+
const setAvailable = (next: boolean): void => {
|
|
41
|
+
if (available === next) return
|
|
42
|
+
available = next
|
|
43
|
+
notify()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ctx.inject(['sidebarRight'], (scope) => {
|
|
47
|
+
scope.effect(() => {
|
|
48
|
+
setAvailable(scope.get('sidebarRight')?.openResource !== undefined)
|
|
49
|
+
return () => setAvailable(false)
|
|
50
|
+
}, 'genui: file-link sidebarRight')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
subscribe(listener) {
|
|
55
|
+
listeners.add(listener)
|
|
56
|
+
return () => { listeners.delete(listener) }
|
|
57
|
+
},
|
|
58
|
+
getSnapshot: () => available,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Provide one fence's session-scoped file opener to descendant components. */
|
|
63
|
+
function FileLinkProvider({ ctx, availability, sessionId, streaming, children }: {
|
|
64
|
+
ctx: FileLinkHostContext
|
|
65
|
+
availability: FileLinkAvailability
|
|
66
|
+
sessionId: string | undefined
|
|
67
|
+
streaming: boolean
|
|
68
|
+
children?: ReactNode
|
|
69
|
+
}) {
|
|
70
|
+
const available = useSyncExternalStore(availability.subscribe, availability.getSnapshot, () => false)
|
|
71
|
+
const open = useMemo(() => {
|
|
72
|
+
if (!available || streaming || sessionId === undefined) return undefined
|
|
73
|
+
return (path: string, line?: number): void => {
|
|
74
|
+
const snapshot = ctx.sessions.list.getSnapshot()
|
|
75
|
+
if (resolveViewedSessionId(snapshot) !== sessionId) return
|
|
76
|
+
const sessions = snapshot as { byId?: Record<string, { cwd?: string } | undefined> }
|
|
77
|
+
const cwd = sessions.byId?.[sessionId]?.cwd
|
|
78
|
+
const address = fileAddressFor(sessionId, cwd, path)
|
|
79
|
+
const sidebar = ctx.get('sidebarRight', false)
|
|
80
|
+
if (sidebar?.openResource === undefined) return
|
|
81
|
+
if (line === undefined) sidebar.openResource(address)
|
|
82
|
+
else sidebar.openResource(address, { params: { line } })
|
|
83
|
+
}
|
|
84
|
+
}, [available, ctx, sessionId, streaming])
|
|
85
|
+
|
|
86
|
+
return createElement(FileLinkOpenContext.Provider, { value: open }, children)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Render a file link as an accessible host navigation button when available. */
|
|
90
|
+
export function FileLink({ path, line, label, children }: { path: string; line?: number; label: string; children?: ReactNode }) {
|
|
91
|
+
const open = useContext(FileLinkOpenContext)
|
|
92
|
+
if (open === undefined) return createElement('span', { title: path }, children)
|
|
93
|
+
return createElement('button', {
|
|
94
|
+
type: 'button',
|
|
95
|
+
className: css.inlineFileLink,
|
|
96
|
+
title: path,
|
|
97
|
+
'aria-label': `${label} (${path})`,
|
|
98
|
+
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
open(path, line)
|
|
101
|
+
},
|
|
102
|
+
}, children)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Add session-scoped file navigation to one fence render tree. */
|
|
106
|
+
export function withFileLinkContext(
|
|
107
|
+
ctx: FileLinkHostContext,
|
|
108
|
+
availability: FileLinkAvailability,
|
|
109
|
+
sessionId: string | undefined,
|
|
110
|
+
streaming: boolean,
|
|
111
|
+
children: ReactNode,
|
|
112
|
+
): ReactNode {
|
|
113
|
+
return createElement(FileLinkProvider, { ctx, availability, sessionId, streaming, children })
|
|
114
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { safeHref } from './genui-runtime/value-utils.ts'
|
|
2
|
+
|
|
3
|
+
export interface ParsedFileLink {
|
|
4
|
+
path: string
|
|
5
|
+
line?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type LinkTarget =
|
|
9
|
+
| { type: 'external'; href: string }
|
|
10
|
+
| { type: 'file'; file: ParsedFileLink }
|
|
11
|
+
| { type: 'invalid' }
|
|
12
|
+
|
|
13
|
+
/** Decode a local file destination and its optional first line fragment. */
|
|
14
|
+
export function parseFileLink(value: string): ParsedFileLink | undefined {
|
|
15
|
+
if (value.length > 2048) return undefined
|
|
16
|
+
const hash = value.indexOf('#')
|
|
17
|
+
const destination = hash < 0 ? value : value.slice(0, hash)
|
|
18
|
+
if (destination.includes('?')) return undefined
|
|
19
|
+
|
|
20
|
+
let path: string
|
|
21
|
+
try {
|
|
22
|
+
path = decodeURIComponent(destination)
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (path === '' || /[\u0000-\u001f\u007f]/.test(path) || /^[\\/]{2}/.test(path)
|
|
28
|
+
|| (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined
|
|
29
|
+
if (hash < 0) return { path }
|
|
30
|
+
|
|
31
|
+
const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(value.slice(hash + 1))
|
|
32
|
+
if (match === null) return undefined
|
|
33
|
+
const line = Number(match[1])
|
|
34
|
+
const end = match[2] === undefined ? line : Number(match[2])
|
|
35
|
+
if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined
|
|
36
|
+
return { path, line }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Classify a link destination while preserving the existing external URL policy. */
|
|
40
|
+
export function classifyLinkTarget(value: unknown): LinkTarget {
|
|
41
|
+
const external = safeHref(value)
|
|
42
|
+
if (external !== undefined) return { type: 'external', href: external }
|
|
43
|
+
if (typeof value !== 'string') return { type: 'invalid' }
|
|
44
|
+
const file = parseFileLink(value.trim())
|
|
45
|
+
return file === undefined ? { type: 'invalid' } : { type: 'file', file }
|
|
46
|
+
}
|
package/src/client/guard.ts
CHANGED
|
@@ -33,7 +33,8 @@ import type { GenuiDiagnostic } from './genui-runtime/diagnostics.ts'
|
|
|
33
33
|
import { GENUI_LIMITS } from './genui-runtime/limits.ts'
|
|
34
34
|
import { analyzeSubmissionRegistry } from './submission-registry.ts'
|
|
35
35
|
import { isTableDetailReachable, prepareTableRows, tableRowsForDetails } from './table-details.ts'
|
|
36
|
-
import { color, enu, int, num, obj, opt,
|
|
36
|
+
import { color, enu, int, num, obj, opt, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
|
|
37
|
+
import { classifyLinkTarget } from './file-link.ts'
|
|
37
38
|
|
|
38
39
|
/** Result of `validateGenuiSpec`. */
|
|
39
40
|
export interface GenuiValidation {
|
|
@@ -336,7 +337,9 @@ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiN
|
|
|
336
337
|
case 'link': {
|
|
337
338
|
const label = str(v.label, GENUI_LIMITS.maxString)
|
|
338
339
|
if (label === undefined) return null
|
|
339
|
-
|
|
340
|
+
const target = classifyLinkTarget(v.href)
|
|
341
|
+
const href = target.type === 'external' ? target.href : target.type === 'file' ? String(v.href).trim() : undefined
|
|
342
|
+
return { type: 'link', label, ...opt('href', href) }
|
|
340
343
|
}
|
|
341
344
|
case 'image': {
|
|
342
345
|
const src = safeMediaSrc(v.src)
|