import { CodeOutlined, CheckCircleOutlined, DownOutlined, EyeOutlined, ExpandOutlined, LeftOutlined, LinkOutlined, LoadingOutlined, MessageOutlined, RightOutlined, UpOutlined, } from '@ant-design/icons'; import { App, Button, Input, Spin, Tabs, Typography } from 'antd'; import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { StepperUI } from '../../../components/stepper'; import { InlineImage } from '../../../components/common/InlineImage'; import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent'; import { FullscreenPreviewModal, ZoomablePreviewSurface } from '../../../components/previewer'; import { renderEditorBlock } from '../../../components/previewer/renderers'; import { ChatPreviewBody } from './ChatPreviewBody'; import { isMarkdownContentType, isMarkdownResourceUrl, normalizeChatResourceUrl } from './chatResourceUrl'; import { openChatExternalLink } from './linkNavigation'; import { classifyPreviewKind } from './previewKind'; import { resolvePromptPreviewOptionValue } from './promptPreviewState'; import type { ChatMessagePart } from './types'; const { Paragraph, Text } = Typography; type PromptTarget = Extract; type PromptOption = PromptTarget['options'][number]; type PromptPreview = NonNullable; type PromptStep = { key: string; title: string; description?: string | null; submitLabel?: string | null; mode?: 'queue' | 'direct' | null; multiple?: boolean; optional?: boolean; responseTemplate?: string | null; freeTextLabel?: string | null; freeTextPlaceholder?: string | null; selectedValues?: string[]; options: PromptOption[]; }; export type PromptStepDraftSelection = { stepKey: string; stepTitle: string; selectedValues: string[]; freeText: string; skipped?: boolean; }; export type PromptDraftSelection = { selectedValues: string[]; freeText: string; stepSelections?: PromptStepDraftSelection[]; summaryText?: string | null; }; function buildOptionSelectionText(options: PromptOption[], selectedValues: string[]) { const selectedOptions = options.filter((option) => selectedValues.includes(option.value)); return selectedOptions .map((option) => (option.label === option.value ? option.label : `${option.label} (${option.value})`)) .join(', '); } function buildSelectionText(target: PromptTarget, selectedValues: string[]) { return buildOptionSelectionText(target.options, selectedValues); } function normalizePromptSteps(target: PromptTarget): PromptStep[] { if (Array.isArray(target.steps) && target.steps.length > 0) { return target.steps.map((step, index) => ({ key: step.key || `step-${index + 1}`, title: step.title, description: step.description ?? null, submitLabel: step.submitLabel ?? null, mode: step.mode ?? target.mode ?? null, multiple: step.multiple === true, optional: step.optional === true, responseTemplate: step.responseTemplate ?? null, freeTextLabel: step.freeTextLabel ?? null, freeTextPlaceholder: step.freeTextPlaceholder ?? null, selectedValues: step.selectedValues ?? [], options: step.options, })); } return [ { key: 'default', title: target.title, description: target.description ?? null, submitLabel: target.submitLabel ?? null, mode: target.mode ?? null, multiple: target.multiple === true, optional: false, responseTemplate: target.responseTemplate ?? null, freeTextLabel: target.freeTextLabel ?? null, freeTextPlaceholder: target.freeTextPlaceholder ?? null, selectedValues: target.selectedValues ?? [], options: target.options, }, ]; } function buildStepSelectionText(step: PromptStep, selectedValues: string[]) { return buildOptionSelectionText(step.options, selectedValues); } function getPreviewablePromptOptions(step: PromptStep | null | undefined) { if (!step) { return []; } return step.options.filter((option) => option.preview); } function replacePromptTemplate( template: string, replacements: Record, ) { return Object.entries(replacements).reduce( (resolved, [key, value]) => resolved.replaceAll(`{{${key}}}`, value), template, ); } function normalizePromptTemplateKey(value: string) { const normalized = value.trim().replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); return normalized || 'step'; } function buildStepTemplateReplacements(steps: PromptStep[], stepSelections: PromptStepDraftSelection[]) { return stepSelections.reduce>((replacements, selection, index) => { const step = steps.find((candidate) => candidate.key === selection.stepKey) ?? null; const stepKey = normalizePromptTemplateKey(selection.stepKey || step?.key || `step_${index + 1}`); const stepTitle = selection.stepTitle || step?.title || `단계 ${index + 1}`; const selectedOptions = step?.options.filter((option) => selection.selectedValues.includes(option.value)) ?? []; const selectedValues = selection.selectedValues.join(', '); const selectedLabels = selectedOptions.map((option) => option.label).join(', '); const selectionText = step ? buildStepSelectionText(step, selection.selectedValues) : selectedValues; const trimmedFreeText = selection.freeText.trim(); const summaryText = selection.skipped ? `${stepTitle}: 건너뜀` : `${stepTitle}: ${selectionText || '선택 없음'}${trimmedFreeText ? ` / 추가 요청: ${trimmedFreeText.replace(/\n+/g, ' ')}` : ''}`; replacements[`${stepKey}_title`] = stepTitle; replacements[`${stepKey}_value`] = selection.selectedValues[0] ?? ''; replacements[`${stepKey}_values`] = selectedValues; replacements[`${stepKey}_label`] = selectedOptions[0]?.label ?? ''; replacements[`${stepKey}_labels`] = selectedLabels; replacements[`${stepKey}_text`] = selectionText; replacements[`${stepKey}_summary`] = summaryText; replacements[`${stepKey}_free_text`] = trimmedFreeText; replacements[`${stepKey}_free_text_block`] = trimmedFreeText ? `추가 요청:\n${trimmedFreeText}` : ''; return replacements; }, {}); } function normalizePromptDraftSelection( target: PromptTarget, selectionOrSelectedValues: PromptDraftSelection | string[], freeText = '', ) { if (Array.isArray(selectionOrSelectedValues)) { return { selectedValues: selectionOrSelectedValues, freeText, stepSelections: undefined, summaryText: null, } satisfies PromptDraftSelection; } if (selectionOrSelectedValues.stepSelections && selectionOrSelectedValues.stepSelections.length > 0) { return selectionOrSelectedValues; } const steps = normalizePromptSteps(target); if (steps.length <= 1) { return selectionOrSelectedValues; } return { ...selectionOrSelectedValues, stepSelections: [ { stepKey: steps[0]?.key ?? 'default', stepTitle: steps[0]?.title ?? target.title, selectedValues: selectionOrSelectedValues.selectedValues, freeText: selectionOrSelectedValues.freeText, }, ], } satisfies PromptDraftSelection; } function buildPromptDraftSummaryText(target: PromptTarget, draft: PromptDraftSelection) { const steps = normalizePromptSteps(target); if (steps.length <= 1) { return buildSelectionText(target, draft.selectedValues); } const stepSelections = draft.stepSelections ?? []; const completedCount = stepSelections.filter((selection) => selection.skipped || selection.selectedValues.length > 0).length; const totalCount = steps.length; const latestSelection = [...stepSelections].reverse().find((selection) => selection.selectedValues.length > 0); const latestLabel = latestSelection ? buildStepSelectionText( steps.find((step) => step.key === latestSelection.stepKey) ?? steps[0], latestSelection.selectedValues, ) : ''; return latestLabel ? `${completedCount}/${totalCount} 단계 · ${latestLabel}` : `${completedCount}/${totalCount} 단계 선택`; } export function buildPromptResponseText( target: PromptTarget, selectionOrSelectedValues: PromptDraftSelection | string[], freeText = '', ) { const steps = normalizePromptSteps(target); const draft = normalizePromptDraftSelection(target, selectionOrSelectedValues, freeText); if (steps.length <= 1) { const selectedOptions = target.options.filter((option) => draft.selectedValues.includes(option.value)); const selectedLabels = selectedOptions.map((option) => option.label); const selectedValue = draft.selectedValues[0] ?? ''; const selectedLabel = selectedLabels[0] ?? ''; const selectionText = buildSelectionText(target, draft.selectedValues); const trimmedFreeText = draft.freeText.trim(); const template = target.responseTemplate?.trim() || `프롬프트 "${target.title}"에서 "${selectionText}" 항목을 선택했습니다. 이 선택을 기준으로 다음 단계를 이어서 진행해 주세요.`; const resolvedText = replacePromptTemplate(template, { prompt_title: target.title, selection_value: selectedValue, selection_values: draft.selectedValues.join(', '), selection_label: selectedLabel, selection_labels: selectedLabels.join(', '), selection_text: selectionText, custom_text: trimmedFreeText, custom_text_block: trimmedFreeText ? `추가 요청:\n${trimmedFreeText}` : '', step_summaries: selectionText, step_count: draft.selectedValues.length > 0 ? '1' : '0', }); if (!trimmedFreeText || resolvedText.includes(trimmedFreeText)) { return resolvedText; } return `${resolvedText}\n\n추가 요청:\n${trimmedFreeText}`; } const stepSelections = (draft.stepSelections ?? []).filter((selection) => { if (selection.skipped) { return true; } return selection.selectedValues.length > 0 || selection.freeText.trim().length > 0; }); const aggregateSelectedValues = stepSelections.flatMap((selection) => selection.selectedValues); const aggregateSelectedLabels = stepSelections.flatMap((selection) => { const step = steps.find((candidate) => candidate.key === selection.stepKey); if (!step) { return []; } return step.options.filter((option) => selection.selectedValues.includes(option.value)).map((option) => option.label); }); const stepSummaryLines = stepSelections.map((selection, index) => { const step = steps.find((candidate) => candidate.key === selection.stepKey); const stepTitle = selection.stepTitle || step?.title || `단계 ${index + 1}`; if (selection.skipped) { return `${index + 1}. ${stepTitle}: 건너뜀`; } const selectionText = step ? buildStepSelectionText(step, selection.selectedValues) : selection.selectedValues.join(', '); const freeTextSummary = selection.freeText.trim() ? `\n 추가 요청: ${selection.freeText.trim().replace(/\n+/g, ' ')}` : ''; return `${index + 1}. ${stepTitle}: ${selectionText || '선택 없음'}${freeTextSummary}`; }); const trimmedFreeText = draft.freeText.trim(); const lastMeaningfulSelection = [...stepSelections].reverse().find( (selection) => selection.skipped || selection.selectedValues.length > 0 || selection.freeText.trim().length > 0, ); const lastMeaningfulStep = lastMeaningfulSelection ? steps.find((candidate) => candidate.key === lastMeaningfulSelection.stepKey) ?? null : null; const template = lastMeaningfulStep?.responseTemplate?.trim() || target.responseTemplate?.trim() || `프롬프트 "${target.title}" 단계 선택을 정리했습니다.\n{{step_summaries}}\n{{custom_text_block}}`; const resolvedText = replacePromptTemplate(template, { prompt_title: target.title, selection_value: aggregateSelectedValues[0] ?? '', selection_values: aggregateSelectedValues.join(', '), selection_label: aggregateSelectedLabels[0] ?? '', selection_labels: aggregateSelectedLabels.join(', '), selection_text: stepSummaryLines.join('\n'), custom_text: trimmedFreeText, custom_text_block: trimmedFreeText ? `최종 요청:\n${trimmedFreeText}` : '', step_summaries: stepSummaryLines.join('\n'), step_count: String(stepSelections.length), ...buildStepTemplateReplacements(steps, stepSelections), }).trim(); if (!trimmedFreeText || resolvedText.includes(trimmedFreeText)) { return resolvedText; } return `${resolvedText}\n\n최종 요청:\n${trimmedFreeText}`; } function buildHtmlFrameDocument(html: string, sourceUrl?: string | null) { const trimmed = html.trim(); if (!trimmed) { return ''; } const baseHref = (() => { try { return sourceUrl ? new URL('.', sourceUrl).toString() : window.location.href; } catch { return typeof window !== 'undefined' ? window.location.href : 'about:blank'; } })(); const baseTag = ``; if (/)/i.test(trimmed)) { return trimmed.replace(/]*)>/i, (match) => `${match}${baseTag}`); } if (/)/i.test(trimmed)) { return trimmed.replace(/]*)>/i, (match) => `${match}${baseTag}`); } return `${baseTag}${trimmed}`; } function isHtmlLikeUrl(url: string) { try { const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost'); return /\.(html?)$/i.test(parsed.pathname); } catch { return false; } } function canShowHtmlPreviewActions(preview: PromptPreview | null | undefined) { if (!preview) { return false; } if (preview.type === 'html') { return true; } if (preview.type !== 'resource') { return false; } if (preview.content?.trim()) { return /<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(preview.content); } return Boolean(preview.url && isHtmlLikeUrl(preview.url)); } function canRenderFramePreview(url: string) { if (typeof window === 'undefined') { return false; } try { const parsed = new URL(url, window.location.href); return parsed.origin === window.location.origin; } catch { return false; } } function shouldRenderAsHtmlDocument(preview: PromptPreview, remoteContentType: string | null, remoteContent: string | null) { if (preview.type === 'html') { return true; } const normalizedContentType = remoteContentType?.toLowerCase() ?? ''; if (normalizedContentType.includes('text/html')) { return true; } if (preview.url && isHtmlLikeUrl(preview.url)) { return true; } return Boolean(remoteContent && /<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(remoteContent)); } function isAppRouteUrl(url: string) { if (typeof window === 'undefined') { return false; } try { const parsed = new URL(url, window.location.href); const pathname = parsed.pathname.toLowerCase(); const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname); const isInternalResource = pathname.startsWith('/api/chat/resources/') || pathname.startsWith('/public/.codex_chat/') || pathname.startsWith('/.codex_chat/'); return parsed.origin === window.location.origin && !hasKnownFileExtension && !isInternalResource; } catch { return false; } } function isHtmlFallbackPreview(preview: PromptPreview, htmlContent: string) { const normalizedPreview = htmlContent.trimStart().toLowerCase(); if (!normalizedPreview) { return false; } if (preview.url && isAppRouteUrl(preview.url)) { return true; } return ( (normalizedPreview.startsWith('(preview?.content ?? null); const [remoteContentType, setRemoteContentType] = useState(null); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(''); const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url); useEffect(() => { setRemoteContent(preview?.content ?? null); setRemoteContentType(null); setLoadError(''); const shouldFetchTextPreview = preview?.type === 'markdown' || preview?.type === 'html'; const shouldInspectResourcePreview = preview?.type === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl); if ( !preview || preview.content || !normalizedPreviewUrl || (!shouldFetchTextPreview && !shouldInspectResourcePreview) ) { setIsLoading(false); return; } const controller = new AbortController(); setIsLoading(true); void fetch(normalizedPreviewUrl, { credentials: 'include', signal: controller.signal, }) .then(async (response) => { if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`.trim()); } const contentType = response.headers.get('content-type'); if (!controller.signal.aborted) { setRemoteContentType(contentType); } if (shouldInspectResourcePreview && !isTextLikeContentType(contentType)) { return null; } return response.text(); }) .then((text) => { if (!controller.signal.aborted) { setRemoteContent(text); } }) .catch((error: unknown) => { if (controller.signal.aborted) { return; } setLoadError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.'); }) .finally(() => { if (!controller.signal.aborted) { setIsLoading(false); } }); return () => controller.abort(); }, [normalizedPreviewUrl, preview]); return { remoteContent, remoteContentType, isLoading, loadError }; } function PromptPreviewSurface({ preview, compact = false, htmlMode = 'preview', }: { preview: PromptPreview; compact?: boolean; htmlMode?: 'preview' | 'source'; }) { const { remoteContent, remoteContentType, isLoading, loadError } = usePromptPreviewContent(preview); const normalizedPreviewUrl = resolvePromptPreviewUrl(preview.url); const shouldRenderAsHtml = shouldRenderAsHtmlDocument(preview, remoteContentType, remoteContent); const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', normalizedPreviewUrl || preview.url) : null; if (preview.type === 'image' && normalizedPreviewUrl) { const imageNode = ( ); if (compact) { return imageNode; } return ( {imageNode} ); } if (isLoading) { return (
} size="small" />
); } if (loadError) { return
{loadError}
; } if (preview.type === 'markdown') { return (
); } if (preview.type === 'html') { const htmlContent = remoteContent || ''; if (normalizedPreviewUrl && isAppRouteUrl(normalizedPreviewUrl)) { return (
앱 화면 경로는 prompt iframe으로 열지 않습니다. `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` 같은 실제 HTML 리소스 URL을 사용해 주세요.
); } if (isHtmlFallbackPreview(preview, htmlContent)) { return (
현재 앱 fallback HTML이 반환되었습니다. `type:"html"`에는 실제 HTML 본문을 넣거나, `type:"resource"`에 `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` 같은 정적 HTML 리소스를 연결해 주세요.
); } if (htmlMode === 'source') { return (
{renderEditorBlock(htmlContent || '표시할 HTML 코드가 없습니다.', 'html', 'code')}
); } const frameNode = (