Files
hermes-agent/apps/desktop/src/components/assistant-ui/directive-text.tsx
T

250 lines
7.3 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 { ComponentType, FC } from 'react'
import { Fragment, useMemo } from 'react'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
file: FileText,
folder: FolderOpen,
url: LinkIcon,
image: ImageIcon,
tool: Wrench
}
/**
* 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):(' + '`[^`\\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 === 'url') {
try {
const parsed = new URL(id)
return parsed.hostname || id
} catch {
return id
}
}
const tail = id.split(/[\\/]/).filter(Boolean).pop()
return tail || id
}
/**
* 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(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(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>
) : (
<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 ?? ''} />
)
const DirectiveChip: FC<{
type: string
label: string
id: string
}> = ({ type, label, id }) => {
const Icon = ICONS[type as HermesRefType] ?? AtSign
return (
<span
className={cn(
'mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary'
)}
data-directive-id={id}
data-directive-type={type}
data-slot="aui_directive-chip"
title={id}
>
{Icon && <Icon className="size-3 shrink-0 text-primary" />}
<span className="truncate">{label}</span>
</span>
)
}