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

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>
);
}