365 lines
10 KiB
TypeScript
Executable File
365 lines
10 KiB
TypeScript
Executable File
import {
|
|
CodeOutlined,
|
|
DownloadOutlined,
|
|
EyeOutlined,
|
|
FileMarkdownOutlined,
|
|
FilePdfOutlined,
|
|
FileTextOutlined,
|
|
LinkOutlined,
|
|
PictureOutlined,
|
|
VideoCameraOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
|
|
import { InlineImage } from '../../../components/common/InlineImage';
|
|
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
|
import { CodexDiffBlock } from '../../../components/previewer';
|
|
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
|
|
import { triggerResourceDownload } from './downloadUtils';
|
|
import '../../../components/previewer/PreviewerUI.css';
|
|
|
|
const { Paragraph, Text } = Typography;
|
|
|
|
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
|
|
|
export type ChatPreviewTarget = {
|
|
label: string;
|
|
url: string;
|
|
kind: ChatPreviewKind;
|
|
};
|
|
|
|
export function resolveChatPreviewGlyph(kind: ChatPreviewKind) {
|
|
switch (kind) {
|
|
case 'image':
|
|
return <PictureOutlined />;
|
|
case 'video':
|
|
return <VideoCameraOutlined />;
|
|
case 'markdown':
|
|
return <FileMarkdownOutlined />;
|
|
case 'code':
|
|
case 'diff':
|
|
return <CodeOutlined />;
|
|
case 'document':
|
|
return <FileTextOutlined />;
|
|
case 'pdf':
|
|
return <FilePdfOutlined />;
|
|
default:
|
|
return <LinkOutlined />;
|
|
}
|
|
}
|
|
|
|
export function resolveChatPreviewKindLabel(kind: ChatPreviewKind) {
|
|
switch (kind) {
|
|
case 'image':
|
|
return 'image preview';
|
|
case 'video':
|
|
return 'video preview';
|
|
case 'markdown':
|
|
return 'markdown preview';
|
|
case 'code':
|
|
return 'code preview';
|
|
case 'diff':
|
|
return 'diff preview';
|
|
case 'document':
|
|
return 'document preview';
|
|
case 'pdf':
|
|
return 'pdf preview';
|
|
default:
|
|
return 'resource preview';
|
|
}
|
|
}
|
|
|
|
function resolvePreviewErrorMessage(previewError: string) {
|
|
const normalized = previewError.trim();
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
if (/^\s*403\b/.test(normalized) || normalized.includes('권한으로 열 수 없습니다')) {
|
|
return '권한이 없거나 허용되지 않은 경로입니다. 세션 리소스 경로와 접근 권한을 확인해 주세요.';
|
|
}
|
|
|
|
if (/^\s*404\b/.test(normalized) || normalized.includes('찾을 수 없습니다')) {
|
|
return '파일이 이동되었거나 아직 세션 리소스 경로에 생성되지 않았습니다. 경로를 다시 확인해 주세요.';
|
|
}
|
|
|
|
if (/^\s*401\b/.test(normalized) || normalized.includes('인증이 필요합니다')) {
|
|
return '인증 정보가 없어서 문서를 열 수 없습니다.';
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function resolvePreviewExtension(target: ChatPreviewTarget) {
|
|
const raw = target.label || target.url;
|
|
const normalized = raw.toLowerCase().split('?')[0] ?? '';
|
|
const match = normalized.match(/\.([a-z0-9]+)$/i);
|
|
return match?.[1] ?? '';
|
|
}
|
|
|
|
function resolveCodeLanguage(target: ChatPreviewTarget, previewText: string) {
|
|
const extension = resolvePreviewExtension(target);
|
|
|
|
if (extension === 'tsx' || extension === 'ts') {
|
|
return 'typescript';
|
|
}
|
|
|
|
if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') {
|
|
return 'javascript';
|
|
}
|
|
|
|
if (extension === 'json') {
|
|
return 'json';
|
|
}
|
|
|
|
if (extension === 'css') {
|
|
return 'css';
|
|
}
|
|
|
|
if (extension === 'scss') {
|
|
return 'scss';
|
|
}
|
|
|
|
if (extension === 'html' || extension === 'htm') {
|
|
return 'html';
|
|
}
|
|
|
|
if (extension === 'md' || extension === 'markdown') {
|
|
return 'markdown';
|
|
}
|
|
|
|
if (extension === 'java') {
|
|
return 'java';
|
|
}
|
|
|
|
if (extension === 'kt') {
|
|
return 'kotlin';
|
|
}
|
|
|
|
if (extension === 'py') {
|
|
return 'python';
|
|
}
|
|
|
|
if (extension === 'go') {
|
|
return 'go';
|
|
}
|
|
|
|
if (extension === 'rs') {
|
|
return 'rust';
|
|
}
|
|
|
|
if (extension === 'sql') {
|
|
return 'sql';
|
|
}
|
|
|
|
if (extension === 'sh' || extension === 'bash' || extension === 'zsh') {
|
|
return 'bash';
|
|
}
|
|
|
|
if (extension === 'yml' || extension === 'yaml') {
|
|
return 'yaml';
|
|
}
|
|
|
|
if (extension === 'xml') {
|
|
return 'xml';
|
|
}
|
|
|
|
if (extension === 'diff' || extension === 'patch') {
|
|
return 'diff';
|
|
}
|
|
|
|
if (/^(diff --git|@@\s|--- a\/|\+\+\+ b\/)/m.test(previewText)) {
|
|
return 'diff';
|
|
}
|
|
|
|
return inferCodeLanguage(extension || 'text');
|
|
}
|
|
|
|
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);
|
|
return parsed.origin === window.location.origin && !hasKnownFileExtension;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
type ChatPreviewBodyProps = {
|
|
target: ChatPreviewTarget | null;
|
|
previewText: string;
|
|
isPreviewLoading: boolean;
|
|
previewError: string;
|
|
previewContentType?: string;
|
|
maxMarkdownBlocks?: number;
|
|
};
|
|
|
|
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
|
const extension = resolvePreviewExtension(target);
|
|
const normalizedContentType = previewContentType?.toLowerCase() ?? '';
|
|
const normalizedPreview = previewText.trimStart().toLowerCase();
|
|
|
|
if (extension === 'html' || extension === 'htm' || target.kind === 'markdown') {
|
|
return false;
|
|
}
|
|
|
|
const looksLikeHtml =
|
|
normalizedContentType.includes('text/html') ||
|
|
normalizedPreview.startsWith('<!doctype html') ||
|
|
normalizedPreview.startsWith('<html') ||
|
|
normalizedPreview.includes('<head') ||
|
|
normalizedPreview.includes('<body');
|
|
|
|
return looksLikeHtml;
|
|
}
|
|
|
|
export function ChatPreviewBody({
|
|
target,
|
|
previewText,
|
|
isPreviewLoading,
|
|
previewError,
|
|
previewContentType,
|
|
maxMarkdownBlocks,
|
|
}: ChatPreviewBodyProps) {
|
|
if (!target) {
|
|
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
|
}
|
|
|
|
if (isPreviewLoading) {
|
|
return (
|
|
<div className="app-chat-panel__preview-loading">
|
|
<Spin size="small" />
|
|
<Text type="secondary">preview를 불러오는 중입니다.</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (previewError) {
|
|
return (
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
message="preview를 불러오지 못했습니다."
|
|
description={resolvePreviewErrorMessage(previewError)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (isHtmlFallbackPreview(target, previewText, previewContentType)) {
|
|
return (
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="실제 소스 파일 대신 앱 HTML 화면이 반환되었습니다."
|
|
description={`${target.label} 경로가 raw 파일이 아니라 현재 앱의 fallback HTML을 돌려주고 있습니다. 정적 파일 경로 또는 실제 다운로드 경로를 사용해야 코드 preview가 정확하게 표시됩니다.`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (target.kind === 'image') {
|
|
return (
|
|
<InlineImage
|
|
src={target.url}
|
|
alt={target.label}
|
|
className="app-chat-panel__preview-image"
|
|
fallbackText="이미지 preview를 불러오지 못했습니다."
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (target.kind === 'video') {
|
|
return <video src={target.url} className="app-chat-panel__preview-video" controls playsInline />;
|
|
}
|
|
|
|
if (target.kind === 'pdf') {
|
|
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
|
}
|
|
|
|
if (target.kind === 'markdown') {
|
|
return (
|
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
|
<MarkdownPreviewContent
|
|
content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'}
|
|
maxBlocks={maxMarkdownBlocks}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
|
|
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
|
|
|
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
|
|
return (
|
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
|
<CodexDiffBlock
|
|
diffText={previewText || ''}
|
|
summary={`${target.label} 기준 raw diff preview입니다.`}
|
|
showToolbar={false}
|
|
className="app-chat-panel__preview-diff"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
|
{renderEditorBlock(previewText || '표시할 preview 본문이 없습니다.', resolvedLanguage, 'code')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isAppRouteUrl(target.url)) {
|
|
return (
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="앱 화면 경로는 preview iframe으로 열지 않습니다."
|
|
description="현재 화면 문맥은 이미 WebSocket으로 서버에 전달됩니다. 이 경로를 다시 열면 앱만 새로 렌더링되어 preview처럼 보이지 않을 수 있습니다."
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (canRenderFramePreview(target.url)) {
|
|
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
|
}
|
|
|
|
return (
|
|
<div className="app-chat-panel__preview-file">
|
|
<Paragraph>
|
|
브라우저에서 직접 렌더링하지 않는 형식입니다. 아래 버튼으로 새 탭에서 열거나 바로 다운로드할 수 있습니다.
|
|
</Paragraph>
|
|
<Space wrap>
|
|
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
|
<Button
|
|
type="text"
|
|
aria-label="다운로드"
|
|
icon={<DownloadOutlined />}
|
|
onClick={() => {
|
|
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
|
triggerResourceDownload(target.url, fileName);
|
|
}}
|
|
/>
|
|
</Space>
|
|
</div>
|
|
);
|
|
}
|