233 lines
6.1 KiB
TypeScript
233 lines
6.1 KiB
TypeScript
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
|
|
|
/**
|
|
* Artifact detection — decides when a fenced block in an assistant message is
|
|
* substantial, self-contained content that deserves an artifact card (opening
|
|
* in the right rail) instead of an inline code block.
|
|
*
|
|
* Pure and cheap: it runs per streaming delta on the growing fence body, so
|
|
* everything here is a bounded regex scan or a line count. No store access.
|
|
*/
|
|
|
|
export type ArtifactKind = 'code' | 'html' | 'svg'
|
|
|
|
export interface ArtifactDetection {
|
|
kind: ArtifactKind
|
|
/** Sanitized fence language ('' possible for html/svg detected by shape). */
|
|
language: string
|
|
/** Human title derived from the content (html <title>, svg <title>, a named
|
|
* declaration for code). Falls back to a kind/language label. */
|
|
title: string
|
|
}
|
|
|
|
// A fence only becomes an artifact once it is unambiguously a document (html),
|
|
// a large standalone graphic (svg), or long enough that inlining it would
|
|
// drown the conversation (code). Small snippets stay inline code cards.
|
|
const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i
|
|
const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i
|
|
const HTML_DOC_MIN_CHARS = 160
|
|
const HTML_FRAGMENT_MIN_CHARS = 1200
|
|
const SVG_MIN_CHARS = 2000
|
|
const CODE_MIN_LINES = 48
|
|
const CODE_MIN_CHARS = 3000
|
|
|
|
const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml'])
|
|
|
|
// Languages whose fences are never artifacts: prose-ish, terminal output, and
|
|
// the fences already owned by richer renderers (mermaid diagrams, small svg).
|
|
const NON_ARTIFACT_LANGUAGES = new Set([
|
|
'',
|
|
'console',
|
|
'diff',
|
|
'log',
|
|
'logs',
|
|
'markdown',
|
|
'md',
|
|
'mermaid',
|
|
'output',
|
|
'patch',
|
|
'plain',
|
|
'plaintext',
|
|
'shell-session',
|
|
'stdout',
|
|
'text',
|
|
'txt'
|
|
])
|
|
|
|
function countLines(text: string): number {
|
|
let lines = 1
|
|
let index = text.indexOf('\n')
|
|
|
|
while (index !== -1) {
|
|
lines += 1
|
|
index = text.indexOf('\n', index + 1)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
function stripTags(value: string): string {
|
|
return value
|
|
.replace(/<[^>]*>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
function titleFromTag(content: string, tag: 'h1' | 'title'): string {
|
|
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content)
|
|
|
|
return match ? stripTags(match[1] || '').slice(0, 80) : ''
|
|
}
|
|
|
|
const CODE_DECLARATION_RE =
|
|
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/
|
|
|
|
// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the
|
|
// first meaningful line — the de-facto LLM convention for naming a file.
|
|
const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i
|
|
|
|
function codeTitle(language: string, content: string): string {
|
|
const head = content.slice(0, 2000)
|
|
const fileName = FILENAME_COMMENT_RE.exec(head)?.[1]
|
|
|
|
if (fileName) {
|
|
return fileName
|
|
}
|
|
|
|
const declaration = CODE_DECLARATION_RE.exec(head)?.[1]
|
|
|
|
if (declaration) {
|
|
return declaration
|
|
}
|
|
|
|
return language
|
|
}
|
|
|
|
export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null {
|
|
const trimmed = (code ?? '').trim()
|
|
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
|
|
const clean = sanitizeLanguageTag(language || '')
|
|
|
|
if (HTML_LANGUAGES.has(clean)) {
|
|
const isDocument = HTML_DOC_RE.test(trimmed)
|
|
|
|
if (
|
|
(isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) ||
|
|
(!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed))
|
|
) {
|
|
return {
|
|
kind: 'html',
|
|
language: clean,
|
|
title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML'
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
if (clean === 'svg') {
|
|
// Small svg fences keep their inline embed (svg-embed.tsx); only a large
|
|
// standalone graphic graduates into an artifact tab.
|
|
if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) {
|
|
return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
if (NON_ARTIFACT_LANGUAGES.has(clean)) {
|
|
return null
|
|
}
|
|
|
|
if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) {
|
|
return null
|
|
}
|
|
|
|
if (isLikelyProseCodeBlock(clean, trimmed)) {
|
|
return null
|
|
}
|
|
|
|
return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) }
|
|
}
|
|
|
|
/** Stable identity slug for versioning: the same (kind, title, language) in a
|
|
* session is treated as one artifact the model iterates on. */
|
|
export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string {
|
|
const title = detection.title
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 48)
|
|
|
|
return `${detection.kind}:${detection.language}:${title || 'untitled'}`
|
|
}
|
|
|
|
/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */
|
|
export function artifactContentHash(content: string): string {
|
|
let hash = 0x811c9dc5
|
|
|
|
for (let i = 0; i < content.length; i += 1) {
|
|
hash ^= content.charCodeAt(i)
|
|
hash = Math.imul(hash, 0x01000193)
|
|
}
|
|
|
|
return (hash >>> 0).toString(36)
|
|
}
|
|
|
|
const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {
|
|
bash: '.sh',
|
|
c: '.c',
|
|
cpp: '.cpp',
|
|
csharp: '.cs',
|
|
css: '.css',
|
|
go: '.go',
|
|
htm: '.html',
|
|
html: '.html',
|
|
java: '.java',
|
|
javascript: '.js',
|
|
js: '.js',
|
|
json: '.json',
|
|
jsx: '.jsx',
|
|
kotlin: '.kt',
|
|
php: '.php',
|
|
py: '.py',
|
|
python: '.py',
|
|
rb: '.rb',
|
|
rs: '.rs',
|
|
ruby: '.rb',
|
|
rust: '.rs',
|
|
sh: '.sh',
|
|
sql: '.sql',
|
|
svg: '.svg',
|
|
swift: '.swift',
|
|
toml: '.toml',
|
|
ts: '.ts',
|
|
tsx: '.tsx',
|
|
typescript: '.ts',
|
|
xhtml: '.html',
|
|
xml: '.xml',
|
|
yaml: '.yaml',
|
|
yml: '.yaml'
|
|
}
|
|
|
|
export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string {
|
|
const base =
|
|
title
|
|
.replace(/[^\p{L}\p{N}._ -]+/gu, '')
|
|
.trim()
|
|
.replace(/\s+/g, '-')
|
|
.slice(0, 60) || 'artifact'
|
|
|
|
if (/\.[a-z0-9]{1,8}$/i.test(base)) {
|
|
return base
|
|
}
|
|
|
|
const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt'
|
|
|
|
return `${base}${ext}`
|
|
}
|