mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* fix(web): autolink markdown links, inline code, and .mmd file paths in chat Chat autolinking previously only worked for bare file paths in plain text. Fancier markdown forms silently produced dead links: - COMMON_FILE_EXTENSIONS omitted common agent-cited types (mmd, puml, rst, csv, ini, etc.), so bare diagram.mmd never linked. - inlineCode nodes were never processed, so `path/to/file.md` never linked. - explicit [label](relative/file.md) links kept a raw relative URL that the SPA router treated as a dead route under /sessions/. Changes: - Expand COMMON_FILE_EXTENSIONS with justified doc/diagram/config/lang exts; deliberately exclude TLD-lookalikes (org/com/io) to avoid domain false positives. - Autolink inlineCode nodes whose ENTIRE value is a single path pattern match (whitespace-free, allowlisted ext), wrapping an inlineCode child to keep monospace. Real code snippets are left untouched. - Rewrite explicit markdown links whose target is a repo-relative allowlisted file path into hapi-file: hrefs (aligns with #1113). Preserves label. Security invariants preserved: shouldLinkPath still rejects abs / ~/ / ../ / Windows-drive / scheme:// paths; scheme-bearing link urls are left for the deny-scheme layer; deny-scheme handling untouched. Refs tiann/hapi#1120 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): don't rewrite explicit links in standalone markdown preview Codex review (#1142): rewriteFileLinkNode ran on the standalone file-preview surface too, but that surface has no HappyChatContext so the shared `A` anchor collapses hapi-file: links to plain text (returns props.children when !chat). That turned an explicit [label](file.md) link in a README preview from an anchor into plain text. Gate explicit-link rewriting behind a rewriteExplicitLinks option (default on for chat) and disable it for the standalone renderer via new MARKDOWN_PLUGINS_STANDALONE(_WITH_BREAKS) arrays. Bare-path and inlineCode autolinks are kept — they were already inert on the standalone surface, so no behavior change there. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse> Co-authored-by: Cursor <cursoragent@cursor.com>
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
|
|
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
|
|
import { TextMessagePartProvider } from '@assistant-ui/react'
|
|
import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type ComponentType } from 'react'
|
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
import {
|
|
MARKDOWN_PLUGINS,
|
|
MARKDOWN_PLUGINS_STANDALONE,
|
|
MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS,
|
|
MARKDOWN_PLUGINS_WITH_BREAKS,
|
|
MARKDOWN_REHYPE_PLUGINS,
|
|
MARKDOWN_COMPONENTS_BY_LANGUAGE,
|
|
MARKDOWN_CLASSNAME,
|
|
defaultComponents,
|
|
denyOnlyTransform,
|
|
UriConfirmProvider,
|
|
} from '@/components/assistant-ui/markdown-text'
|
|
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
|
import type { CodeHeaderProps, SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string
|
|
components?: MarkdownTextPrimitiveProps['components']
|
|
className?: string
|
|
preserveSingleLineBreaks?: boolean
|
|
/** Render outside assistant-ui thread context (file pane, fixtures). */
|
|
standalone?: boolean
|
|
}
|
|
|
|
function StandaloneCode(props: ComponentPropsWithoutRef<'code'>) {
|
|
const Code = defaultComponents.code!
|
|
return <Code {...props} />
|
|
}
|
|
|
|
function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) {
|
|
const child = Children.toArray(props.children)[0]
|
|
if (!isValidElement<ComponentPropsWithoutRef<'code'>>(child)) {
|
|
const Pre = defaultComponents.pre!
|
|
return <Pre {...props} />
|
|
}
|
|
|
|
const className = String(child.props.className ?? '')
|
|
const language = /language-(\w+)/.exec(className)?.[1] ?? 'unknown'
|
|
const code = String(child.props.children ?? '').replace(/\n$/, '')
|
|
const Highlighter: ComponentType<SyntaxHighlighterProps> =
|
|
MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
|
|
?? SyntaxHighlighter
|
|
const CodeHeader = defaultComponents.CodeHeader as ComponentType<CodeHeaderProps>
|
|
const Pre = defaultComponents.pre!
|
|
const Code = defaultComponents.code!
|
|
|
|
return (
|
|
<>
|
|
<CodeHeader language={language} code={code} />
|
|
<Highlighter language={language} code={code} components={{ Pre, Code }} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
function StandaloneMarkdownContent(props: MarkdownRendererProps) {
|
|
const mergedComponents = props.components
|
|
? { ...defaultComponents, ...props.components }
|
|
: defaultComponents
|
|
|
|
const {
|
|
pre: _pre,
|
|
code: _code,
|
|
SyntaxHighlighter: _sh,
|
|
CodeHeader: _header,
|
|
...componentsRest
|
|
} = mergedComponents as typeof mergedComponents & Record<string, unknown>
|
|
|
|
const components = useMemo<Components>(() => ({
|
|
...(componentsRest as Components),
|
|
pre: StandalonePre,
|
|
code: StandaloneCode,
|
|
}), [componentsRest])
|
|
|
|
return (
|
|
<UriConfirmProvider>
|
|
<div className={cn(MARKDOWN_CLASSNAME, props.className)}>
|
|
<ReactMarkdown
|
|
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS : MARKDOWN_PLUGINS_STANDALONE}
|
|
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
|
components={components}
|
|
urlTransform={denyOnlyTransform}
|
|
>
|
|
{props.content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</UriConfirmProvider>
|
|
)
|
|
}
|
|
|
|
function MarkdownContent(props: MarkdownRendererProps) {
|
|
const mergedComponents = props.components
|
|
? { ...defaultComponents, ...props.components }
|
|
: defaultComponents
|
|
|
|
return (
|
|
<UriConfirmProvider>
|
|
<TextMessagePartProvider text={props.content}>
|
|
<MarkdownTextPrimitive
|
|
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_WITH_BREAKS : MARKDOWN_PLUGINS}
|
|
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
|
components={mergedComponents}
|
|
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
|
|
urlTransform={denyOnlyTransform}
|
|
className={cn(MARKDOWN_CLASSNAME, props.className)}
|
|
/>
|
|
</TextMessagePartProvider>
|
|
</UriConfirmProvider>
|
|
)
|
|
}
|
|
|
|
export function MarkdownRenderer(props: MarkdownRendererProps) {
|
|
if (props.standalone) {
|
|
return <StandaloneMarkdownContent {...props} />
|
|
}
|
|
return <MarkdownContent {...props} />
|
|
}
|