Files
ai-code-app/src/app/main/mainChatPanel/ChatPromptCard.tsx

1352 lines
50 KiB
TypeScript

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<ChatMessagePart, { type: 'prompt' }>;
type PromptOption = PromptTarget['options'][number];
type PromptPreview = NonNullable<PromptOption['preview']>;
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<string, string>,
) {
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<Record<string, string>>((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 '<!doctype html><html><body></body></html>';
}
const baseHref = (() => {
try {
return sourceUrl ? new URL('.', sourceUrl).toString() : window.location.href;
} catch {
return typeof window !== 'undefined' ? window.location.href : 'about:blank';
}
})();
const baseTag = `<base href="${baseHref}">`;
if (/<head(\s|>)/i.test(trimmed)) {
return trimmed.replace(/<head(\s*[^>]*)>/i, (match) => `${match}${baseTag}`);
}
if (/<html(\s|>)/i.test(trimmed)) {
return trimmed.replace(/<html(\s*[^>]*)>/i, (match) => `${match}<head>${baseTag}</head>`);
}
return `<!doctype html><html><head>${baseTag}</head><body>${trimmed}</body></html>`;
}
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('<!doctype html') ||
normalizedPreview.startsWith('<html') ||
normalizedPreview.includes('<head') ||
normalizedPreview.includes('<body')) &&
normalizedPreview.includes('<div id="root"')
);
}
function isTextLikeContentType(contentType: string | null) {
if (!contentType) {
return false;
}
const normalized = contentType.toLowerCase();
return (
normalized.startsWith('text/') ||
normalized.includes('json') ||
normalized.includes('xml') ||
normalized.includes('javascript') ||
normalized.includes('svg')
);
}
function resolvePromptPreviewUrl(url?: string | null) {
const normalized = String(url ?? '').trim();
if (!normalized) {
return '';
}
return normalizeChatResourceUrl(normalized);
}
function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const [remoteContent, setRemoteContent] = useState<string | null>(preview?.content ?? null);
const [remoteContentType, setRemoteContentType] = useState<string | null>(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 = (
<InlineImage
src={normalizedPreviewUrl}
alt={preview.alt?.trim() || preview.title?.trim() || 'prompt preview image'}
className={`app-chat-prompt-card__preview-image${compact ? ' app-chat-prompt-card__preview-image--compact' : ''}`}
fallbackText="이미지 preview를 불러오지 못했습니다."
/>
);
if (compact) {
return imageNode;
}
return (
<ZoomablePreviewSurface stageClassName="app-chat-prompt-card__preview-zoom-stage" contentClassName="app-chat-prompt-card__preview-zoom-content">
{imageNode}
</ZoomablePreviewSurface>
);
}
if (isLoading) {
return (
<div className="app-chat-prompt-card__preview-placeholder app-chat-prompt-card__preview-placeholder--loading">
<Spin indicator={<LoadingOutlined spin />} size="small" />
</div>
);
}
if (loadError) {
return <div className="app-chat-prompt-card__preview-placeholder">{loadError}</div>;
}
if (preview.type === 'markdown') {
return (
<div className="app-chat-prompt-card__preview-markdown">
<MarkdownPreviewContent content={remoteContent || '표시할 markdown preview가 없습니다.'} maxBlocks={compact ? 5 : undefined} />
</div>
);
}
if (preview.type === 'html') {
const htmlContent = remoteContent || '';
if (normalizedPreviewUrl && isAppRouteUrl(normalizedPreviewUrl)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
prompt iframe으로 . `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` HTML URL을 .
</div>
);
}
if (isHtmlFallbackPreview(preview, htmlContent)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
fallback HTML이 . `type:"html"` HTML , `type:"resource"` `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` HTML .
</div>
);
}
if (htmlMode === 'source') {
return (
<div className="app-chat-prompt-card__preview-code">
{renderEditorBlock(htmlContent || '표시할 HTML 코드가 없습니다.', 'html', 'code')}
</div>
);
}
const frameNode = (
<iframe
title={preview.title?.trim() || 'prompt html preview'}
srcDoc={htmlDocument ?? buildHtmlFrameDocument(htmlContent, normalizedPreviewUrl || preview.url)}
className="app-chat-prompt-card__preview-frame"
sandbox="allow-same-origin"
/>
);
if (compact) {
return frameNode;
}
return (
<ZoomablePreviewSurface stageClassName="app-chat-prompt-card__preview-zoom-stage" contentClassName="app-chat-prompt-card__preview-zoom-content">
{frameNode}
</ZoomablePreviewSurface>
);
}
if (preview.type === 'resource' && normalizedPreviewUrl) {
const resourceKind =
isMarkdownResourceUrl(normalizedPreviewUrl || preview.url) || isMarkdownContentType(remoteContentType)
? 'markdown'
: classifyPreviewKind(normalizedPreviewUrl);
if (isAppRouteUrl(normalizedPreviewUrl)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
URL은 resource preview로 . `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` .
</div>
);
}
if (remoteContentType?.toLowerCase().includes('text/html') && isHtmlFallbackPreview(preview, remoteContent || '')) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
fallback HTML이 . `type:"resource"` (`/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html`) .
</div>
);
}
return (
<ChatPreviewBody
target={{
label: preview.title?.trim() || preview.alt?.trim() || 'prompt resource preview',
url: normalizedPreviewUrl,
kind: resourceKind,
}}
previewText={remoteContent || ''}
isPreviewLoading={isLoading}
previewError={loadError}
previewContentType={remoteContentType ?? undefined}
maxMarkdownBlocks={compact ? 5 : undefined}
renderHtmlAsFrame={htmlMode !== 'source'}
/>
);
}
return <div className="app-chat-prompt-card__preview-placeholder"> preview가 .</div>;
}
function PromptPreviewCard({
option,
onOpenPreview,
}: {
option: PromptOption;
onOpenPreview: (option: PromptOption) => void;
}) {
const preview = option.preview;
const { message } = App.useApp();
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
if (!preview) {
return null;
}
return (
<div className="app-chat-prompt-card__preview-shell">
<div className="app-chat-prompt-card__preview-toolbar">
<Text className="app-chat-prompt-card__preview-title" ellipsis={{ tooltip: preview.title?.trim() || option.label }}>
{preview.title?.trim() || option.label}
</Text>
<Button
type="text"
size="small"
className="app-chat-prompt-card__preview-tool"
icon={<ExpandOutlined />}
aria-label={`${option.label} preview 확대`}
onClick={(event) => {
event.stopPropagation();
onOpenPreview(option);
}}
/>
{preview.url ? (
<Button
type="text"
size="small"
className="app-chat-prompt-card__preview-tool"
icon={<LinkOutlined />}
aria-label={`${option.label} preview 열기`}
onClick={(event) => {
event.stopPropagation();
openChatExternalLink(normalizedPreviewUrl || preview.url || '', event);
}}
/>
) : (
<Button
type="text"
size="small"
className="app-chat-prompt-card__preview-tool"
icon={<LinkOutlined />}
aria-label={`${option.label} preview 링크 없음`}
onClick={(event) => {
event.stopPropagation();
message.info('이 preview는 내부 본문만 있어 별도 링크가 없습니다.');
}}
/>
)}
</div>
<PromptPreviewSurface preview={preview} compact />
</div>
);
}
export function resolvePromptPreviewOption(
options: PromptOption[],
activePreviewOptionValue: string | null,
selectedValues: string[],
) {
const resolvedValue = resolvePromptPreviewOptionValue(options, activePreviewOptionValue, selectedValues);
return options.find((option) => option.value === resolvedValue) ?? null;
}
function buildInitialStepSelectionMap(target: PromptTarget, steps: PromptStep[]) {
return Object.fromEntries(
steps.map((step) => {
const selectedValues =
steps.length === 1 && step.key === 'default'
? target.selectedValues ?? step.selectedValues ?? []
: step.selectedValues ?? [];
return [
step.key,
{
stepKey: step.key,
stepTitle: step.title,
selectedValues,
freeText: '',
skipped: false,
} satisfies PromptStepDraftSelection,
];
}),
);
}
function resolveCurrentStepIndex(
steps: PromptStep[],
stepSelections: Record<string, PromptStepDraftSelection>,
preferredStepKey?: string | null,
) {
const preferredIndex = preferredStepKey ? steps.findIndex((step) => step.key === preferredStepKey) : -1;
if (preferredIndex >= 0) {
return preferredIndex;
}
const firstIncompleteIndex = steps.findIndex((step) => {
const selection = stepSelections[step.key];
return !selection || (!selection.skipped && selection.selectedValues.length === 0);
});
if (firstIncompleteIndex >= 0) {
return firstIncompleteIndex;
}
return Math.max(steps.length - 1, 0);
}
function canProceedStep(step: PromptStep, selection: PromptStepDraftSelection | undefined) {
if (!step) {
return false;
}
if (step.optional && selection?.skipped) {
return true;
}
return (selection?.selectedValues.length ?? 0) > 0;
}
function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Record<string, PromptStepDraftSelection>) {
const steps = normalizePromptSteps(target);
const draftSelections = steps
.map((step) => stepSelections[step.key])
.filter(Boolean) as PromptStepDraftSelection[];
const meaningfulSelections = draftSelections.filter((selection) => selection.skipped || selection.selectedValues.length > 0 || selection.freeText.trim());
if (meaningfulSelections.length === 0) {
return null;
}
const selectedValues = meaningfulSelections.flatMap((selection) => selection.selectedValues);
const aggregatedFreeText = meaningfulSelections
.map((selection) => selection.freeText.trim())
.filter(Boolean)
.join('\n\n');
return {
selectedValues,
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
summaryText: buildPromptDraftSummaryText(target, {
selectedValues,
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
}),
} satisfies PromptDraftSelection;
}
export function ChatPromptCard({
target,
onSubmit,
readOnly = false,
onSelectionChange,
onSubmitted,
submittedSelection,
}: {
target: PromptTarget;
onSubmit: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
readOnly?: boolean;
onSelectionChange?: (selection: PromptDraftSelection | null) => void;
onSubmitted?: (selection: PromptDraftSelection) => void;
submittedSelection?: PromptDraftSelection | null;
}) {
const steps = useMemo(() => normalizePromptSteps(target), [target]);
const hasStepper = steps.length > 1;
const initialStepSelections = useMemo(() => buildInitialStepSelectionMap(target, steps), [steps, target]);
const [stepSelections, setStepSelections] = useState<Record<string, PromptStepDraftSelection>>(initialStepSelections);
const [activeStepIndex, setActiveStepIndex] = useState(() => resolveCurrentStepIndex(steps, initialStepSelections, target.currentStepKey));
const [isSubmitting, setIsSubmitting] = useState(false);
const [submittedSummary, setSubmittedSummary] = useState('');
const [submittedFreeText, setSubmittedFreeText] = useState('');
const [expandedOptionValue, setExpandedOptionValue] = useState<string | null>(null);
const [expandedHtmlMode, setExpandedHtmlMode] = useState<'preview' | 'source'>('preview');
const [isCollapsed, setIsCollapsed] = useState(false);
const [activePreviewOptionValue, setActivePreviewOptionValue] = useState<string | null>(null);
const resolvedSelectedValues = target.selectedValues ?? [];
const resolvedSelectionSummary = buildSelectionText(target, resolvedSelectedValues);
const isResolved = resolvedSelectedValues.length > 0;
const externallySubmittedSummary =
submittedSelection?.summaryText ||
(submittedSelection ? buildPromptDraftSummaryText(target, submittedSelection) : '');
const submittedFreeTextValue = submittedFreeText || submittedSelection?.freeText.trim() || '';
const displayedSubmittedSummary = submittedSummary || externallySubmittedSummary;
const isLocked =
readOnly || target.readOnly === true || isResolved || Boolean(submittedSummary) || Boolean(externallySubmittedSummary);
const resolvedStateLabel =
target.resolvedBy === 'timeout'
? '시간 초과 자동선택'
: target.resolvedBy === 'system'
? '임의 선택 결과'
: isResolved
? '선택 완료'
: null;
const expandedOption = steps
.flatMap((step) => step.options)
.find((option) => option.value === expandedOptionValue) ?? null;
useEffect(() => {
setExpandedHtmlMode('preview');
}, [expandedOptionValue]);
const activeStep = steps[Math.min(activeStepIndex, Math.max(steps.length - 1, 0))] ?? steps[0];
const activeSelection = activeStep ? stepSelections[activeStep.key] : undefined;
const previewableOptions = useMemo(() => getPreviewablePromptOptions(activeStep), [activeStep]);
const activePreviewOption = useMemo(
() => resolvePromptPreviewOption(previewableOptions, activePreviewOptionValue, activeSelection?.selectedValues ?? []),
[activePreviewOptionValue, activeSelection?.selectedValues, previewableOptions],
);
const selectionSummary = activeStep && activeSelection ? buildStepSelectionText(activeStep, activeSelection.selectedValues) : '';
const displayedSelectionSummary = displayedSubmittedSummary || (hasStepper ? buildPromptDraftSummaryText(target, buildPromptSelectionPayload(target, stepSelections) ?? {
selectedValues: [],
freeText: '',
}) : resolvedSelectionSummary);
const isFinalStep = activeStepIndex >= steps.length - 1;
const canProceed = activeStep ? canProceedStep(activeStep, activeSelection) : false;
const canAdvance = !isLocked && !isSubmitting && canProceed;
const canSubmit = !isLocked && !isSubmitting && isFinalStep && canProceed;
const submitLabel = activeStep?.submitLabel?.trim() || target.submitLabel?.trim() || '선택 전달';
const progressPayload = buildPromptSelectionPayload(target, stepSelections);
const emitSelectionChange = (nextSelections: Record<string, PromptStepDraftSelection>) => {
if (!onSelectionChange) {
return;
}
if (isLocked) {
onSelectionChange(null);
return;
}
onSelectionChange(buildPromptSelectionPayload(target, nextSelections));
};
useEffect(() => {
setStepSelections(initialStepSelections);
setActiveStepIndex(resolveCurrentStepIndex(steps, initialStepSelections, target.currentStepKey));
}, [initialStepSelections, steps, target.currentStepKey]);
useEffect(() => {
if (previewableOptions.length === 0) {
setActivePreviewOptionValue(null);
return;
}
setActivePreviewOptionValue((current) => {
if (current && previewableOptions.some((option) => option.value === current)) {
return current;
}
return null;
});
}, [previewableOptions]);
useEffect(() => {
if (isLocked) {
emitSelectionChange({});
}
}, [isLocked]);
const updateActiveSelection = (updater: (current: PromptStepDraftSelection) => PromptStepDraftSelection) => {
if (!activeStep) {
return;
}
setStepSelections((current) => {
const currentSelection = current[activeStep.key] ?? {
stepKey: activeStep.key,
stepTitle: activeStep.title,
selectedValues: [],
freeText: '',
skipped: false,
};
const nextSelection = updater(currentSelection);
const nextSelections = {
...current,
[activeStep.key]: nextSelection,
};
emitSelectionChange(nextSelections);
return nextSelections;
});
};
const handleStepAdvance = async () => {
if (!activeStep || !activeSelection) {
return;
}
if (!isFinalStep) {
if (!canAdvance) {
return;
}
setActiveStepIndex((current) => Math.min(current + 1, steps.length - 1));
return;
}
if (!canSubmit) {
return;
}
const payload = buildPromptSelectionPayload(target, stepSelections);
if (!payload) {
return;
}
setIsSubmitting(true);
const isSent = await onSubmit({
text: buildPromptResponseText(target, payload),
mode: activeStep.mode === 'direct' ? 'direct' : target.mode === 'direct' ? 'direct' : 'queue',
});
setIsSubmitting(false);
if (!isSent) {
return;
}
setSubmittedSummary(payload.summaryText || buildPromptDraftSummaryText(target, payload));
setSubmittedFreeText(payload.freeText);
onSubmitted?.(payload);
};
return (
<>
<section className="app-chat-preview-card app-chat-preview-card--prompt">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--prompt" aria-hidden="true">
<MessageOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.title}</span>
<span className="app-chat-preview-card__kind">{hasStepper ? 'prompt stepper' : 'prompt'}</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
{submittedSummary ? (
<span className="app-chat-prompt-card__submitted">
<CheckCircleOutlined />
<span></span>
</span>
) : externallySubmittedSummary ? (
<span className="app-chat-prompt-card__submitted">
<CheckCircleOutlined />
<span></span>
</span>
) : resolvedStateLabel ? (
<span className="app-chat-prompt-card__submitted">
<CheckCircleOutlined />
<span>{resolvedStateLabel}</span>
</span>
) : null}
{displayedSelectionSummary ? (
<span className="app-chat-prompt-card__selection-pill" title={displayedSelectionSummary}>
{displayedSelectionSummary}
</span>
) : null}
<Button
type="text"
size="small"
className="app-chat-prompt-card__collapse-toggle"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
aria-label={isCollapsed ? `${target.title} 펼치기` : `${target.title} 접기`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsCollapsed((current) => !current);
}}
/>
</div>
</div>
{!isCollapsed ? (
<div className="app-chat-preview-card__body app-chat-preview-card__body--prompt">
{target.description ? (
<Paragraph className="app-chat-prompt-card__description">{target.description}</Paragraph>
) : null}
{target.resultText ? <Paragraph className="app-chat-prompt-card__result">{target.resultText}</Paragraph> : null}
{hasStepper ? (
<div className="app-chat-prompt-card__stepper">
<StepperUI
steps={steps.map((step, index) => {
const selection = stepSelections[step.key];
const isComplete = selection?.skipped || (selection?.selectedValues.length ?? 0) > 0;
return {
key: step.key,
label: step.title,
description:
selection?.skipped
? '건너뜀'
: isComplete
? buildStepSelectionText(step, selection?.selectedValues ?? []) || step.description
: step.description,
status: index < activeStepIndex ? 'complete' : index === activeStepIndex ? 'current' : 'pending',
};
})}
currentStepKey={activeStep?.key}
direction="vertical"
compact
onStepClick={
isLocked
? undefined
: (_, index) => {
if (index > activeStepIndex + 1) {
return;
}
setActiveStepIndex(index);
}
}
/>
</div>
) : null}
{activeStep ? (
<div className="app-chat-prompt-card__step-panel">
{hasStepper ? (
<div className="app-chat-prompt-card__step-header">
<div>
<Text className="app-chat-prompt-card__step-kicker">
{activeStepIndex + 1}/{steps.length}
</Text>
<Text className="app-chat-prompt-card__step-title">{activeStep.title}</Text>
</div>
{activeStep.optional ? <span className="app-chat-prompt-card__optional-pill"> </span> : null}
</div>
) : null}
{hasStepper && activeStep.description ? (
<Paragraph className="app-chat-prompt-card__description">{activeStep.description}</Paragraph>
) : null}
{previewableOptions.length > 0 ? (
<div className="app-chat-prompt-card__preview-tabs-shell">
<Tabs
size="small"
className="app-chat-prompt-card__preview-tabs"
activeKey={activePreviewOption?.value}
onChange={(nextValue) => setActivePreviewOptionValue(nextValue)}
items={previewableOptions.map((option) => ({
key: option.value,
label: option.label,
}))}
/>
{activePreviewOption?.preview ? (
<PromptPreviewCard
option={activePreviewOption}
onOpenPreview={(nextOption) => setExpandedOptionValue(nextOption.value)}
/>
) : null}
</div>
) : null}
<div className="app-chat-prompt-card__options" role={activeStep.multiple ? 'group' : 'radiogroup'} aria-label={activeStep.title}>
{activeStep.options.map((option) => {
const isSelected = activeSelection?.selectedValues.includes(option.value) ?? false;
const optionInteractionProps = isLocked
? {}
: {
role: 'button' as const,
tabIndex: 0,
onClick: () => {
updateActiveSelection((current) => {
const nextSelectedValues = activeStep.multiple
? current.selectedValues.includes(option.value)
? current.selectedValues.filter((value) => value !== option.value)
: [...current.selectedValues, option.value]
: [option.value];
return {
...current,
selectedValues: nextSelectedValues,
skipped: false,
};
});
},
onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
updateActiveSelection((current) => {
const nextSelectedValues = activeStep.multiple
? current.selectedValues.includes(option.value)
? current.selectedValues.filter((value) => value !== option.value)
: [...current.selectedValues, option.value]
: [option.value];
return {
...current,
selectedValues: nextSelectedValues,
skipped: false,
};
});
},
};
return (
<div
key={`${activeStep.key}-${option.value}`}
className={`app-chat-prompt-card__option${isSelected ? ' app-chat-prompt-card__option--selected' : ''}${
isLocked ? ' app-chat-prompt-card__option--readonly' : ''
}`}
{...optionInteractionProps}
>
<span className="app-chat-prompt-card__option-head">
{activeStep.multiple ? (
<span className="app-chat-prompt-card__checkbox" aria-hidden="true">
<span />
</span>
) : (
<span className="app-chat-prompt-card__radio" aria-hidden="true">
<span />
</span>
)}
<Text className="app-chat-prompt-card__option-label">{option.label}</Text>
{isSelected && (isLocked || Boolean(submittedSummary) || isResolved) ? (
<span className="app-chat-prompt-card__option-state"></span>
) : null}
</span>
{option.description ? (
<span className="app-chat-prompt-card__option-description">{option.description}</span>
) : null}
{option.preview ? <span className="app-chat-prompt-card__option-preview-hint"> </span> : null}
</div>
);
})}
</div>
{!isLocked ? (
<div className="app-chat-prompt-card__free-text">
<Text className="app-chat-prompt-card__free-text-label">
{activeStep.freeTextLabel?.trim() || target.freeTextLabel?.trim() || '기타 요청'}
</Text>
<Input.TextArea
value={activeSelection?.freeText ?? ''}
onChange={(event) => {
const nextFreeText = event.target.value;
updateActiveSelection((current) => ({
...current,
freeText: nextFreeText,
}));
}}
placeholder={
activeStep.freeTextPlaceholder?.trim() ||
target.freeTextPlaceholder?.trim() ||
'선택과 함께 전달할 추가 요청을 입력하세요.'
}
autoSize={{ minRows: 2, maxRows: 5 }}
maxLength={1000}
disabled={isSubmitting}
/>
</div>
) : null}
<div className="app-chat-prompt-card__footer">
<div className="app-chat-prompt-card__summary" aria-live="polite">
{submittedSummary
? `마지막 전달: ${submittedSummary}`
: externallySubmittedSummary
? `마지막 전달: ${externallySubmittedSummary}`
: resolvedSelectionSummary
? target.resolvedBy === 'timeout'
? `시간 초과로 자동 선택: ${resolvedSelectionSummary}`
: `선택 결과: ${resolvedSelectionSummary}`
: readOnly || target.readOnly === true
? '지난 prompt는 읽기 전용입니다.'
: hasStepper
? progressPayload?.summaryText || '단계를 순서대로 선택하세요.'
: selectionSummary
? `선택: ${selectionSummary}`
: '항목을 선택하세요.'}
{submittedFreeTextValue ? (
<span className="app-chat-prompt-card__summary-detail"> : {submittedFreeTextValue}</span>
) : null}
</div>
<div className="app-chat-prompt-card__footer-actions">
{hasStepper ? (
<Button
size="small"
icon={<LeftOutlined />}
onClick={() => setActiveStepIndex((current) => Math.max(0, current - 1))}
disabled={isSubmitting || activeStepIndex === 0}
>
</Button>
) : null}
{hasStepper && activeStep?.optional ? (
<Button
size="small"
onClick={() => {
updateActiveSelection((current) => ({
...current,
selectedValues: [],
skipped: true,
}));
if (!isFinalStep) {
setActiveStepIndex((current) => Math.min(current + 1, steps.length - 1));
}
}}
disabled={isLocked || isSubmitting}
>
</Button>
) : null}
<Button
type="primary"
size="small"
icon={hasStepper && !isFinalStep ? <RightOutlined /> : undefined}
onClick={() => void handleStepAdvance()}
disabled={hasStepper && !isFinalStep ? !canAdvance : !canSubmit}
loading={isSubmitting}
>
{hasStepper && !isFinalStep ? '다음 단계' : submitLabel}
</Button>
</div>
</div>
</div>
) : null}
</div>
) : null}
</section>
<FullscreenPreviewModal
open={Boolean(expandedOption?.preview)}
onClose={() => setExpandedOptionValue(null)}
title={expandedOption?.preview?.title?.trim() || expandedOption?.label || 'preview'}
actions={
canShowHtmlPreviewActions(expandedOption?.preview) ? (
<>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="HTML 실행 미리보기"
icon={<EyeOutlined />}
onClick={() => setExpandedHtmlMode('preview')}
/>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="HTML 코드 보기"
icon={<CodeOutlined />}
onClick={() => setExpandedHtmlMode('source')}
/>
</>
) : null
}
className="app-chat-prompt-card__preview-modal"
>
<div className="app-chat-prompt-card__preview-modal-surface">
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} htmlMode={expandedHtmlMode} /> : null}
</div>
</FullscreenPreviewModal>
</>
);
}