mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
System messages (slash-command output like /debug, plus the generic system-message fallback) were rendered as plain text, so the uploaded paste.rs URLs in a debug report were neither clickable nor easily copyable. Route both through LinkifiedText so URLs become real <a> links (open externally via the desktop bridge, selectable/copyable text). Add an opt-in explicitOnly mode that matches only explicit http(s):// / www. URLs, used here so filename-shaped tokens in the report (agent.log, errors.log, gateway.log) aren't mistaken for bare domains and linkified. Bare-domain matching is preserved for all other LinkifiedText callers. Adds regression tests covering explicitOnly (links only real URLs, keeps .log filenames as text) and the default bare-domain behavior.
310 lines
8 KiB
TypeScript
310 lines
8 KiB
TypeScript
import type { ComponentProps, ReactNode } from 'react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
import { ArrowUpRight } from '@/lib/icons'
|
|
|
|
import { cn } from './utils'
|
|
|
|
const titleCache = new Map<string, string>()
|
|
const titleInflight = new Map<string, Promise<string>>()
|
|
const titleSubs = new Map<string, Set<(value: string) => void>>()
|
|
|
|
const URL_RE =
|
|
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
|
|
|
|
// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the
|
|
// surrounding text is full of filename-shaped tokens (e.g. `agent.log`,
|
|
// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would
|
|
// otherwise mistake for domains and linkify.
|
|
const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi
|
|
|
|
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
|
|
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
|
|
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
|
|
|
|
const ERROR_TITLE_RE =
|
|
/\b(?:access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
|
|
|
|
export function normalizeExternalUrl(value: string): string {
|
|
const trimmed = value.trim()
|
|
|
|
if (!trimmed || /^https?:\/\//i.test(trimmed)) {
|
|
return trimmed
|
|
}
|
|
|
|
return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed
|
|
}
|
|
|
|
function parseUrl(value: string): null | URL {
|
|
try {
|
|
return new URL(normalizeExternalUrl(value))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function titleCacheKey(value: string): string {
|
|
const url = parseUrl(value)
|
|
|
|
if (!url) {
|
|
return normalizeExternalUrl(value)
|
|
}
|
|
|
|
const host = url.hostname.replace(/^www\./i, '').toLowerCase()
|
|
const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
|
|
|
|
return `${host}${pathname}${url.search || ''}`
|
|
}
|
|
|
|
export function shortHostLabel(value: string): string {
|
|
return parseUrl(value)?.hostname.replace(/^www\./, '') ?? value
|
|
}
|
|
|
|
export function hostPathLabel(value: string): string {
|
|
const url = parseUrl(value)
|
|
|
|
if (!url) {
|
|
return value
|
|
}
|
|
|
|
const host = url.hostname.replace(/^www\./, '')
|
|
const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''
|
|
|
|
return `${host}${path}`
|
|
}
|
|
|
|
function cleanSlug(segment: string): string {
|
|
try {
|
|
return decodeURIComponent(segment)
|
|
.replace(/\.a\d+\..*$/i, '')
|
|
.replace(/\.(?:html?|php|aspx?)$/i, '')
|
|
.replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '')
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export function urlSlugTitleLabel(value: string): string {
|
|
const url = parseUrl(value)
|
|
|
|
for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) {
|
|
const cleaned = cleanSlug(segment)
|
|
|
|
if (!cleaned || !/[a-z]/i.test(cleaned)) {
|
|
continue
|
|
}
|
|
|
|
if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) {
|
|
continue
|
|
}
|
|
|
|
const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
|
|
|
|
if (titled.length >= 4) {
|
|
return titled
|
|
}
|
|
}
|
|
|
|
return hostPathLabel(value)
|
|
}
|
|
|
|
export function isTitleFetchable(value: string): boolean {
|
|
if (!value || SKIP_PROTO_RE.test(value)) {
|
|
return false
|
|
}
|
|
|
|
const url = parseUrl(value)
|
|
|
|
return Boolean(url && /^https?:$/.test(url.protocol) && !LOCAL_HOST_RE.test(url.host))
|
|
}
|
|
|
|
export function fetchLinkTitle(url: string): Promise<string> {
|
|
const normalizedUrl = normalizeExternalUrl(url)
|
|
const key = titleCacheKey(normalizedUrl)
|
|
|
|
if (!isTitleFetchable(normalizedUrl)) {
|
|
return Promise.resolve('')
|
|
}
|
|
|
|
if (titleCache.has(key)) {
|
|
return Promise.resolve(titleCache.get(key) ?? '')
|
|
}
|
|
|
|
const pending = titleInflight.get(key)
|
|
|
|
if (pending) {
|
|
return pending
|
|
}
|
|
|
|
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.fetchLinkTitle
|
|
|
|
if (!bridge) {
|
|
titleCache.set(key, '')
|
|
|
|
return Promise.resolve('')
|
|
}
|
|
|
|
const promise = bridge(normalizedUrl)
|
|
.then(value => (value || '').replace(/\s+/g, ' ').trim())
|
|
.then(clean => (clean && !ERROR_TITLE_RE.test(clean) ? clean : ''))
|
|
.catch(() => '')
|
|
.then(safe => {
|
|
titleCache.set(key, safe)
|
|
titleInflight.delete(key)
|
|
titleSubs.get(key)?.forEach(sub => sub(safe))
|
|
|
|
return safe
|
|
})
|
|
|
|
titleInflight.set(key, promise)
|
|
|
|
return promise
|
|
}
|
|
|
|
export function useLinkTitle(url?: null | string): string {
|
|
const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url])
|
|
const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl])
|
|
const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : ''))
|
|
|
|
useEffect(() => {
|
|
setTitle(key ? (titleCache.get(key) ?? '') : '')
|
|
|
|
if (!key || !isTitleFetchable(normalizedUrl)) {
|
|
return
|
|
}
|
|
|
|
const subs = titleSubs.get(key) ?? new Set<(value: string) => void>()
|
|
|
|
subs.add(setTitle)
|
|
titleSubs.set(key, subs)
|
|
void fetchLinkTitle(normalizedUrl)
|
|
|
|
return () => {
|
|
subs.delete(setTitle)
|
|
|
|
if (!subs.size) {
|
|
titleSubs.delete(key)
|
|
}
|
|
}
|
|
}, [key, normalizedUrl])
|
|
|
|
return title
|
|
}
|
|
|
|
export function openExternalLink(href: string): void {
|
|
if (href) {
|
|
void window.hermesDesktop?.openExternal?.(href)
|
|
}
|
|
}
|
|
|
|
interface ExternalLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
|
|
href: string
|
|
children?: ReactNode
|
|
showExternalIcon?: boolean
|
|
}
|
|
|
|
export function ExternalLinkIcon({ className }: { className?: string }) {
|
|
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
|
|
}
|
|
|
|
export function ExternalLink({
|
|
children,
|
|
className,
|
|
href,
|
|
onClick,
|
|
showExternalIcon = true,
|
|
...rest
|
|
}: ExternalLinkProps) {
|
|
const target = normalizeExternalUrl(href)
|
|
|
|
return (
|
|
<a
|
|
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current/20', className)}
|
|
href={target}
|
|
onClick={event => {
|
|
event.stopPropagation()
|
|
onClick?.(event)
|
|
|
|
if (event.defaultPrevented) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
openExternalLink(target)
|
|
}}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
{...rest}
|
|
>
|
|
{children ?? urlSlugTitleLabel(target)}
|
|
{showExternalIcon && <ExternalLinkIcon />}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
interface PrettyLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
|
|
href: string
|
|
label?: string
|
|
fallbackLabel?: string
|
|
}
|
|
|
|
export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: PrettyLinkProps) {
|
|
const target = useMemo(() => normalizeExternalUrl(href), [href])
|
|
const fetched = useLinkTitle(label ? null : target)
|
|
const display = fetched || label?.trim() || fallbackLabel?.trim() || urlSlugTitleLabel(target)
|
|
|
|
return (
|
|
<ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}>
|
|
<span className="font-medium">{display}</span>
|
|
</ExternalLink>
|
|
)
|
|
}
|
|
|
|
interface LinkifiedTextProps {
|
|
className?: string
|
|
text: string
|
|
pretty?: boolean
|
|
explicitOnly?: boolean
|
|
}
|
|
|
|
export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) {
|
|
const nodes: ReactNode[] = []
|
|
let cursor = 0
|
|
|
|
for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) {
|
|
const raw = match[0]
|
|
const url = normalizeExternalUrl(raw)
|
|
const index = match.index ?? 0
|
|
|
|
if (index > cursor) {
|
|
nodes.push(text.slice(cursor, index))
|
|
}
|
|
|
|
nodes.push(
|
|
pretty ? (
|
|
<PrettyLink href={url} key={`${url}-${index}`} />
|
|
) : (
|
|
<ExternalLink href={url} key={`${url}-${index}`}>
|
|
{raw}
|
|
</ExternalLink>
|
|
)
|
|
)
|
|
|
|
cursor = index + raw.length
|
|
}
|
|
|
|
if (cursor < text.length) {
|
|
nodes.push(text.slice(cursor))
|
|
}
|
|
|
|
return <span className={className}>{nodes.length ? nodes : text}</span>
|
|
}
|
|
|
|
export function __resetLinkTitleCache(): void {
|
|
titleCache.clear()
|
|
titleInflight.clear()
|
|
titleSubs.clear()
|
|
}
|