chore: exclude local resource artifacts from main sync

This commit is contained in:
2026-05-15 10:16:45 +09:00
parent 442879313f
commit d38d022872
504 changed files with 17074 additions and 3642 deletions

View File

@@ -1,6 +1,8 @@
import {
CodeOutlined,
CheckCircleOutlined,
DownOutlined,
EyeOutlined,
ExpandOutlined,
LeftOutlined,
LinkOutlined,
@@ -9,12 +11,18 @@ import {
RightOutlined,
UpOutlined,
} from '@ant-design/icons';
import { App, Button, Input, Modal, Spin, Typography } from 'antd';
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;
@@ -104,6 +112,14 @@ 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>,
@@ -114,6 +130,38 @@ function replacePromptTemplate(
);
}
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[],
@@ -243,7 +291,14 @@ export function buildPromptResponseText(
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, {
@@ -257,6 +312,7 @@ export function buildPromptResponseText(
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)) {
@@ -302,6 +358,26 @@ function isHtmlLikeUrl(url: string) {
}
}
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;
@@ -385,11 +461,22 @@ function isTextLikeContentType(contentType: string | null) {
);
}
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);
@@ -399,12 +486,12 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const shouldFetchTextPreview =
preview?.type === 'markdown' || preview?.type === 'html';
const shouldInspectResourcePreview =
preview?.type === 'resource' && preview.url && canRenderFramePreview(preview.url);
preview?.type === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
if (
!preview ||
preview.content ||
!preview.url ||
!normalizedPreviewUrl ||
(!shouldFetchTextPreview && !shouldInspectResourcePreview)
) {
setIsLoading(false);
@@ -414,7 +501,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const controller = new AbortController();
setIsLoading(true);
void fetch(preview.url, {
void fetch(normalizedPreviewUrl, {
credentials: 'include',
signal: controller.signal,
})
@@ -454,7 +541,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
});
return () => controller.abort();
}, [preview]);
}, [normalizedPreviewUrl, preview]);
return { remoteContent, remoteContentType, isLoading, loadError };
}
@@ -462,23 +549,36 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
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 || '', preview.url) : null;
const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', normalizedPreviewUrl || preview.url) : null;
if (preview.type === 'image' && preview.url) {
return (
if (preview.type === 'image' && normalizedPreviewUrl) {
const imageNode = (
<InlineImage
src={preview.url}
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) {
@@ -504,10 +604,10 @@ function PromptPreviewSurface({
if (preview.type === 'html') {
const htmlContent = remoteContent || '';
if (preview.url && isAppRouteUrl(preview.url)) {
if (normalizedPreviewUrl && isAppRouteUrl(normalizedPreviewUrl)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
prompt iframe으로 . `/api/chat/resources/...html` HTML URL을 .
prompt iframe으로 . `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` HTML URL을 .
</div>
);
}
@@ -515,26 +615,49 @@ function PromptPreviewSurface({
if (isHtmlFallbackPreview(preview, htmlContent)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
fallback HTML이 . `type:"html"` HTML , HTML `type:"resource"` `/api/chat/resources/...html` URL로 .
fallback HTML이 . `type:"html"` HTML , `type:"resource"` `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` HTML .
</div>
);
}
return (
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, preview.url)}
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' && preview.url) {
if (isAppRouteUrl(preview.url)) {
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` .
URL은 resource preview로 . `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` .
</div>
);
}
@@ -542,33 +665,26 @@ function PromptPreviewSurface({
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`) .
fallback HTML이 . `type:"resource"` (`/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html`) .
</div>
);
}
if (shouldRenderAsHtml && htmlDocument) {
return (
<iframe
title={preview.title?.trim() || 'prompt resource preview'}
srcDoc={htmlDocument}
className="app-chat-prompt-card__preview-frame"
sandbox="allow-same-origin"
/>
);
}
if (canRenderFramePreview(preview.url)) {
return (
<iframe
title={preview.title?.trim() || 'prompt resource preview'}
src={preview.url}
className="app-chat-prompt-card__preview-frame"
/>
);
}
return <div className="app-chat-prompt-card__preview-placeholder"> inline preview .</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>;
@@ -583,6 +699,7 @@ function PromptPreviewCard({
}) {
const preview = option.preview;
const { message } = App.useApp();
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
if (!preview) {
return null;
@@ -614,7 +731,7 @@ function PromptPreviewCard({
aria-label={`${option.label} preview 열기`}
onClick={(event) => {
event.stopPropagation();
openChatExternalLink(preview.url ?? '', event);
openChatExternalLink(normalizedPreviewUrl || preview.url || '', event);
}}
/>
) : (
@@ -636,6 +753,15 @@ function PromptPreviewCard({
);
}
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) => {
@@ -705,14 +831,18 @@ function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Recor
}
const selectedValues = meaningfulSelections.flatMap((selection) => selection.selectedValues);
const aggregatedFreeText = meaningfulSelections
.map((selection) => selection.freeText.trim())
.filter(Boolean)
.join('\n\n');
return {
selectedValues,
freeText: '',
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
summaryText: buildPromptDraftSummaryText(target, {
selectedValues,
freeText: '',
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
}),
} satisfies PromptDraftSelection;
@@ -723,12 +853,14 @@ export function ChatPromptCard({
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]);
@@ -740,7 +872,9 @@ export function ChatPromptCard({
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;
@@ -762,8 +896,17 @@ export function ChatPromptCard({
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: [],
@@ -794,6 +937,21 @@ export function ChatPromptCard({
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({});
@@ -841,7 +999,6 @@ export function ChatPromptCard({
return;
}
const trimmedFreeText = submittedSelection?.freeText ?? '';
const payload = buildPromptSelectionPayload(target, stepSelections);
if (!payload) {
@@ -850,7 +1007,7 @@ export function ChatPromptCard({
setIsSubmitting(true);
const isSent = await onSubmit({
text: buildPromptResponseText(target, payload, trimmedFreeText),
text: buildPromptResponseText(target, payload),
mode: activeStep.mode === 'direct' ? 'direct' : target.mode === 'direct' ? 'direct' : 'queue',
});
setIsSubmitting(false);
@@ -860,7 +1017,8 @@ export function ChatPromptCard({
}
setSubmittedSummary(payload.summaryText || buildPromptDraftSummaryText(target, payload));
setSubmittedFreeText(trimmedFreeText);
setSubmittedFreeText(payload.freeText);
onSubmitted?.(payload);
};
return (
@@ -970,6 +1128,26 @@ export function ChatPromptCard({
{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;
@@ -1041,12 +1219,7 @@ export function ChatPromptCard({
{option.description ? (
<span className="app-chat-prompt-card__option-description">{option.description}</span>
) : null}
{option.preview ? (
<PromptPreviewCard
option={option}
onOpenPreview={(nextOption) => setExpandedOptionValue(nextOption.value)}
/>
) : null}
{option.preview ? <span className="app-chat-prompt-card__option-preview-hint"> </span> : null}
</div>
);
})}
@@ -1143,18 +1316,36 @@ export function ChatPromptCard({
</div>
) : null}
</section>
<Modal
<FullscreenPreviewModal
open={Boolean(expandedOption?.preview)}
onCancel={() => setExpandedOptionValue(null)}
footer={null}
width="100vw"
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"
title={null}
>
<div className="app-chat-prompt-card__preview-modal-surface">
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} /> : null}
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} htmlMode={expandedHtmlMode} /> : null}
</div>
</Modal>
</FullscreenPreviewModal>
</>
);
}