Initial import
This commit is contained in:
303
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file
303
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file
@@ -0,0 +1,303 @@
|
||||
import { DownloadOutlined, EyeOutlined } 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 '../../../components/previewer/PreviewerUI.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
|
||||
export type ChatPreviewTarget = {
|
||||
label: string;
|
||||
url: string;
|
||||
kind: ChatPreviewKind;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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,
|
||||
}: 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={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'code' || target.kind === 'document') {
|
||||
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
||||
|
||||
if (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 href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}>
|
||||
새 탭 열기
|
||||
</Button>
|
||||
<Button href={target.url} download icon={<DownloadOutlined />}>
|
||||
다운로드
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user