1352 lines
50 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|