import { CloseOutlined, CopyOutlined, DeleteOutlined, DisconnectOutlined, DownloadOutlined, DownOutlined, ExclamationCircleOutlined, FullscreenExitOutlined, FullscreenOutlined, MessageOutlined, PaperClipOutlined, PlusOutlined, RedoOutlined, SendOutlined, SyncOutlined, ThunderboltOutlined, UpOutlined, } from '@ant-design/icons'; import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { startTransition, useEffect, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent, type ReactNode, type RefObject, type TouchEvent, } from 'react'; import { InlineImage } from '../../../components/common/InlineImage'; import { CodexDiffBlock } from '../../../components/previewer'; import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController'; import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind, } from './ChatPreviewBody'; import { triggerResourceDownload } from './downloadUtils'; import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls'; import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers'; import { normalizeChatResourceUrl } from './chatResourceUrl'; import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils'; import { ChatLinkCardPreview } from './ChatLinkCardPreview'; import { openChatExternalLink } from './linkNavigation'; import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview'; import { extractChatMessageParts } from './messageParts'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types'; const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); type ChatTypeOption = { value: string; label: string; description: string; disabled?: boolean; }; type PreviewOption = { id: string; label: string; url: string; kind: string; }; type QueuedRequestOption = { requestId: string; order: number; text: string; }; type InlinePreviewKind = ChatPreviewKind; type InlinePreviewTarget = { url: string; label: string; kind: InlinePreviewKind; }; type OpenPreviewTarget = | string | { id: string; label: string; url: string; kind: InlinePreviewKind; source?: 'message' | 'context'; }; type PendingComposerUpload = { key: string; name: string; status: 'uploading' | 'uploaded' | 'failed'; reason?: string; }; type PreviewFetchError = Error & { status?: number; }; const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]'; const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]'; const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; type MessageRenderPayload = { previewSourceText: string; visibleText: string; diffBlocks: string[]; rankedLinkTargets: RankedLinkPreviewTarget[]; linkCardTargets: Extract[]; }; const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i; const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i; const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i; function normalizeInlinePreviewUrl(value: string) { return normalizeChatResourceUrl(value); } function classifyInlinePreviewKind(url: string): InlinePreviewKind { const pathname = url.toLowerCase().split('?')[0] ?? ''; if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) { return 'image'; } if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) { return 'video'; } if (/\.(md|markdown)$/i.test(pathname)) { return 'markdown'; } if (/\.(diff|patch)$/i.test(pathname)) { return 'diff'; } if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { return 'code'; } if (/\.(txt|log|csv)$/i.test(pathname)) { return 'document'; } if (/\.pdf$/i.test(pathname)) { return 'pdf'; } return 'file'; } function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') { if (typeof document === 'undefined') { return; } const blob = new Blob([content], { type: mimeType }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(objectUrl); } function buildInlinePreviewLabel(url: string) { try { const parsed = new URL(url); return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname; } catch { return url; } } function buildPreviewFileName(item: Pick) { try { const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim(); return fileName || item.label.trim() || item.url; } catch { return item.label.trim() || item.url; } } function resolvePreviewFileExtension(item: Pick) { const fileName = buildPreviewFileName(item).toLowerCase(); const match = fileName.match(/\.([a-z0-9]{1,16})$/i); return match?.[1] ?? ''; } function buildResourceChipMeta(item: Pick) { const extension = resolvePreviewFileExtension(item); if (extension) { return extension.toUpperCase(); } return resolveChatPreviewKindLabel(item.kind as ChatPreviewKind) .replace(/\s+preview$/i, '') .replace(/\s+download$/i, '') .toUpperCase(); } function normalizeRankedLinkTitle(value: string) { return value .replace(/^\[(.+)\]\([^)]+\)$/u, '$1') .replace(/\s+/g, ' ') .trim(); } function extractRankedLinkTargets(text: string) { const lines = String(text ?? '').split('\n'); const keptLines: string[] = []; const rankedLinkTargets: RankedLinkPreviewTarget[] = []; const seen = new Set(); const pushRankedLink = (title: string, url: string) => { const normalizedUrl = normalizeInlinePreviewUrl(url.trim()); const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl); const key = `${normalizedTitle}::${normalizedUrl}`; if (seen.has(key)) { return; } seen.add(key); rankedLinkTargets.push({ title: normalizedTitle, url: normalizedUrl, }); }; for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ''; const trimmedLine = line.trim(); if (!trimmedLine) { keptLines.push(line); continue; } const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)]; if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) { markdownMatches.forEach((match) => { const [, label, href] = match; if (href?.trim()) { pushRankedLink(label?.trim() || href.trim(), href); } }); continue; } const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN); if (!titleMatch) { keptLines.push(line); continue; } const collectedLines = [line]; const title = titleMatch[1]?.trim() ?? ''; let url = ''; let hasRank = RANK_LINE_PATTERN.test(trimmedLine); let cursor = index + 1; while (cursor < lines.length) { const candidate = lines[cursor] ?? ''; const trimmedCandidate = candidate.trim(); if (!trimmedCandidate) { collectedLines.push(candidate); cursor += 1; continue; } if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) { break; } const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN); if (linkMatch) { url = linkMatch[1]?.trim() ?? url; collectedLines.push(candidate); hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate); cursor += 1; continue; } if (RANK_LINE_PATTERN.test(trimmedCandidate)) { hasRank = true; collectedLines.push(candidate); cursor += 1; continue; } break; } if (title && url && hasRank) { pushRankedLink(title, url); index = cursor - 1; continue; } keptLines.push(...collectedLines); index = cursor - 1; } return { strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), rankedLinkTargets, }; } function buildComposerFilePickKey(file: File) { return `${file.name}:${file.size}:${file.type}:${file.lastModified}`; } function isClipboardImageFile(file: File) { const normalizedType = String(file.type ?? '').trim().toLowerCase(); if (normalizedType.startsWith('image/')) { return true; } const normalizedName = String(file.name ?? '').trim().toLowerCase(); return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName); } function isGeneratedClipboardImageName(file: File) { const normalizedName = String(file.name ?? '').trim().toLowerCase(); if (!normalizedName) { return true; } return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName); } function getClipboardImageMimeRank(file: File) { const normalizedType = String(file.type ?? '').trim().toLowerCase(); switch (normalizedType) { case 'image/png': return 0; case 'image/jpeg': return 1; case 'image/webp': return 2; case 'image/gif': return 3; case 'image/bmp': return 4; case 'image/heic': case 'image/heif': return 5; case 'image/tiff': case 'image/tif': return 6; default: return 7; } } function resolvePreferredClipboardImageFiles(files: File[]) { if (files.length <= 1) { return files; } const sortedFiles = [...files] .sort((left, right) => { const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right); if (rankDifference !== 0) { return rankDifference; } return right.size - left.size; }) .slice(0, 1); if (files.every(isGeneratedClipboardImageName)) { return sortedFiles; } return sortedFiles; } function resolveComposerPasteFiles(clipboardData: DataTransfer) { const clipboardItemFiles = Array.from(clipboardData.items ?? []) .filter((item) => item.kind === 'file') .map((item) => item.getAsFile()) .filter((file): file is File => file instanceof File) .filter((file) => file.size > 0); const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0); const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles; const imageFiles = candidateFiles.filter(isClipboardImageFile); const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles; return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values()); } async function createPreviewFetchError(response: Response): Promise { const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; let responseMessage = ''; try { if (contentType.includes('application/json')) { const payload = (await response.json()) as { message?: string }; responseMessage = String(payload.message ?? '').trim(); } else { responseMessage = (await response.text()).trim(); } } catch { responseMessage = ''; } const statusLabel = response.status === 403 ? '이 문서는 현재 권한으로 열 수 없습니다.' : response.status === 404 ? '이 문서를 찾을 수 없습니다.' : response.status === 401 ? '이 문서를 열기 위한 인증이 필요합니다.' : `preview 요청이 실패했습니다. (${response.status})`; const detail = responseMessage && responseMessage !== response.statusText ? responseMessage : response.statusText.trim(); const error = new Error(detail ? `${statusLabel} ${detail}` : statusLabel) as PreviewFetchError; error.status = response.status; return error; } function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] { const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)]; const seen = new Set(); const targets: InlinePreviewTarget[] = []; for (const matchedUrl of matches) { const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl); const kind = classifyInlinePreviewKind(normalizedUrl); if (kind === 'file') { continue; } if (seen.has(normalizedUrl)) { continue; } seen.add(normalizedUrl); targets.push({ url: normalizedUrl, label: buildInlinePreviewLabel(normalizedUrl), kind, }); } return targets; } function renderMessageInlineParts(line: string): ReactNode[] { const renderedParts: ReactNode[] = []; let cursor = 0; for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) { const [fullMatch, label, rawHref] = match; const start = match.index ?? 0; if (start > cursor) { renderedParts.push(line.slice(cursor, start)); } const href = normalizeInlinePreviewUrl(rawHref.trim()); renderedParts.push( { openChatExternalLink(href, event); }} > {label.trim() || href} , ); cursor = start + fullMatch.length; } if (cursor < line.length) { renderedParts.push(line.slice(cursor)); } return renderedParts.length > 0 ? renderedParts : [line]; } function renderMessageBody(text: string) { const lines = text.split('\n'); return lines.map((line, index) => { const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN); if (imageMatch) { const [, alt, rawSrc] = imageMatch; const src = normalizeInlinePreviewUrl(rawSrc.trim()); return (
); } if (!line.length) { return