feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

@@ -2,11 +2,12 @@ import {
AudioOutlined,
CodeOutlined,
CopyOutlined,
EyeOutlined,
DownloadOutlined,
FileImageOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileTextOutlined,
FullscreenOutlined,
LinkOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
@@ -128,6 +129,81 @@ async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
document.body.removeChild(textarea);
}
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
if (typeof attachment.title === 'string' && attachment.title.trim()) {
return attachment.title.trim();
}
if (attachment.linkUrl) {
try {
const resolvedUrl = new URL(
attachment.linkUrl,
typeof window !== 'undefined' ? window.location.origin : 'https://test.sm-home.cloud/',
);
const fileName = resolvedUrl.pathname.split('/').pop()?.trim();
if (fileName) {
return fileName;
}
} catch {
return `${attachment.key}.txt`;
}
}
return `${attachment.key}.txt`;
}
function resolveAttachmentMimeType(attachment: EvidenceAttachmentItem) {
switch (attachment.kind) {
case 'markdown':
return 'text/markdown;charset=utf-8';
case 'json':
return 'application/json;charset=utf-8';
case 'code':
case 'text':
case 'empty':
return 'text/plain;charset=utf-8';
default:
return 'application/octet-stream';
}
}
function downloadAttachmentValue(attachment: EvidenceAttachmentItem) {
if (typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const fileName = resolveAttachmentDownloadFileName(attachment);
const link = document.createElement('a');
if (attachment.linkUrl) {
link.href = attachment.linkUrl;
if (attachment.kind === 'preview') {
link.target = '_blank';
link.rel = 'noreferrer';
} else {
link.download = fileName;
}
} else {
const blob = new Blob([attachment.value], {
type: resolveAttachmentMimeType(attachment),
});
const objectUrl = URL.createObjectURL(blob);
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
return;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function resolvePreviewerType(kind: EvidenceAttachmentKind) {
switch (kind) {
case 'markdown':
@@ -260,6 +336,14 @@ export function EvidenceAttachmentStrip({
}
}
async function handleDownload(attachment: EvidenceAttachmentItem) {
try {
downloadAttachmentValue(attachment);
} catch {
message.error('다운로드에 실패했습니다.');
}
}
if (attachments.length === 0) {
return (
<div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}>
@@ -308,29 +392,27 @@ export function EvidenceAttachmentStrip({
void handleCopy(attachment);
}}
/>
{attachment.linkUrl ? (
{attachment.linkUrl || attachment.value ? (
<Button
type="link"
type="text"
size="small"
href={attachment.linkUrl}
target="_blank"
rel="noreferrer"
style={{ paddingInline: 0 }}
icon={<LinkOutlined />}
>
</Button>
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
void handleDownload(attachment);
}}
/>
) : null}
{onPreview ? (
<Button
size="small"
icon={<EyeOutlined />}
type="text"
aria-label="최대화"
icon={<FullscreenOutlined />}
onClick={() => {
void onPreview(attachment);
}}
>
</Button>
/>
) : null}
</Space>
}

View File

@@ -1,4 +1,11 @@
import { App, Card, Flex, Modal, Space, Switch, Typography } from 'antd';
import {
CloseOutlined,
CopyOutlined,
DownloadOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
} from '@ant-design/icons';
import { App, Button, Card, Flex, Modal, Space, Switch, Typography } from 'antd';
import { useState } from 'react';
import type { SampleMeta } from '../../../widgets/core';
import {
@@ -96,6 +103,7 @@ export function Sample() {
const { message } = App.useApp();
const [compact, setCompact] = useState(false);
const [selectedAttachment, setSelectedAttachment] = useState<EvidenceAttachmentItem | null>(null);
const [isPreviewExpanded, setIsPreviewExpanded] = useState(false);
return (
<Flex vertical gap={16}>
@@ -130,13 +138,51 @@ export function Sample() {
open={Boolean(selectedAttachment)}
title={selectedAttachment?.title ?? 'Attachment Preview'}
footer={null}
width={1080}
width={isPreviewExpanded ? 'calc(100vw - 32px)' : 1080}
onCancel={() => {
setSelectedAttachment(null);
setIsPreviewExpanded(false);
}}
>
{selectedAttachment ? (
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} />
<Flex vertical gap={12}>
<Flex justify="flex-end" gap={8}>
<Button
aria-label="복사"
icon={<CopyOutlined />}
onClick={async () => {
await navigator.clipboard.writeText(selectedAttachment.copyValue ?? selectedAttachment.value);
message.success(`${String(selectedAttachment.title)} 복사`);
}}
/>
{selectedAttachment.linkUrl ? (
<Button
aria-label="다운로드"
icon={<DownloadOutlined />}
href={selectedAttachment.linkUrl}
target={selectedAttachment.kind === 'preview' ? '_blank' : undefined}
rel={selectedAttachment.kind === 'preview' ? 'noreferrer' : undefined}
download={selectedAttachment.kind === 'preview' ? undefined : true}
/>
) : null}
<Button
aria-label={isPreviewExpanded ? '최대화 해제' : '최대화'}
icon={isPreviewExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => {
setIsPreviewExpanded((previous) => !previous);
}}
/>
<Button
aria-label="닫기"
icon={<CloseOutlined />}
onClick={() => {
setSelectedAttachment(null);
setIsPreviewExpanded(false);
}}
/>
</Flex>
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} />
</Flex>
) : null}
</Modal>
</Flex>

View File

@@ -30,6 +30,7 @@ type CodexDiffBlockProps = {
summary?: string;
emptyDescription?: string;
showToolbar?: boolean;
expandAll?: boolean;
className?: string;
};
@@ -170,30 +171,42 @@ export function CodexDiffBlock({
summary,
emptyDescription = '표시할 diff가 없습니다.',
showToolbar = true,
expandAll = false,
className,
}: CodexDiffBlockProps) {
const diffSections = useMemo(() => parseCodexDiffSections(diffText), [diffText]);
const [expandedDiffPaths, setExpandedDiffPaths] = useState<string[]>(() => diffSections.slice(0, 1).map((section) => section.path));
const [expandedDiffPath, setExpandedDiffPath] = useState<string | null>(() =>
expandAll ? diffSections[0]?.path ?? null : diffSections[0]?.path ?? null,
);
useEffect(() => {
setExpandedDiffPaths((currentPaths) => {
const availablePaths = new Set(diffSections.map((section) => section.path));
const nextPaths = currentPaths.filter((path) => availablePaths.has(path));
if (expandAll) {
setExpandedDiffPath(diffSections[0]?.path ?? null);
return;
}
if (nextPaths.length > 0) {
return nextPaths;
setExpandedDiffPath((currentPath) => {
if (currentPath && diffSections.some((section) => section.path === currentPath)) {
return currentPath;
}
return diffSections[0] ? [diffSections[0].path] : [];
return diffSections[0]?.path ?? null;
});
}, [diffSections]);
}, [diffSections, expandAll]);
if (!diffSections.length) {
return <Empty description={emptyDescription} />;
}
return (
<div className={className ?? 'codex-diff-previewer__diff-list'}>
<div
className={[
className ?? 'codex-diff-previewer__diff-list',
expandAll ? 'codex-diff-previewer__diff-list--expand-all' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="codex-diff-previewer__diff-toolbar">
<Text type="secondary">
{summary ?? `파일 ${diffSections.length}개 기준으로 diff를 분리해 표시합니다.`}
@@ -203,15 +216,15 @@ export function CodexDiffBlock({
<Button
size="small"
onClick={() => {
setExpandedDiffPaths(diffSections.map((section) => section.path));
setExpandedDiffPath(diffSections[0]?.path ?? null);
}}
>
diff
</Button>
<Button
size="small"
onClick={() => {
setExpandedDiffPaths([]);
setExpandedDiffPath(null);
}}
>
@@ -221,7 +234,7 @@ export function CodexDiffBlock({
</div>
{diffSections.map((section) => {
const isExpanded = expandedDiffPaths.includes(section.path);
const isExpanded = expandedDiffPath === section.path;
const displayPath = section.previousPath ? `${section.previousPath} -> ${section.path}` : section.path;
return (
@@ -230,11 +243,7 @@ export function CodexDiffBlock({
type="button"
className="codex-diff-previewer__diff-toggle"
onClick={() => {
setExpandedDiffPaths((currentPaths) =>
currentPaths.includes(section.path)
? currentPaths.filter((path) => path !== section.path)
: [...currentPaths, section.path],
);
setExpandedDiffPath((currentPath) => (currentPath === section.path ? null : section.path));
}}
>
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">

View File

@@ -58,9 +58,10 @@
.codex-diff-previewer__diff-section--expanded {
position: fixed;
inset: 16px;
inset: 0;
z-index: 1250;
border-radius: 20px;
border-radius: 0;
border-inline: 0;
background: #0f172a;
box-shadow:
0 28px 72px rgba(15, 23, 42, 0.38),
@@ -95,7 +96,7 @@
}
.codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
height: calc(100vh - 68px);
height: calc(100vh - 60px);
overflow: auto;
}

View File

@@ -1,5 +1,6 @@
import {
CopyOutlined,
DownloadOutlined,
DownOutlined,
FileImageOutlined,
FileTextOutlined,
@@ -92,7 +93,7 @@ export function CodexDiffPreviewer({
}: CodexDiffPreviewerProps) {
const [messageApi, contextHolder] = message.useMessage();
const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff');
const [expandedSourcePaths, setExpandedSourcePaths] = useState<string[]>(() => files.slice(0, 1).map((file) => file.path));
const [expandedSourcePath, setExpandedSourcePath] = useState<string | null>(() => files[0]?.path ?? null);
const [expandedPreviewPath, setExpandedPreviewPath] = useState<string | null>(null);
const statusCount = useMemo(() => buildStatusCount(files), [files]);
const canShowSource = files.length > 0;
@@ -129,6 +130,24 @@ export function CodexDiffPreviewer({
}
}
function handleDownload(path: string, content: string) {
if (typeof document === 'undefined') {
messageApi.error('다운로드를 사용할 수 없습니다.');
return;
}
const fileName = path.split('/').pop() || 'preview.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
}
function handleFullscreen(path: string) {
setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path));
}
@@ -141,15 +160,12 @@ export function CodexDiffPreviewer({
return;
}
setExpandedSourcePaths((currentPaths) => {
const availablePaths = new Set(files.map((file) => file.path));
const nextPaths = currentPaths.filter((path) => availablePaths.has(path));
if (nextPaths.length > 0) {
return nextPaths;
setExpandedSourcePath((currentPath) => {
if (currentPath && files.some((file) => file.path === currentPath)) {
return currentPath;
}
return files[0] ? [files[0].path] : [];
return files[0]?.path ?? null;
});
}, [canShowSource, diffText, files]);
@@ -231,15 +247,15 @@ export function CodexDiffPreviewer({
<Button
size="small"
onClick={() => {
setExpandedSourcePaths(files.map((file) => file.path));
setExpandedSourcePath(files[0]?.path ?? null);
}}
>
</Button>
<Button
size="small"
onClick={() => {
setExpandedSourcePaths([]);
setExpandedSourcePath(null);
}}
>
@@ -248,7 +264,7 @@ export function CodexDiffPreviewer({
</div>
{files.map((file) => {
const isExpanded = expandedSourcePaths.includes(file.path);
const isExpanded = expandedSourcePath === file.path;
const isPreviewExpanded = expandedPreviewPath === file.path;
const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path;
@@ -266,11 +282,7 @@ export function CodexDiffPreviewer({
type="button"
className="codex-diff-previewer__diff-toggle"
onClick={() => {
setExpandedSourcePaths((currentPaths) =>
currentPaths.includes(file.path)
? currentPaths.filter((path) => path !== file.path)
: [...currentPaths, file.path],
);
setExpandedSourcePath((currentPath) => (currentPath === file.path ? null : file.path));
}}
>
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
@@ -296,6 +308,16 @@ export function CodexDiffPreviewer({
void handleCopy(file.content);
}}
/>
<Button
size="small"
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={(event) => {
event.stopPropagation();
handleDownload(file.path, file.content);
}}
/>
<Button
size="small"
type="text"
@@ -316,6 +338,7 @@ export function CodexDiffPreviewer({
value={file.content}
language={file.language}
imageAlt={file.path.split('/').pop() ?? file.path}
downloadFileName={file.path.split('/').pop() ?? file.path}
height={isPreviewExpanded ? 'calc(100vh - 120px)' : height}
copyable={false}
maximizable={false}

View File

@@ -93,6 +93,15 @@
padding: 4px;
}
.previewer-ui__action-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
padding-inline: 0;
}
.previewer-ui--headerless {
border-color: transparent;
border-radius: 0;
@@ -105,17 +114,18 @@
.previewer-ui--expanded {
position: fixed;
inset: 16px;
inset: 0;
z-index: 1200;
height: auto;
border-radius: 20px;
border-radius: 0;
border-inline: 0;
box-shadow:
0 24px 64px rgba(15, 23, 42, 0.28),
0 12px 28px rgba(15, 23, 42, 0.16);
}
.previewer-ui--expanded .previewer-ui__body {
height: calc(100vh - 32px - 53px) !important;
height: calc(100vh - 53px) !important;
}
.previewer-ui__language-select {

View File

@@ -1,4 +1,4 @@
import { CopyOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, Empty, Select, message } from 'antd';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
@@ -136,6 +136,22 @@ async function copyText(text: string) {
document.body.removeChild(textarea);
}
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
if (typeof document === 'undefined') {
throw new Error('다운로드를 사용할 수 없습니다.');
}
const blob = new Blob([content], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
}
function resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'value'>) {
switch (type) {
case 'json':
@@ -147,6 +163,45 @@ function resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'valu
}
}
function resolveDownloadValue({
type,
value,
downloadValue,
}: Pick<PreviewerUIProps, 'type' | 'value' | 'downloadValue'>) {
if (typeof downloadValue === 'string') {
return downloadValue;
}
if (type === 'image') {
return String(value ?? '');
}
return resolveCopyValue({ type, value });
}
function resolveDownloadFileName({
type,
language,
downloadFileName,
}: Pick<PreviewerUIProps, 'type' | 'language' | 'downloadFileName'>) {
if (downloadFileName?.trim()) {
return downloadFileName.trim();
}
switch (type) {
case 'json':
return 'preview.json';
case 'markdown':
return 'preview.md';
case 'code':
return `preview.${language || 'txt'}`;
case 'image':
return 'preview';
default:
return 'preview.txt';
}
}
function renderContent({
type,
value,
@@ -212,6 +267,10 @@ export function PreviewerUI({
value,
copyValue,
copyable = true,
downloadable = true,
downloadValue,
downloadUrl,
downloadFileName,
maximizable = true,
language = 'text',
format = 'auto',
@@ -226,8 +285,11 @@ export function PreviewerUI({
const [isExpanded, setIsExpanded] = useState(false);
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
const canCopy = copyable && resolvedCopyValue.trim().length > 0;
const shouldShowActions = hasLanguageSelector || canCopy || maximizable || Boolean(toolbar);
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
useEffect(() => {
if (!isExpanded || typeof document === 'undefined') {
@@ -271,6 +333,37 @@ export function PreviewerUI({
setIsExpanded((previous) => !previous);
}
function handleDownload() {
if (!canDownload) {
return;
}
try {
if (downloadUrl) {
if (typeof document === 'undefined') {
throw new Error('다운로드를 사용할 수 없습니다.');
}
const link = document.createElement('a');
link.href = downloadUrl;
link.download = resolvedDownloadFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const mimeType =
type === 'json'
? 'application/json;charset=utf-8'
: type === 'markdown'
? 'text/markdown;charset=utf-8'
: 'text/plain;charset=utf-8';
downloadBlob(resolvedDownloadValue, resolvedDownloadFileName, mimeType);
}
} catch {
messageApi.error('다운로드에 실패했습니다.');
}
}
const actionContent = (
<>
{hasLanguageSelector ? (
@@ -286,15 +379,27 @@ export function PreviewerUI({
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label="복사"
icon={<CopyOutlined />}
onClick={() => void handleCopy()}
/>
) : null}
{canDownload ? (
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={handleDownload}
/>
) : null}
{maximizable ? (
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label={isExpanded ? '최대화 해제' : '최대화'}
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => void toggleFullscreen()}

View File

@@ -16,6 +16,10 @@ export type PreviewerUIProps = {
value?: unknown;
copyValue?: string;
copyable?: boolean;
downloadable?: boolean;
downloadValue?: string;
downloadUrl?: string;
downloadFileName?: string;
maximizable?: boolean;
language?: string;
format?: PreviewerFormat;