hermes-agent/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx

153 lines
4.3 KiB
TypeScript

import type { FC } from 'react'
import { Fragment, useMemo } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { cn } from '@/lib/utils'
// User messages should render the bare-minimum of markdown: backtick `code`
// spans and ``` fenced blocks. We deliberately don't pull in the full
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
// because user input rarely contains structured docs and the heavy pipeline
// adds a lot of runtime cost per bubble.
//
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
// inside the plain-text segments.
interface FenceSegment {
kind: 'fence'
code: string
lang: string | null
}
interface InlineSegment {
kind: 'inline'
text: string
}
interface InlineCodeSegment {
kind: 'inline-code'
code: string
}
interface InlineTextSegment {
kind: 'inline-text'
text: string
}
type TopSegment = FenceSegment | InlineSegment
type InlineNode = InlineCodeSegment | InlineTextSegment
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
// Greedy backtick run length so ``code with `backticks` inside`` works.
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
function splitFences(text: string): TopSegment[] {
const segments: TopSegment[] = []
let cursor = 0
for (const match of text.matchAll(FENCE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
}
segments.push({
kind: 'fence',
lang: (match[1] || '').trim() || null,
code: match[2] ?? ''
})
cursor = start + match[0].length
}
if (cursor < text.length) {
segments.push({ kind: 'inline', text: text.slice(cursor) })
}
return segments
}
function splitInlineCode(text: string): InlineNode[] {
const nodes: InlineNode[] = []
let cursor = 0
for (const match of text.matchAll(INLINE_CODE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
}
nodes.push({ kind: 'inline-code', code: match[2] })
cursor = start + match[0].length
}
if (cursor < text.length) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
}
return nodes
}
interface UserMessageTextProps {
text: string
className?: string
}
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
const top = useMemo(() => splitFences(text), [text])
return (
<span className={cn('block', className)} data-slot="aui_user-message-text">
{top.map((segment, segmentIndex) => {
if (segment.kind === 'fence') {
return (
<pre
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
data-slot="aui_user-fence"
key={`fence-${segmentIndex}`}
>
<code className="block whitespace-pre">{segment.code}</code>
</pre>
)
}
return (
<Fragment key={`inline-${segmentIndex}`}>
<InlineSegmentView text={segment.text} />
</Fragment>
)
})}
</span>
)
}
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
// UAX#9 paragraph so it resolves direction independently.
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
data-slot="aui_user-inline-code"
key={`code-${nodeIndex}`}
>
{node.code}
</code>
) : (
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
// still render. DirectiveContent already preserves whitespace.
<Fragment key={`text-${nodeIndex}`}>
<DirectiveContent text={node.text} />
</Fragment>
)
)}
</span>
)
}