A multi-MB message (logged bundle, huge tool dump) froze the renderer before any paint: Streamdown runs `preprocess` + `marked` lex over the whole string synchronously in a useMemo, an uninterruptible long task that no try/catch or content-visibility can help (our JS runs before the browser ever skips layout). Tiered fix: - Message gate: past 200KB, bypass markdown entirely and render the raw text in `content-visibility:auto` line-chunks — synchronous work is bounded to a string split, the browser virtualizes layout natively, and every line stays in the DOM (selectable, find-in-page). - Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a span per token) and render plain, chunked the same way. - Collapse/expand: a reusable ExpandableBlock clamps code blocks and the huge-text fallback to a 120px preview with a gradient + chevron, expanding to 300px. The inner element is always a scroll container so the content-visibility chunks stay lazily laid out in both states. No content is ever dropped; the copy button (card header) always yields the full block.
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
|
||
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
|
||
import type { FC } from 'react'
|
||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||
|
||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
|
||
|
||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||
|
||
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
|
||
* Used both by the rendered <DirectiveIcon> and the raw SVG markup the
|
||
* contenteditable composer embeds via `directiveIconSvg`. */
|
||
const ICON_PATHS: Record<HermesRefType, string[]> = {
|
||
file: [
|
||
'M14 3v4a1 1 0 0 0 1 1h4',
|
||
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
|
||
'M9 9l1 0',
|
||
'M9 13l6 0',
|
||
'M9 17l6 0'
|
||
],
|
||
folder: [
|
||
'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2'
|
||
],
|
||
url: [
|
||
'M9 15l6 -6',
|
||
'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
|
||
'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'
|
||
],
|
||
image: [
|
||
'M15 8h.01',
|
||
'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12',
|
||
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
|
||
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
|
||
],
|
||
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||
session: [
|
||
'M8 9h8',
|
||
'M8 13h6',
|
||
'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z'
|
||
]
|
||
}
|
||
|
||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||
|
||
const SVG_ATTRS =
|
||
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
||
|
||
const iconPathsFor = (type: string) => ICON_PATHS[type as HermesRefType] ?? ICON_FALLBACK
|
||
|
||
/** SVG markup string for embedding directly in HTML (composer contenteditable). */
|
||
export function directiveIconSvg(type: string) {
|
||
const inner = iconPathsFor(type)
|
||
.map(d => `<path d="${d}"/>`)
|
||
.join('')
|
||
|
||
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
|
||
}
|
||
|
||
function iconElementFromPaths(paths: string[]) {
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
|
||
svg.setAttribute('fill', 'none')
|
||
svg.setAttribute('stroke', 'currentColor')
|
||
svg.setAttribute('stroke-linecap', 'round')
|
||
svg.setAttribute('stroke-linejoin', 'round')
|
||
svg.setAttribute('stroke-width', '2')
|
||
svg.setAttribute('viewBox', '0 0 24 24')
|
||
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||
|
||
for (const d of paths) {
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||
path.setAttribute('d', d)
|
||
svg.append(path)
|
||
}
|
||
|
||
return svg
|
||
}
|
||
|
||
export function directiveIconElement(type: string) {
|
||
return iconElementFromPaths(iconPathsFor(type))
|
||
}
|
||
|
||
/** Per-type slash-command pill styling. The composer inserts these chips when a
|
||
* command is picked; the kind drives a theme-aware accent so commands, skills,
|
||
* and themes read distinctly (Cursor-style). */
|
||
export type SlashChipKind = 'command' | 'skill' | 'theme'
|
||
|
||
const SLASH_ICON_PATHS: Record<SlashChipKind, string[]> = {
|
||
command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||
skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
|
||
theme: [
|
||
'M3 21v-4a4 4 0 1 1 4 4h-4',
|
||
'M21 3a16 16 0 0 0 -12.8 10.2',
|
||
'M21 3a16 16 0 0 1 -10.2 12.8',
|
||
'M10.6 9a9 9 0 0 1 4.4 4.4'
|
||
]
|
||
}
|
||
|
||
const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
|
||
command:
|
||
'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
|
||
skill:
|
||
'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
|
||
theme:
|
||
'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
|
||
}
|
||
|
||
export const SLASH_CHIP_BASE_CLASS =
|
||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
|
||
|
||
export function slashChipClass(kind: SlashChipKind): string {
|
||
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
|
||
}
|
||
|
||
export function slashIconElement(kind: SlashChipKind) {
|
||
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
|
||
}
|
||
|
||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||
<svg
|
||
className="size-3 shrink-0 opacity-80"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
viewBox="0 0 24 24"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
{iconPathsFor(type).map(d => (
|
||
<path d={d} key={d} />
|
||
))}
|
||
</svg>
|
||
)
|
||
|
||
/** Shared chip styling — used by both the rendered <DirectiveChip> and the
|
||
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
|
||
* muted-foreground text so chips read as quiet tags on any bubble color. */
|
||
export const DIRECTIVE_CHIP_CLASS =
|
||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||
|
||
/**
|
||
* Parses our composer's `@type:value` references into directive segments
|
||
* so they render as inline chips in user messages instead of raw text.
|
||
*
|
||
* Supported types: file, folder, url, image. Anything else stays plain text.
|
||
*
|
||
* Mirrors the Python `agent/context_references.REFERENCE_PATTERN` syntax:
|
||
* the value may be wrapped in backticks, single quotes, or double quotes so
|
||
* paths with spaces/parens/etc. survive parsing intact.
|
||
*/
|
||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||
|
||
const HERMES_DIRECTIVE_RE = new RegExp(
|
||
'@(file|folder|url|image|tool|line|terminal|session):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||
'g'
|
||
)
|
||
|
||
const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/
|
||
|
||
function unwrapRefValue(raw: string): string {
|
||
if (raw.length < 2) {
|
||
return raw
|
||
}
|
||
|
||
const head = raw[0]
|
||
const tail = raw[raw.length - 1]
|
||
|
||
if ((head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")) {
|
||
return raw.slice(1, -1)
|
||
}
|
||
|
||
return raw.replace(TRAILING_PUNCTUATION_RE, '')
|
||
}
|
||
|
||
function needsQuoting(value: string): boolean {
|
||
return /[\s()[\]{}<>"'`]/.test(value)
|
||
}
|
||
|
||
export function formatRefValue(value: string): string {
|
||
if (!needsQuoting(value)) {
|
||
return value
|
||
}
|
||
|
||
if (!value.includes('`')) {
|
||
return `\`${value}\``
|
||
}
|
||
|
||
if (!value.includes('"')) {
|
||
return `"${value}"`
|
||
}
|
||
|
||
if (!value.includes("'")) {
|
||
return `'${value}'`
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = {
|
||
serialize(item: Unstable_TriggerItem): string {
|
||
const metadata = item.metadata as { rawText?: unknown; insertId?: unknown } | undefined
|
||
const rawText = typeof metadata?.rawText === 'string' ? metadata.rawText : null
|
||
const insertId = typeof metadata?.insertId === 'string' ? metadata.insertId : null
|
||
|
||
// Live-completion items carry the gateway's original `text` field via metadata.
|
||
if (rawText) {
|
||
// Palette starters (`@file:` with empty value) — insert verbatim so the
|
||
// user can keep typing the path inline.
|
||
if (rawText.endsWith(':') && !insertId) {
|
||
return rawText
|
||
}
|
||
|
||
// Simple references like `@diff` / `@staged`.
|
||
if (!insertId) {
|
||
return rawText
|
||
}
|
||
|
||
// Typed references with a value — quote when needed.
|
||
const kindMatch = rawText.match(/^@([^:]+):/)
|
||
const kind = kindMatch?.[1] ?? item.type
|
||
|
||
return `@${kind}:${formatRefValue(insertId)}`
|
||
}
|
||
|
||
// Fallback for legacy callers that pass raw `id` strings.
|
||
if (item.id === `${item.type}:`) {
|
||
return `@${item.id}`
|
||
}
|
||
|
||
return `@${item.type}:${formatRefValue(item.id)}`
|
||
},
|
||
parse(text: string): readonly Unstable_DirectiveSegment[] {
|
||
return parseDirectiveText(text)
|
||
}
|
||
}
|
||
|
||
function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
|
||
const matches = [
|
||
...Array.from(text.matchAll(CANONICAL_DIRECTIVE_RE)).map(match => ({
|
||
start: match.index ?? 0,
|
||
end: (match.index ?? 0) + match[0].length,
|
||
type: match[1] || 'tool',
|
||
label: match[2] || match[3] || '',
|
||
id: match[3] || match[2] || ''
|
||
})),
|
||
...Array.from(text.matchAll(HERMES_DIRECTIVE_RE)).map(match => {
|
||
const id = unwrapRefValue(match[2] || '')
|
||
|
||
return {
|
||
start: match.index ?? 0,
|
||
end: (match.index ?? 0) + match[0].length,
|
||
type: match[1] || 'file',
|
||
label: shortLabel(match[1] as HermesRefType, id),
|
||
id
|
||
}
|
||
})
|
||
]
|
||
.filter(match => match.id)
|
||
.sort((a, b) => a.start - b.start)
|
||
|
||
const segments: Unstable_DirectiveSegment[] = []
|
||
let cursor = 0
|
||
|
||
for (const match of matches) {
|
||
if (match.start < cursor) {
|
||
continue
|
||
}
|
||
|
||
if (match.start > cursor) {
|
||
segments.push({ kind: 'text', text: text.slice(cursor, match.start) })
|
||
}
|
||
|
||
segments.push({
|
||
kind: 'mention',
|
||
type: match.type,
|
||
label: match.label,
|
||
id: match.id
|
||
})
|
||
cursor = match.end
|
||
}
|
||
|
||
if (cursor < text.length) {
|
||
segments.push({ kind: 'text', text: text.slice(cursor) })
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
function shortLabel(type: HermesRefType, id: string): string {
|
||
if (type === 'terminal') {
|
||
return id || 'terminal'
|
||
}
|
||
|
||
if (type === 'url') {
|
||
try {
|
||
const parsed = new URL(id)
|
||
|
||
return parsed.hostname || id
|
||
} catch {
|
||
return id
|
||
}
|
||
}
|
||
|
||
// `@session:<profile>/<id>` — show a short id; the composer chip carries the
|
||
// friendly title, but once sent the wire form only has the id.
|
||
if (type === 'session') {
|
||
const sid = id.split('/').filter(Boolean).pop() || id
|
||
|
||
return sid.length > 10 ? `${sid.slice(0, 8)}…` : sid
|
||
}
|
||
|
||
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||
|
||
return tail || id
|
||
}
|
||
|
||
function safeEmbeddedImages(text: string) {
|
||
try {
|
||
return extractEmbeddedImages(text)
|
||
} catch {
|
||
return { cleanedText: text, images: [] as string[] }
|
||
}
|
||
}
|
||
|
||
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
|
||
try {
|
||
return [...hermesDirectiveFormatter.parse(text)]
|
||
} catch {
|
||
return [{ kind: 'text', text }]
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
||
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
||
*/
|
||
export function DirectiveContent({ text }: { text: string }) {
|
||
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
|
||
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
|
||
|
||
return (
|
||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||
{segments.map((segment, index) =>
|
||
segment.kind === 'text' ? (
|
||
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
|
||
) : segment.type === 'image' ? (
|
||
<DirectiveImage id={segment.id} key={`img-${index}-${segment.id}`} label={segment.label} />
|
||
) : (
|
||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||
)
|
||
)}
|
||
{images.length > 0 && (
|
||
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
|
||
{images.map((src, index) => (
|
||
<ZoomableImage
|
||
alt=""
|
||
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
|
||
draggable={false}
|
||
key={`img-${index}`}
|
||
slot="aui_embedded-image"
|
||
src={src}
|
||
/>
|
||
))}
|
||
</span>
|
||
)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** assistant-ui adapter: same renderer, exposed as a TextMessagePartComponent. */
|
||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => (
|
||
<DirectiveContent text={text ?? ''} />
|
||
)
|
||
|
||
/** Image refs render as a thumbnail rather than a chip — matches how persisted
|
||
* messages render after the backend embeds the data URL, so the UX is stable
|
||
* across initial send and refresh. */
|
||
const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
|
||
const isUrl = /^(?:https?|data):/i.test(id)
|
||
const [src, setSrc] = useState<string | null>(isUrl ? id : null)
|
||
const [failed, setFailed] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (isUrl || !id) {
|
||
return
|
||
}
|
||
|
||
let alive = true
|
||
|
||
// Remote gateway: the image lives on the gateway's disk, not ours — fetch
|
||
// it over the authenticated API. Local: read it straight off this disk.
|
||
const load =
|
||
window.hermesDesktop && isRemoteGateway()
|
||
? gatewayMediaDataUrl(id)
|
||
: window.hermesDesktop?.readFileDataUrl(id)
|
||
|
||
void Promise.resolve(load)
|
||
.then(url => alive && url && setSrc(url))
|
||
.catch(() => alive && setFailed(true))
|
||
|
||
return () => {
|
||
alive = false
|
||
}
|
||
}, [id, isUrl])
|
||
|
||
if (failed) {
|
||
return <DirectiveChip id={id} label={label} type="image" />
|
||
}
|
||
|
||
if (!src) {
|
||
return (
|
||
<span
|
||
aria-hidden
|
||
className="inline-block size-12 shrink-0 animate-pulse rounded-md bg-[color-mix(in_srgb,currentColor_8%,transparent)]"
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<ZoomableImage
|
||
alt={label}
|
||
className="max-h-32 max-w-48 rounded-md border border-border/40 object-contain"
|
||
draggable={false}
|
||
slot="aui_directive-image"
|
||
src={src}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const DirectiveChip: FC<{
|
||
type: string
|
||
label: string
|
||
id: string
|
||
}> = ({ type, label, id }) => (
|
||
<span
|
||
className={DIRECTIVE_CHIP_CLASS}
|
||
data-directive-id={id}
|
||
data-directive-type={type}
|
||
data-slot="aui_directive-chip"
|
||
title={id}
|
||
>
|
||
<DirectiveIcon type={type} />
|
||
<span className="truncate">{label}</span>
|
||
</span>
|
||
)
|