import { AppstoreOutlined, ArrowLeftOutlined, CheckOutlined, CloseOutlined, ControlOutlined, CopyOutlined, DeleteOutlined, DisconnectOutlined, DownloadOutlined, DownOutlined, ExclamationOutlined, ExclamationCircleOutlined, FilterOutlined, FullscreenExitOutlined, FullscreenOutlined, LeftOutlined, RightOutlined, NodeIndexOutlined, PaperClipOutlined, PlusOutlined, ProfileOutlined, RedoOutlined, SendOutlined, ShareAltOutlined, SettingOutlined, MinusOutlined, SyncOutlined, ThunderboltOutlined, UpOutlined, } from '@ant-design/icons'; import { Alert, Button, Checkbox, Dropdown, Input, Modal, Segmented, Select, Spin, Tag, message } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { MenuProps } from 'antd'; import { Fragment, memo, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type ClipboardEvent, type ReactNode, type RefObject, type TouchEvent, type UIEvent, } from 'react'; import { createPortal } from 'react-dom'; import { InlineImage } from '../../../components/common/InlineImage'; import { CodexDiffBlock, parseCodexDiffSections } from '../../../components/previewer'; import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController'; import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind, } from './ChatPreviewBody'; import { triggerResourceDownload } from './downloadUtils'; import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers'; import { normalizeChatResourceUrl } from './chatResourceUrl'; import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, sharePreviewLink } from './chatUtils'; import { ChatLinkCardPreview } from './ChatLinkCardPreview'; import { ChatActivityChecklist, buildChatActivityChecklistEntries } from './ChatActivityChecklist'; import { describeExecutorCommand } from './executorActivitySummary'; import { buildComposerFilePickKey } from './composerFilePickKey'; import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from './ChatPromptCard'; import { openChatExternalLink } from './linkNavigation'; import { classifyPreviewKind } from './previewKind'; import { isPromptResolved } from './promptState'; import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview'; import { extractAttachmentPreviewUrls, extractChatMessageParts } from './messageParts'; import type { ChatComposerAttachment, ChatConversationRequest, ChatConversationRequestStatus, 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 CodexModelOption = { value: string; label: string; description: string; }; type PreviewOption = { id: string; label: string; url: string; kind: string; }; type QueuedRequestOption = { requestId: string; order: number; text: string; }; type ComposerAssistAction = { key: string; label: string; description?: string | null; resolveText: () => string | Promise; }; function resolveComposerAssistIcon(actionKey: string) { if (actionKey === 'menu') { return ; } if (actionKey === 'focus') { return ; } if (actionKey === 'selection') { return ; } if (actionKey === 'errors') { return ; } return ; } function buildComposerAssistDropdownLabel(title?: string | null) { const normalizedTitle = title?.trim(); return normalizedTitle ? `${normalizedTitle} 작업환경 구성` : '작업환경 구성'; } 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 = { attemptId: string; key: string; name: string; status: 'uploading' | 'failed'; reason?: string; }; type ComposerSendResult = 'sent' | 'pending' | 'blocked'; type SystemExecutionFilter = 'all' | 'active' | 'attention' | 'active-attention'; type SystemExecutionSort = 'latest' | 'answered' | 'status'; type PendingPromptSelection = PromptDraftSelection & { status: 'draft' | 'submitted'; promptTitle: string; target: Extract; requestId: string | null; }; type StoredPromptSelection = Pick< PendingPromptSelection, 'selectedValues' | 'freeText' | 'stepSelections' | 'summaryText' | 'status' | 'promptTitle' | 'requestId' >; type SystemExecutionDisplayRequest = { request: ChatConversationRequest; depth: number; }; type SystemExecutionDisplayMode = 'expanded' | 'collapsed' | 'hidden'; type RoomShareExpandMode = 'latest' | 'pending' | 'all'; type SystemExecutionAttentionState = { activityLines: string[]; promptTargets: Extract[]; promptSubmittedCount: number; isPromptManuallyCompleted: boolean; hasVerificationTarget: boolean; hasConfirmedVerificationTarget: boolean; isVerificationManuallyCompleted: boolean; hasPendingPromptBadge: boolean; hasPendingVerificationBadge: boolean; hasOwnAttentionState: boolean; }; type SystemExecutionActivityOverview = { lines: string[]; planTitle: string | null; executors: Array<{ key: string; title: string; line: string; focus: string; command: string | null; commandSummary: string | null; updateCount: number; }>; }; const ROOM_SHARE_EXPAND_MODE_LABELS: Record = { latest: '마지막건', pending: '처리중·미확인', all: '전체', }; function summarizeRoomShareActivityLines(lines: string[]) { const normalizedLines = lines.map((line) => line.trim()).filter(Boolean); if (normalizedLines.length === 0) { return [] as string[]; } const summaries: string[] = []; const pushUnique = (label: string) => { if (!summaries.includes(label)) { summaries.push(label); } }; normalizedLines.forEach((line) => { const lower = line.toLowerCase(); if (/(read|search|inspect|context|analysis|analy|조사|확인|정리|검토|조회)/.test(lower)) { pushUnique('요청 내용을 확인하고 필요한 정보를 정리하고 있어요.'); } if (/(edit|patch|write|implement|fix|update|modify|반영|수정|작성)/.test(lower)) { pushUnique('답변에 반영할 내용을 만들고 있어요.'); } if (/(test|build|verify|check|validate|검증|점검|실행)/.test(lower)) { pushUnique('결과를 점검하고 있어요.'); } if (/(file|resource|upload|preview|첨부|파일)/.test(lower)) { pushUnique('참고 자료와 첨부 내용을 함께 살펴보고 있어요.'); } }); if (summaries.length > 0) { return summaries.slice(0, 3); } const latestLine = normalizedLines[normalizedLines.length - 1] ?.replace(/[\`*_>#-]+/g, ' ') .replace(/\s+/g, ' ') .trim(); return latestLine ? [`현재 작업을 진행하고 있어요. `] : ['현재 작업을 진행하고 있어요.']; } type SystemExecutionBadgeTone = | 'queued' | 'started' | 'completed' | 'failed' | 'cancelled' | 'neutral' | 'attention' | 'prompt' | 'unread'; function buildPendingPromptSelectionKey( messageId: number, promptIndex: number, targetTitle: string, target: Extract, ) { return `${messageId}:${promptIndex}:${targetTitle}:${buildPromptTargetSignature(target)}`; } function buildPromptSelectionStorageKey(sessionId: string) { const normalizedSessionId = sessionId.trim(); return normalizedSessionId ? `main-chat-panel:prompt-selections:${normalizedSessionId}` : ''; } function readStoredPromptSelections(sessionId: string) { if (typeof window === 'undefined') { return {} as Record; } const storageKey = buildPromptSelectionStorageKey(sessionId); if (!storageKey) { return {} as Record; } try { const rawValue = window.localStorage.getItem(storageKey); if (!rawValue) { return {} as Record; } const parsed = JSON.parse(rawValue); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return {} as Record; } return Object.fromEntries( Object.entries(parsed as Record).flatMap(([key, value]) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return []; } const record = value as Record; const status = record.status === 'submitted' || record.status === 'draft' ? record.status : null; if (!status) { return []; } const selectedValues = Array.isArray(record.selectedValues) ? record.selectedValues.map((item) => String(item ?? '').trim()).filter(Boolean) : []; const stepSelections = Array.isArray(record.stepSelections) ? record.stepSelections.flatMap((item) => { if (!item || typeof item !== 'object' || Array.isArray(item)) { return []; } const stepRecord = item as Record; const stepKey = String(stepRecord.stepKey ?? '').trim(); const stepTitle = String(stepRecord.stepTitle ?? '').trim(); if (!stepKey || !stepTitle) { return []; } return [{ stepKey, stepTitle, selectedValues: Array.isArray(stepRecord.selectedValues) ? stepRecord.selectedValues.map((entry) => String(entry ?? '').trim()).filter(Boolean) : [], freeText: String(stepRecord.freeText ?? ''), skipped: stepRecord.skipped === true, }]; }) : undefined; return [[ key, { status, selectedValues, freeText: String(record.freeText ?? ''), stepSelections, summaryText: String(record.summaryText ?? '').trim() || null, promptTitle: String(record.promptTitle ?? '').trim(), requestId: String(record.requestId ?? '').trim() || null, } satisfies StoredPromptSelection, ]]; }), ); } catch { return {} as Record; } } function writeStoredPromptSelections( sessionId: string, selections: Record, ) { if (typeof window === 'undefined') { return; } const storageKey = buildPromptSelectionStorageKey(sessionId); if (!storageKey) { return; } const nextValue = Object.fromEntries( Object.entries(selections) .map(([key, selection]) => [ key, { status: selection.status, selectedValues: selection.selectedValues, freeText: selection.freeText, stepSelections: selection.stepSelections, summaryText: selection.summaryText ?? null, promptTitle: selection.promptTitle, requestId: selection.requestId, } satisfies StoredPromptSelection, ]), ); try { if (Object.keys(nextValue).length === 0) { window.localStorage.removeItem(storageKey); return; } window.localStorage.setItem(storageKey, JSON.stringify(nextValue)); } catch { // Ignore storage quota and serialization failures. Prompt state should remain usable in-memory. } } type SystemExecutionBadge = { label: string; shortLabel: string; tone: SystemExecutionBadgeTone; }; type SystemExecutionBadgeDisplay = SystemExecutionBadge & { hideOnMobile?: boolean; }; type AggregatedRequestStatusSummary = { label: string | null; tone: SystemExecutionBadgeTone; }; type SystemExecutionFilterOption = { value: SystemExecutionFilter; label: string; compactLabel: string; ariaLabel: string; }; type ConversationMessageEntry = | { kind: 'single'; key: string; message: ChatMessage; } | { kind: 'group'; key: string; groupId: string; request: ChatConversationRequest | undefined; requestIds: string[]; messages: ChatMessage[]; }; 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 VERIFICATION_REQUEST_PATTERN = /(검증|확인|점검|테스트|캡처|스크린샷|실화면|재확인)/iu; const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; const IMMEDIATE_SEND_TOGGLE_HOLD_MS = 2000; const SYSTEM_EXECUTION_JUMP_MAX_RETRIES = 4; const MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH = 767; const DEFAULT_QUEUE_SUMMARY_MAX_LENGTH = 32; const TABLET_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 88; const DESKTOP_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 120; type MessageRenderPayload = { previewSourceText: string; visibleText: string; diffBlocks: string[]; rankedLinkTargets: RankedLinkPreviewTarget[]; linkCardTargets: Extract[]; promptTargets: 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 resolveConversationRootRequestId( requestId: string, requestStateMap: Map, ) { let currentRequestId = requestId.trim(); if (!currentRequestId) { return ''; } const visitedRequestIds = new Set([currentRequestId]); let currentRequest = requestStateMap.get(currentRequestId); while (currentRequest) { const parentRequestId = currentRequest.parentRequestId?.trim() || ''; if (!parentRequestId || visitedRequestIds.has(parentRequestId)) { break; } const parentRequest = requestStateMap.get(parentRequestId); if (!parentRequest) { break; } currentRequestId = parentRequest.requestId; currentRequest = parentRequest; visitedRequestIds.add(parentRequestId); } return currentRequestId; } function collectRequestLineageIds( requestId: string, requestStateMap: Map, ) { const normalizedRequestId = requestId.trim(); if (!normalizedRequestId) { return []; } const lineageIds: string[] = []; const visitedRequestIds = new Set(); let currentRequestId = normalizedRequestId; while (currentRequestId && !visitedRequestIds.has(currentRequestId)) { lineageIds.push(currentRequestId); visitedRequestIds.add(currentRequestId); const currentRequest = requestStateMap.get(currentRequestId); const parentRequestId = currentRequest?.parentRequestId?.trim() || ''; if (!parentRequestId) { break; } currentRequestId = parentRequestId; } return lineageIds; } function resolveChildComposerParentRequestId( request: ChatConversationRequest | null | undefined, requestStateMap: Map, ) { let currentRequest = request ?? null; const visitedRequestIds = new Set(); while (currentRequest) { const currentRequestId = currentRequest.requestId.trim(); if (!currentRequestId || visitedRequestIds.has(currentRequestId)) { break; } visitedRequestIds.add(currentRequestId); if (currentRequest.requestOrigin !== 'prompt') { return currentRequestId; } const parentRequestId = currentRequest.parentRequestId?.trim() || ''; if (!parentRequestId) { return currentRequestId; } currentRequest = requestStateMap.get(parentRequestId) ?? null; } return request?.requestId?.trim() || ''; } function resolvePromptSubmissionParentRequestId( requestId: string | null | undefined, requestStateMap: Map, ) { const normalizedRequestId = requestId?.trim() || ''; if (!normalizedRequestId) { return ''; } const request = requestStateMap.get(normalizedRequestId) ?? null; return resolveChildComposerParentRequestId(request, requestStateMap) || normalizedRequestId; } function resolveConversationMessageGroupRequestId( requestId: string | null | undefined, requestStateMap: Map, ) { const normalizedRequestId = requestId?.trim() || ''; if (!normalizedRequestId) { return ''; } return resolveConversationRootRequestId(normalizedRequestId, requestStateMap) || normalizedRequestId; } function getConversationRequestDepth( request: ChatConversationRequest | null | undefined, requestStateMap: Map, ) { if (!request) { return 0; } return Math.max(0, collectRequestLineageIds(request.requestId, requestStateMap).length - 1); } export function classifyInlinePreviewKind(url: string): InlinePreviewKind { return classifyPreviewKind(url); } function isHtmlPreviewUrl(url: string) { const pathname = url.toLowerCase().split('?')[0] ?? ''; return pathname.endsWith('.html') || pathname.endsWith('.htm'); } 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 getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) { const targetRect = target.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); return targetRect.top - containerRect.top + container.scrollTop; } function getContainerScrollPaddingTop(container: HTMLElement) { if (typeof window === 'undefined') { return 0; } const paddingTop = Number.parseFloat(window.getComputedStyle(container).paddingTop || '0'); return Number.isFinite(paddingTop) ? Math.max(0, paddingTop) : 0; } function resolveScrollableAnchorContainer(target: HTMLElement, preferredContainer?: HTMLElement | null) { const candidates: Array = [preferredContainer, target.parentElement]; let currentAncestor = target.parentElement; while (currentAncestor) { candidates.push(currentAncestor); currentAncestor = currentAncestor.parentElement; } for (const candidate of candidates) { if (!(candidate instanceof HTMLElement)) { continue; } const style = window.getComputedStyle(candidate); const overflowY = style.overflowY; const isScrollableOverflow = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'; if (!isScrollableOverflow) { continue; } if (candidate.scrollHeight <= candidate.clientHeight + 1) { continue; } return candidate; } return null; } function isPhoneLikeViewport() { if (typeof window === 'undefined') { return false; } const isNarrowViewport = window.matchMedia?.(`(max-width: ${MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH}px)`).matches === true; const hasCoarsePointer = window.matchMedia?.('(pointer: coarse)').matches === true; const hasTouchPoints = navigator.maxTouchPoints > 0; return isNarrowViewport && (hasCoarsePointer || hasTouchPoints); } function getSystemExecutionJumpTargetPriority( target: ReturnType, ) { if (!target) { return -1; } switch (target.kind) { case 'prompt': return 3; case 'response': return 2; case 'request': return 1; default: return 0; } } function resolvePreviewFileExtension(item: Pick) { const fileName = buildPreviewFileName(item).toLowerCase(); const match = fileName.match(/\.([a-z0-9]{1,16})$/i); return match?.[1] ?? ''; } function renderSystemExecutionFilterIcon(filter: SystemExecutionFilter) { if (filter === 'active') { return ; } if (filter === 'attention') { return ; } return ; } 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 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+)?(?: \(\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 seen = new Set(); const targets: InlinePreviewTarget[] = []; const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => { const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl); const kind = classifyInlinePreviewKind(normalizedUrl); if (kind === 'file') { return; } // Plain HTML artifact paths should stay as text unless the reply explicitly opts into preview rendering. if (!options?.allowHtml && isHtmlPreviewUrl(normalizedUrl)) { return; } if (seen.has(normalizedUrl)) { return; } seen.add(normalizedUrl); targets.push({ url: normalizedUrl, label: buildInlinePreviewLabel(normalizedUrl), kind, }); }; extractHiddenPreviewUrls(text).forEach((matchedUrl) => { pushTarget(matchedUrl, { allowHtml: true }); }); 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 ); }