2191 lines
74 KiB
TypeScript
Executable File
2191 lines
74 KiB
TypeScript
Executable File
import {
|
|
CloseOutlined,
|
|
CopyOutlined,
|
|
DeleteOutlined,
|
|
DisconnectOutlined,
|
|
DownloadOutlined,
|
|
DownOutlined,
|
|
ExclamationCircleOutlined,
|
|
FullscreenExitOutlined,
|
|
FullscreenOutlined,
|
|
MessageOutlined,
|
|
PaperClipOutlined,
|
|
PlusOutlined,
|
|
RedoOutlined,
|
|
SendOutlined,
|
|
SyncOutlined,
|
|
ThunderboltOutlined,
|
|
UpOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
|
import {
|
|
startTransition,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type ClipboardEvent,
|
|
type ReactNode,
|
|
type RefObject,
|
|
type TouchEvent,
|
|
} from 'react';
|
|
import { InlineImage } from '../../../components/common/InlineImage';
|
|
import { CodexDiffBlock } from '../../../components/previewer';
|
|
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
|
import {
|
|
ChatPreviewBody,
|
|
resolveChatPreviewGlyph,
|
|
resolveChatPreviewKindLabel,
|
|
type ChatPreviewKind,
|
|
} from './ChatPreviewBody';
|
|
import { triggerResourceDownload } from './downloadUtils';
|
|
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
|
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
|
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
|
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
|
|
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
|
import { openChatExternalLink } from './linkNavigation';
|
|
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
|
import { extractChatMessageParts } from './messageParts';
|
|
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types';
|
|
|
|
const KST_TIME_ZONE = 'Asia/Seoul';
|
|
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: KST_TIME_ZONE,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
type ChatTypeOption = {
|
|
value: string;
|
|
label: string;
|
|
description: string;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
type PreviewOption = {
|
|
id: string;
|
|
label: string;
|
|
url: string;
|
|
kind: string;
|
|
};
|
|
|
|
type QueuedRequestOption = {
|
|
requestId: string;
|
|
order: number;
|
|
text: string;
|
|
};
|
|
|
|
type InlinePreviewKind = ChatPreviewKind;
|
|
|
|
type InlinePreviewTarget = {
|
|
url: string;
|
|
label: string;
|
|
kind: InlinePreviewKind;
|
|
};
|
|
|
|
type OpenPreviewTarget =
|
|
| string
|
|
| {
|
|
id: string;
|
|
label: string;
|
|
url: string;
|
|
kind: InlinePreviewKind;
|
|
source?: 'message' | 'context';
|
|
};
|
|
|
|
type PendingComposerUpload = {
|
|
key: string;
|
|
name: string;
|
|
status: 'uploading' | 'uploaded' | 'failed';
|
|
reason?: string;
|
|
};
|
|
|
|
type PreviewFetchError = Error & {
|
|
status?: number;
|
|
};
|
|
|
|
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
|
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
|
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
|
|
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
|
|
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
|
|
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
|
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
|
|
|
type MessageRenderPayload = {
|
|
previewSourceText: string;
|
|
visibleText: string;
|
|
diffBlocks: string[];
|
|
rankedLinkTargets: RankedLinkPreviewTarget[];
|
|
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
|
};
|
|
|
|
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
|
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
|
|
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;
|
|
|
|
function normalizeInlinePreviewUrl(value: string) {
|
|
return normalizeChatResourceUrl(value);
|
|
}
|
|
|
|
function classifyInlinePreviewKind(url: string): InlinePreviewKind {
|
|
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
|
|
|
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
|
|
return 'image';
|
|
}
|
|
|
|
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
|
|
return 'video';
|
|
}
|
|
|
|
if (/\.(md|markdown)$/i.test(pathname)) {
|
|
return 'markdown';
|
|
}
|
|
|
|
if (/\.(diff|patch)$/i.test(pathname)) {
|
|
return 'diff';
|
|
}
|
|
|
|
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
|
|
return 'code';
|
|
}
|
|
|
|
if (/\.(txt|log|csv)$/i.test(pathname)) {
|
|
return 'document';
|
|
}
|
|
|
|
if (/\.pdf$/i.test(pathname)) {
|
|
return 'pdf';
|
|
}
|
|
|
|
return 'file';
|
|
}
|
|
|
|
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
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 buildInlinePreviewLabel(url: string) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
|
|
try {
|
|
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
|
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
|
return fileName || item.label.trim() || item.url;
|
|
} catch {
|
|
return item.label.trim() || item.url;
|
|
}
|
|
}
|
|
|
|
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
|
|
const fileName = buildPreviewFileName(item).toLowerCase();
|
|
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
|
|
return match?.[1] ?? '';
|
|
}
|
|
|
|
function buildResourceChipMeta(item: Pick<PreviewOption, 'url' | 'label' | 'kind'>) {
|
|
const extension = resolvePreviewFileExtension(item);
|
|
|
|
if (extension) {
|
|
return extension.toUpperCase();
|
|
}
|
|
|
|
return resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)
|
|
.replace(/\s+preview$/i, '')
|
|
.replace(/\s+download$/i, '')
|
|
.toUpperCase();
|
|
}
|
|
|
|
function normalizeRankedLinkTitle(value: string) {
|
|
return value
|
|
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function extractRankedLinkTargets(text: string) {
|
|
const lines = String(text ?? '').split('\n');
|
|
const keptLines: string[] = [];
|
|
const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
const pushRankedLink = (title: string, url: string) => {
|
|
const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
|
|
const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
|
|
const key = `${normalizedTitle}::${normalizedUrl}`;
|
|
|
|
if (seen.has(key)) {
|
|
return;
|
|
}
|
|
|
|
seen.add(key);
|
|
rankedLinkTargets.push({
|
|
title: normalizedTitle,
|
|
url: normalizedUrl,
|
|
});
|
|
};
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index] ?? '';
|
|
const trimmedLine = line.trim();
|
|
|
|
if (!trimmedLine) {
|
|
keptLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
|
|
if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
|
|
markdownMatches.forEach((match) => {
|
|
const [, label, href] = match;
|
|
if (href?.trim()) {
|
|
pushRankedLink(label?.trim() || href.trim(), href);
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
|
|
if (!titleMatch) {
|
|
keptLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
const collectedLines = [line];
|
|
const title = titleMatch[1]?.trim() ?? '';
|
|
let url = '';
|
|
let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
|
|
let cursor = index + 1;
|
|
|
|
while (cursor < lines.length) {
|
|
const candidate = lines[cursor] ?? '';
|
|
const trimmedCandidate = candidate.trim();
|
|
|
|
if (!trimmedCandidate) {
|
|
collectedLines.push(candidate);
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
|
|
if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
|
|
break;
|
|
}
|
|
|
|
const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
|
|
if (linkMatch) {
|
|
url = linkMatch[1]?.trim() ?? url;
|
|
collectedLines.push(candidate);
|
|
hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
|
|
if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
|
|
hasRank = true;
|
|
collectedLines.push(candidate);
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (title && url && hasRank) {
|
|
pushRankedLink(title, url);
|
|
index = cursor - 1;
|
|
continue;
|
|
}
|
|
|
|
keptLines.push(...collectedLines);
|
|
index = cursor - 1;
|
|
}
|
|
|
|
return {
|
|
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
|
rankedLinkTargets,
|
|
};
|
|
}
|
|
|
|
function buildComposerFilePickKey(file: File) {
|
|
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
|
}
|
|
|
|
function isClipboardImageFile(file: File) {
|
|
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
|
|
|
if (normalizedType.startsWith('image/')) {
|
|
return true;
|
|
}
|
|
|
|
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
|
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
|
|
}
|
|
|
|
function isGeneratedClipboardImageName(file: File) {
|
|
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
|
|
|
if (!normalizedName) {
|
|
return true;
|
|
}
|
|
|
|
return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName);
|
|
}
|
|
|
|
function getClipboardImageMimeRank(file: File) {
|
|
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
|
|
|
switch (normalizedType) {
|
|
case 'image/png':
|
|
return 0;
|
|
case 'image/jpeg':
|
|
return 1;
|
|
case 'image/webp':
|
|
return 2;
|
|
case 'image/gif':
|
|
return 3;
|
|
case 'image/bmp':
|
|
return 4;
|
|
case 'image/heic':
|
|
case 'image/heif':
|
|
return 5;
|
|
case 'image/tiff':
|
|
case 'image/tif':
|
|
return 6;
|
|
default:
|
|
return 7;
|
|
}
|
|
}
|
|
|
|
function resolvePreferredClipboardImageFiles(files: File[]) {
|
|
if (files.length <= 1) {
|
|
return files;
|
|
}
|
|
const sortedFiles = [...files]
|
|
.sort((left, right) => {
|
|
const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);
|
|
|
|
if (rankDifference !== 0) {
|
|
return rankDifference;
|
|
}
|
|
|
|
return right.size - left.size;
|
|
})
|
|
.slice(0, 1);
|
|
|
|
if (files.every(isGeneratedClipboardImageName)) {
|
|
return sortedFiles;
|
|
}
|
|
|
|
return sortedFiles;
|
|
}
|
|
|
|
function resolveComposerPasteFiles(clipboardData: DataTransfer) {
|
|
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
|
|
.filter((item) => item.kind === 'file')
|
|
.map((item) => item.getAsFile())
|
|
.filter((file): file is File => file instanceof File)
|
|
.filter((file) => file.size > 0);
|
|
const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
|
|
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
|
|
const imageFiles = candidateFiles.filter(isClipboardImageFile);
|
|
const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;
|
|
|
|
return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
|
|
}
|
|
|
|
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
|
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
|
let responseMessage = '';
|
|
|
|
try {
|
|
if (contentType.includes('application/json')) {
|
|
const payload = (await response.json()) as { message?: string };
|
|
responseMessage = String(payload.message ?? '').trim();
|
|
} else {
|
|
responseMessage = (await response.text()).trim();
|
|
}
|
|
} catch {
|
|
responseMessage = '';
|
|
}
|
|
|
|
const statusLabel =
|
|
response.status === 403
|
|
? '이 문서는 현재 권한으로 열 수 없습니다.'
|
|
: response.status === 404
|
|
? '이 문서를 찾을 수 없습니다.'
|
|
: response.status === 401
|
|
? '이 문서를 열기 위한 인증이 필요합니다.'
|
|
: `preview 요청이 실패했습니다. (${response.status})`;
|
|
const detail = responseMessage && responseMessage !== response.statusText ? responseMessage : response.statusText.trim();
|
|
const error = new Error(detail ? `${statusLabel} ${detail}` : statusLabel) as PreviewFetchError;
|
|
error.status = response.status;
|
|
return error;
|
|
}
|
|
|
|
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
|
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
|
const seen = new Set<string>();
|
|
const targets: InlinePreviewTarget[] = [];
|
|
|
|
for (const matchedUrl of matches) {
|
|
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
|
|
const kind = classifyInlinePreviewKind(normalizedUrl);
|
|
|
|
if (kind === 'file') {
|
|
continue;
|
|
}
|
|
|
|
if (seen.has(normalizedUrl)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(normalizedUrl);
|
|
targets.push({
|
|
url: normalizedUrl,
|
|
label: buildInlinePreviewLabel(normalizedUrl),
|
|
kind,
|
|
});
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
function renderMessageInlineParts(line: string): ReactNode[] {
|
|
const renderedParts: ReactNode[] = [];
|
|
let cursor = 0;
|
|
|
|
for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
|
|
const [fullMatch, label, rawHref] = match;
|
|
const start = match.index ?? 0;
|
|
|
|
if (start > cursor) {
|
|
renderedParts.push(line.slice(cursor, start));
|
|
}
|
|
|
|
const href = normalizeInlinePreviewUrl(rawHref.trim());
|
|
renderedParts.push(
|
|
<a
|
|
key={`${href}-${start}`}
|
|
href={href}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
onClick={(event) => {
|
|
openChatExternalLink(href, event);
|
|
}}
|
|
>
|
|
{label.trim() || href}
|
|
</a>,
|
|
);
|
|
cursor = start + fullMatch.length;
|
|
}
|
|
|
|
if (cursor < line.length) {
|
|
renderedParts.push(line.slice(cursor));
|
|
}
|
|
|
|
return renderedParts.length > 0 ? renderedParts : [line];
|
|
}
|
|
|
|
function renderMessageBody(text: string) {
|
|
const lines = text.split('\n');
|
|
|
|
return lines.map((line, index) => {
|
|
const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN);
|
|
|
|
if (imageMatch) {
|
|
const [, alt, rawSrc] = imageMatch;
|
|
const src = normalizeInlinePreviewUrl(rawSrc.trim());
|
|
|
|
return (
|
|
<div key={`img-${index}`} className="app-chat-message__block app-chat-message__block--image">
|
|
<InlineImage
|
|
src={src}
|
|
alt={alt.trim() || 'chat image'}
|
|
className="app-chat-message__inline-image markdown-preview__image"
|
|
fallbackText="이미지 preview를 불러오지 못했습니다."
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!line.length) {
|
|
return <div key={`space-${index}`} className="app-chat-message__block app-chat-message__block--spacer" aria-hidden="true" />;
|
|
}
|
|
|
|
return (
|
|
<div key={`line-${index}`} className="app-chat-message__block">
|
|
{renderMessageInlineParts(line)}
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
|
|
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
|
|
const extractedMessageParts = extractChatMessageParts(message.text);
|
|
const text = extractedMessageParts.strippedText;
|
|
const linkCardTargets = [
|
|
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
|
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
|
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
|
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
|
.map((match) => match[1]?.trim())
|
|
.filter((value): value is string => Boolean(value));
|
|
|
|
const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
|
const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
|
|
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
|
|
|
return {
|
|
previewSourceText,
|
|
visibleText,
|
|
diffBlocks,
|
|
rankedLinkTargets,
|
|
linkCardTargets,
|
|
};
|
|
}
|
|
|
|
function summarizeQueuedText(text: string) {
|
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
|
|
}
|
|
|
|
function normalizeAttachmentName(value: string) {
|
|
return String(value ?? '').trim().toLowerCase();
|
|
}
|
|
|
|
function isActivityLogMessage(message: ChatMessage) {
|
|
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
|
}
|
|
|
|
function getMissingRequestMessageText(message: ChatMessage) {
|
|
if (!isMissingRequestMessage(message)) {
|
|
return '';
|
|
}
|
|
|
|
return message.text.slice(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`.length).trim();
|
|
}
|
|
|
|
function getExecutionFailureMessageText(message: ChatMessage) {
|
|
if (!isExecutionFailureMessage(message)) {
|
|
return '';
|
|
}
|
|
|
|
return message.text.slice(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`.length).trim();
|
|
}
|
|
|
|
function extractActivityLines(message: ChatMessage) {
|
|
return message.text
|
|
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
|
|
.split('\n\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function summarizeActivityLines(lines: string[]) {
|
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
const summary = lines[index]
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.find((line) => line.startsWith('# 이유:') || line.startsWith('# 진행:') || line.startsWith('# 상태:'));
|
|
|
|
if (!summary) {
|
|
continue;
|
|
}
|
|
|
|
return summary.replace(/^#\s*(이유|진행|상태):\s*/u, '').trim();
|
|
}
|
|
|
|
return lines.at(-1) ?? '';
|
|
}
|
|
|
|
function isLikelyCollapsibleMessage(text: string) {
|
|
const normalizedText = String(text ?? '').trim();
|
|
|
|
if (!normalizedText) {
|
|
return false;
|
|
}
|
|
|
|
if (normalizedText.length > COLLAPSIBLE_MESSAGE_CHAR_COUNT) {
|
|
return true;
|
|
}
|
|
|
|
const visualLines = normalizedText
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
return visualLines.length > COLLAPSIBLE_MESSAGE_LINE_COUNT;
|
|
}
|
|
|
|
function formatChatTimestamp(timestamp: string) {
|
|
const normalized = String(timestamp ?? '').trim();
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
if (KST_TIMESTAMP_PATTERN.test(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
const parsed = new Date(normalized);
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return normalized;
|
|
}
|
|
|
|
return KST_DATE_TIME_FORMATTER.format(parsed).replace(',', '');
|
|
}
|
|
|
|
function formatRequestStatusLabel(request: ChatConversationRequest | undefined) {
|
|
switch (request?.status) {
|
|
case 'accepted':
|
|
return '접수됨';
|
|
case 'queued':
|
|
return '대기중';
|
|
case 'started':
|
|
return request.hasResponse ? '응답작성중' : '처리중';
|
|
case 'completed':
|
|
return '완료';
|
|
case 'failed':
|
|
return '실패';
|
|
case 'cancelled':
|
|
return '취소됨';
|
|
case 'removed':
|
|
return '삭제됨';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isTerminalRequestStatus(status: ChatConversationRequest['status'] | undefined) {
|
|
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed';
|
|
}
|
|
|
|
function getRequestDetailText(request: ChatConversationRequest | undefined) {
|
|
if (!request) {
|
|
return '';
|
|
}
|
|
|
|
const normalizedStatusMessage = String(request.statusMessage ?? '').trim();
|
|
|
|
if (!normalizedStatusMessage) {
|
|
return '';
|
|
}
|
|
|
|
if (request.status === 'failed') {
|
|
return normalizedStatusMessage.startsWith('실패')
|
|
? normalizedStatusMessage
|
|
: `실패 사유: ${normalizedStatusMessage}`;
|
|
}
|
|
|
|
if (request.status === 'cancelled') {
|
|
return normalizedStatusMessage.startsWith('취소')
|
|
? normalizedStatusMessage
|
|
: `취소 사유: ${normalizedStatusMessage}`;
|
|
}
|
|
|
|
if (request.status === 'removed') {
|
|
return normalizedStatusMessage;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function InlineMessagePreview({
|
|
target,
|
|
isExpanded,
|
|
hasModalPreview,
|
|
onOpenModalPreview,
|
|
onToggle,
|
|
}: {
|
|
target: InlinePreviewTarget;
|
|
isExpanded: boolean;
|
|
hasModalPreview: boolean;
|
|
onOpenModalPreview: () => void;
|
|
onToggle: () => void;
|
|
}) {
|
|
const [textPreview, setTextPreview] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [previewError, setPreviewError] = useState('');
|
|
const [previewContentType, setPreviewContentType] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!isExpanded) {
|
|
return;
|
|
}
|
|
|
|
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setIsLoading(true);
|
|
setPreviewError('');
|
|
setPreviewContentType('');
|
|
|
|
fetch(target.url, {
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok) {
|
|
throw await createPreviewFetchError(response);
|
|
}
|
|
|
|
setPreviewContentType(response.headers.get('content-type') ?? '');
|
|
const text = await response.text();
|
|
setTextPreview(text.slice(0, 1600));
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
setTextPreview('');
|
|
setPreviewContentType('');
|
|
setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) {
|
|
setIsLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, [isExpanded, target.kind, target.url]);
|
|
|
|
const handleCopyPreview = () => {
|
|
void copyPreviewContent({
|
|
kind: target.kind,
|
|
url: target.url,
|
|
fallbackText: textPreview,
|
|
})
|
|
.then((result) => {
|
|
if (result === 'image') {
|
|
message.success('preview 이미지를 복사했습니다.');
|
|
return;
|
|
}
|
|
|
|
if (result === 'url') {
|
|
message.success('preview 이미지 URL을 복사했습니다.');
|
|
return;
|
|
}
|
|
|
|
message.success('preview 내용을 복사했습니다.');
|
|
})
|
|
.catch((error: unknown) => {
|
|
message.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.');
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
|
|
<div className="app-chat-preview-card__header">
|
|
<div className="app-chat-preview-card__meta">
|
|
<div className="app-chat-preview-card__titles">
|
|
<span className="app-chat-preview-card__label">{target.label}</span>
|
|
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="app-chat-preview-card__actions">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={<CopyOutlined />}
|
|
aria-label="preview 내용 복사"
|
|
onClick={handleCopyPreview}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={hasModalPreview && isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
aria-label={hasModalPreview && isExpanded ? 'preview 100% 닫기' : 'preview 100%'}
|
|
onClick={hasModalPreview ? onOpenModalPreview : onToggle}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={<DownloadOutlined />}
|
|
aria-label="preview 다운로드"
|
|
onClick={() => {
|
|
void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
|
|
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
|
});
|
|
}}
|
|
/>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
className="app-chat-preview-card__toggle"
|
|
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
|
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
|
|
onClick={onToggle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded ? (
|
|
<div className="app-chat-preview-card__body">
|
|
<ChatPreviewBody
|
|
target={target}
|
|
previewText={textPreview}
|
|
isPreviewLoading={isLoading}
|
|
previewError={previewError}
|
|
previewContentType={previewContentType}
|
|
maxMarkdownBlocks={12}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DiffMessagePreview({
|
|
diffText,
|
|
fileCount,
|
|
isExpanded,
|
|
isFullscreen,
|
|
onToggle,
|
|
onToggleFullscreen,
|
|
}: {
|
|
diffText: string;
|
|
fileCount: number;
|
|
isExpanded: boolean;
|
|
isFullscreen: boolean;
|
|
onToggle: () => void;
|
|
onToggleFullscreen: () => void;
|
|
}) {
|
|
const handleCopyDiff = () => {
|
|
void copyText(diffText)
|
|
.then(() => {
|
|
message.success('diff를 복사했습니다.');
|
|
})
|
|
.catch((error: unknown) => {
|
|
message.error(error instanceof Error ? error.message : 'diff를 복사하지 못했습니다.');
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section
|
|
className={`app-chat-preview-card${isExpanded || isFullscreen ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}${
|
|
isFullscreen ? ' app-chat-preview-card--fullscreen' : ''
|
|
}`}
|
|
>
|
|
<div className="app-chat-preview-card__header">
|
|
<div className="app-chat-preview-card__meta">
|
|
<div className="app-chat-preview-card__titles">
|
|
<span className="app-chat-preview-card__label">Codex Diff</span>
|
|
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}개`}</span>
|
|
</div>
|
|
</div>
|
|
<div className="app-chat-preview-card__actions">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={<CopyOutlined />}
|
|
aria-label="diff 복사"
|
|
onClick={handleCopyDiff}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
aria-label={isFullscreen ? 'diff 최대화 해제' : 'diff 최대화'}
|
|
onClick={onToggleFullscreen}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={<DownloadOutlined />}
|
|
aria-label="diff 다운로드"
|
|
onClick={() => {
|
|
downloadTextFile(diffText, 'codex-result.diff', 'text/x-diff;charset=utf-8');
|
|
}}
|
|
/>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
className="app-chat-preview-card__toggle"
|
|
icon={isExpanded || isFullscreen ? <UpOutlined /> : <DownOutlined />}
|
|
aria-label={isExpanded || isFullscreen ? 'diff 접기' : 'diff 펼치기'}
|
|
onClick={onToggle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isExpanded || isFullscreen ? (
|
|
<div className="app-chat-preview-card__body">
|
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
|
<CodexDiffBlock
|
|
diffText={diffText}
|
|
showToolbar={false}
|
|
expandAll={isFullscreen}
|
|
summary={`파일 ${fileCount}개 diff preview`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
type ChatConversationViewProps = {
|
|
viewportRef: RefObject<HTMLDivElement | null>;
|
|
composerRef: RefObject<TextAreaRef | null>;
|
|
visibleMessages: ChatMessage[];
|
|
activeSystemStatus: string | null;
|
|
isSystemStatusPending: boolean;
|
|
showScrollToBottom: boolean;
|
|
copiedMessageId: number | null;
|
|
draft: string;
|
|
composerAttachments: ChatComposerAttachment[];
|
|
requestStateMap: Map<string, ChatConversationRequest>;
|
|
isConversationLoading: boolean;
|
|
conversationLoadingLabel: string;
|
|
hasOlderMessages: boolean;
|
|
isLoadingOlderMessages: boolean;
|
|
isPullToLoadArmed: boolean;
|
|
pullToLoadDistance: number;
|
|
selectedChatTypeId: string | null;
|
|
queuedRequests: QueuedRequestOption[];
|
|
chatTypeOptions: ChatTypeOption[];
|
|
previewItems: PreviewOption[];
|
|
isResourceStripOpen: boolean;
|
|
isComposerDisabled: boolean;
|
|
isMobileViewport: boolean;
|
|
isChatTypeSelectionLocked: boolean;
|
|
isComposerAttachmentUploading: boolean;
|
|
isSendWithoutContextEnabled: boolean;
|
|
onViewportScroll: () => void;
|
|
onViewportTouchEnd: () => void;
|
|
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
|
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
|
|
onDraftChange: (value: string) => void;
|
|
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
|
|
onRemoveComposerAttachment: (attachmentId: string) => void;
|
|
onSelectChatType: (value: string) => void;
|
|
onSend: (draftText?: string) => void;
|
|
onSendImmediate: (draftText?: string) => void;
|
|
onToggleSendWithoutContext: () => void;
|
|
onClearDraft: () => void;
|
|
onScrollToBottom: () => void;
|
|
onToggleResourceStrip: () => void;
|
|
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
|
|
onCopyMessage: (message: ChatMessage) => void;
|
|
onRetryMessage: (message: ChatMessage) => void;
|
|
onCancelMessage: (message: ChatMessage) => void;
|
|
onDeleteRequest: (message: ChatMessage) => void;
|
|
onRemoveQueuedRequest: (requestId: string) => void;
|
|
};
|
|
|
|
export function ChatConversationView({
|
|
viewportRef,
|
|
composerRef,
|
|
visibleMessages,
|
|
activeSystemStatus,
|
|
isSystemStatusPending,
|
|
showScrollToBottom,
|
|
copiedMessageId,
|
|
draft,
|
|
composerAttachments,
|
|
requestStateMap,
|
|
isConversationLoading,
|
|
conversationLoadingLabel,
|
|
hasOlderMessages,
|
|
isLoadingOlderMessages,
|
|
isPullToLoadArmed,
|
|
pullToLoadDistance,
|
|
selectedChatTypeId,
|
|
queuedRequests,
|
|
chatTypeOptions,
|
|
previewItems,
|
|
isResourceStripOpen,
|
|
isComposerDisabled,
|
|
isMobileViewport,
|
|
isChatTypeSelectionLocked,
|
|
isComposerAttachmentUploading,
|
|
isSendWithoutContextEnabled,
|
|
onViewportScroll,
|
|
onViewportTouchEnd,
|
|
onViewportTouchMove,
|
|
onViewportTouchStart,
|
|
onDraftChange,
|
|
onPickComposerFiles,
|
|
onRemoveComposerAttachment,
|
|
onSelectChatType,
|
|
onSend,
|
|
onSendImmediate,
|
|
onToggleSendWithoutContext,
|
|
onClearDraft,
|
|
onScrollToBottom,
|
|
onToggleResourceStrip,
|
|
onOpenPreview,
|
|
onCopyMessage,
|
|
onRetryMessage,
|
|
onCancelMessage,
|
|
onDeleteRequest,
|
|
onRemoveQueuedRequest,
|
|
}: ChatConversationViewProps) {
|
|
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
|
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
|
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
|
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
|
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
|
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
|
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
|
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
|
const [composerDraft, setComposerDraft] = useState(draft);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
|
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
|
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
|
|
const lastReportedDraftRef = useRef(draft);
|
|
|
|
useEffect(() => {
|
|
if (draft === lastReportedDraftRef.current) {
|
|
return;
|
|
}
|
|
|
|
setComposerDraft(draft);
|
|
}, [draft]);
|
|
|
|
useEffect(() => {
|
|
if (composerDraft === lastReportedDraftRef.current) {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
lastReportedDraftRef.current = composerDraft;
|
|
startTransition(() => {
|
|
onDraftChange(composerDraft);
|
|
});
|
|
}, 120);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [composerDraft, onDraftChange]);
|
|
|
|
const orderedMessages = useMemo(() => {
|
|
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
|
|
const requestId = activityMessage.clientRequestId?.trim();
|
|
|
|
if (!requestId) {
|
|
return true;
|
|
}
|
|
|
|
const requestState = requestStateMap.get(requestId);
|
|
const hasCodexResponse = visibleMessages.some(
|
|
(candidate) =>
|
|
candidate.clientRequestId?.trim() === requestId &&
|
|
candidate.author === 'codex' &&
|
|
candidate.text.trim().length > 0 &&
|
|
!isPreparingChatReplyText(candidate.text),
|
|
);
|
|
|
|
return !isTerminalRequestStatus(requestState?.status) || !hasCodexResponse;
|
|
};
|
|
|
|
const latestActivityByRequestId = new Map<string, ChatMessage>();
|
|
const orphanActivityMessages: ChatMessage[] = [];
|
|
const baseMessages = visibleMessages.filter((message) => {
|
|
if (!isActivityLogMessage(message)) {
|
|
return true;
|
|
}
|
|
|
|
const activityKey = message.clientRequestId?.trim();
|
|
|
|
if (!activityKey) {
|
|
orphanActivityMessages.push(message);
|
|
return false;
|
|
}
|
|
|
|
if (shouldDisplayActivityMessage(message)) {
|
|
latestActivityByRequestId.set(activityKey, message);
|
|
}
|
|
return false;
|
|
});
|
|
const insertedActivityRequestIds = new Set<string>();
|
|
const ordered: ChatMessage[] = [];
|
|
|
|
baseMessages.forEach((message) => {
|
|
ordered.push(message);
|
|
|
|
if (message.author !== 'user') {
|
|
return;
|
|
}
|
|
|
|
const requestId = message.clientRequestId?.trim();
|
|
|
|
if (!requestId) {
|
|
return;
|
|
}
|
|
|
|
const activityMessage = latestActivityByRequestId.get(requestId);
|
|
|
|
if (!activityMessage) {
|
|
return;
|
|
}
|
|
|
|
ordered.push(activityMessage);
|
|
insertedActivityRequestIds.add(requestId);
|
|
});
|
|
|
|
latestActivityByRequestId.forEach((message, requestId) => {
|
|
if (!insertedActivityRequestIds.has(requestId)) {
|
|
orphanActivityMessages.push(message);
|
|
}
|
|
});
|
|
|
|
return [...ordered, ...orphanActivityMessages];
|
|
}, [requestStateMap, visibleMessages]);
|
|
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
|
const isChatTypeReadonly = isChatTypeSelectionLocked;
|
|
const visiblePreviewItems = useMemo(() => {
|
|
if (!showLatestResourceOnly) {
|
|
return previewItems;
|
|
}
|
|
|
|
const seenFileNames = new Set<string>();
|
|
return previewItems.filter((item) => {
|
|
const fileName = buildPreviewFileName(item);
|
|
|
|
if (seenFileNames.has(fileName)) {
|
|
return false;
|
|
}
|
|
|
|
seenFileNames.add(fileName);
|
|
return true;
|
|
});
|
|
}, [previewItems, showLatestResourceOnly]);
|
|
|
|
useEffect(() => {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const { body, documentElement } = document;
|
|
const previousBodyOverflow = body.style.overflow;
|
|
const previousHtmlOverflow = documentElement.style.overflow;
|
|
|
|
if (fullscreenPreviewKey) {
|
|
body.style.overflow = 'hidden';
|
|
documentElement.style.overflow = 'hidden';
|
|
}
|
|
|
|
return () => {
|
|
body.style.overflow = previousBodyOverflow;
|
|
documentElement.style.overflow = previousHtmlOverflow;
|
|
};
|
|
}, [fullscreenPreviewKey]);
|
|
|
|
const setActivitySectionRef = (requestId: string, element: HTMLElement | null) => {
|
|
if (element) {
|
|
activitySectionRefs.current.set(requestId, element);
|
|
return;
|
|
}
|
|
|
|
activitySectionRefs.current.delete(requestId);
|
|
};
|
|
|
|
const setMessageBodyRef = (messageId: number, element: HTMLDivElement | null) => {
|
|
if (element) {
|
|
messageBodyRefs.current.set(messageId, element);
|
|
return;
|
|
}
|
|
|
|
messageBodyRefs.current.delete(messageId);
|
|
};
|
|
|
|
const scrollActivitySectionIntoView = (requestId: string) => {
|
|
const section = activitySectionRefs.current.get(requestId);
|
|
if (!section || typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
window.requestAnimationFrame(() => {
|
|
section.scrollIntoView({
|
|
block: 'nearest',
|
|
behavior: 'smooth',
|
|
});
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const requestIdsToCollapse = new Set<string>();
|
|
|
|
orderedMessages.forEach((message) => {
|
|
if (!isActivityLogMessage(message)) {
|
|
return;
|
|
}
|
|
|
|
const requestId = message.clientRequestId?.trim();
|
|
|
|
if (!requestId || autoCollapsedActivityRequestIdsRef.current.has(requestId)) {
|
|
return;
|
|
}
|
|
|
|
const requestState = requestStateMap.get(requestId);
|
|
const hasCodexResponse = orderedMessages.some(
|
|
(candidate) => candidate.clientRequestId === requestId && candidate.author === 'codex' && candidate.text.trim().length > 0,
|
|
);
|
|
|
|
if (isTerminalRequestStatus(requestState?.status) || hasCodexResponse) {
|
|
requestIdsToCollapse.add(requestId);
|
|
}
|
|
});
|
|
|
|
if (requestIdsToCollapse.size === 0) {
|
|
return;
|
|
}
|
|
|
|
requestIdsToCollapse.forEach((requestId) => {
|
|
autoCollapsedActivityRequestIdsRef.current.add(requestId);
|
|
});
|
|
|
|
setCollapsedActivityRequestIds((current) => {
|
|
const next = new Set(current);
|
|
requestIdsToCollapse.forEach((requestId) => {
|
|
next.add(requestId);
|
|
});
|
|
return Array.from(next);
|
|
});
|
|
}, [orderedMessages, requestStateMap]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
let frameId = 0;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
const measureCollapsibleMessages = () => {
|
|
const nextIds: number[] = [];
|
|
|
|
orderedMessages.forEach((message) => {
|
|
if (message.author !== 'user' && message.author !== 'codex') {
|
|
return;
|
|
}
|
|
|
|
if (isLikelyCollapsibleMessage(message.text)) {
|
|
nextIds.push(message.id);
|
|
return;
|
|
}
|
|
|
|
const element = messageBodyRefs.current.get(message.id);
|
|
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const computedStyle = window.getComputedStyle(element);
|
|
const rawLineHeight = Number.parseFloat(computedStyle.lineHeight);
|
|
const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 12 * 1.45;
|
|
const collapsedMaxHeight = lineHeight * COLLAPSIBLE_MESSAGE_LINE_COUNT;
|
|
const fullHeight = element.scrollHeight;
|
|
|
|
if (fullHeight > collapsedMaxHeight + 4) {
|
|
nextIds.push(message.id);
|
|
}
|
|
});
|
|
|
|
setCollapsibleMessageIds((current) => {
|
|
if (current.length === nextIds.length && current.every((id, index) => id === nextIds[index])) {
|
|
return current;
|
|
}
|
|
|
|
return nextIds;
|
|
});
|
|
};
|
|
|
|
const scheduleMeasure = () => {
|
|
window.cancelAnimationFrame(frameId);
|
|
frameId = window.requestAnimationFrame(measureCollapsibleMessages);
|
|
};
|
|
|
|
scheduleMeasure();
|
|
window.addEventListener('resize', scheduleMeasure);
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
resizeObserver = new ResizeObserver(() => {
|
|
scheduleMeasure();
|
|
});
|
|
|
|
orderedMessages.forEach((message) => {
|
|
const element = messageBodyRefs.current.get(message.id);
|
|
|
|
if (element) {
|
|
resizeObserver?.observe(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(frameId);
|
|
window.removeEventListener('resize', scheduleMeasure);
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, [orderedMessages, expandedMessageIds]);
|
|
|
|
useEffect(() => {
|
|
if (isConversationLoading) {
|
|
setShowBusyOverlay(false);
|
|
return;
|
|
}
|
|
|
|
if (!isComposerAttachmentUploading) {
|
|
setShowBusyOverlay(false);
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
setShowBusyOverlay(true);
|
|
}, 350);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [isComposerAttachmentUploading, isConversationLoading]);
|
|
|
|
useEffect(() => {
|
|
if (pendingComposerUploads.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const uploadedAttachmentNames = new Set(
|
|
composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
|
|
);
|
|
const resolvedUploads = pendingComposerUploads.filter((item) => {
|
|
const normalizedName = normalizeAttachmentName(item.name);
|
|
|
|
if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
|
|
return false;
|
|
}
|
|
|
|
return item.status === 'uploaded' || item.status === 'failed';
|
|
});
|
|
|
|
if (resolvedUploads.length > 0) {
|
|
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
|
|
setPendingComposerUploads((current) => current.filter((item) => !resolvedKeys.has(item.key)));
|
|
}
|
|
}, [composerAttachments, pendingComposerUploads]);
|
|
|
|
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
|
|
|
const syncPendingComposerUploads = async (files: File[]) => {
|
|
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
|
|
const nextPendingUploads = files.map((file) => ({
|
|
key: buildComposerFilePickKey(file),
|
|
name: file.name,
|
|
status: 'uploading' as const,
|
|
}));
|
|
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
|
|
|
setPendingComposerUploads((current) => [
|
|
...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
|
|
...nextPendingUploads,
|
|
]);
|
|
|
|
let result: ComposerFilePickResult = { items: [] };
|
|
|
|
try {
|
|
result = (await onPickComposerFiles(files)) ?? { items: [] };
|
|
} catch {
|
|
result = {
|
|
items: nextPendingUploads.map((item) => ({
|
|
key: item.key,
|
|
fileName: item.name,
|
|
status: 'failed',
|
|
})),
|
|
};
|
|
}
|
|
|
|
const resultByKey = new Map<string, ComposerFilePickResult['items'][number]>(
|
|
result.items.map((item) => [item.key, item]),
|
|
);
|
|
|
|
setPendingComposerUploads((current) =>
|
|
current.flatMap((item) => {
|
|
if (!pendingKeys.has(item.key)) {
|
|
return [item];
|
|
}
|
|
|
|
const matched = resultByKey.get(item.key);
|
|
|
|
if (!matched || matched.status === 'failed') {
|
|
return [{ ...item, status: 'failed', reason: matched?.reason }];
|
|
}
|
|
|
|
return [{ ...item, status: 'uploaded', reason: undefined }];
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(event.target.files ?? []);
|
|
event.target.value = '';
|
|
|
|
if (files.length === 0) {
|
|
return;
|
|
}
|
|
|
|
void syncPendingComposerUploads(files);
|
|
};
|
|
|
|
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
const clipboardData = event.clipboardData;
|
|
|
|
if (!clipboardData) {
|
|
return;
|
|
}
|
|
const files = resolveComposerPasteFiles(clipboardData);
|
|
|
|
if (files.length === 0) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
void syncPendingComposerUploads(files);
|
|
};
|
|
|
|
const dismissPendingComposerUpload = (key: string) => {
|
|
setPendingComposerUploads((current) => current.filter((item) => item.key !== key));
|
|
};
|
|
|
|
const composerAttachmentStrip =
|
|
pendingComposerUploads.length > 0 || composerAttachments.length > 0 ? (
|
|
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
|
{pendingComposerUploads.map((upload) => (
|
|
<div
|
|
key={`pending:${upload.key}`}
|
|
className={`app-chat-panel__composer-attachment-chip app-chat-panel__composer-attachment-chip--pending${
|
|
upload.status === 'failed' ? ' app-chat-panel__composer-attachment-chip--failed' : ''
|
|
}`}
|
|
title={upload.status === 'failed' ? upload.reason ?? '업로드 실패' : undefined}
|
|
>
|
|
<span className="app-chat-panel__composer-attachment-name">{upload.name}</span>
|
|
<span className="app-chat-panel__composer-attachment-pending-label">
|
|
{upload.status === 'failed'
|
|
? upload.reason ?? '업로드 실패'
|
|
: upload.status === 'uploaded'
|
|
? '첨부 반영 중'
|
|
: '업로드 중'}
|
|
</span>
|
|
{upload.status === 'failed' ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-panel__composer-attachment-remove"
|
|
icon={<CloseOutlined />}
|
|
aria-label={`${upload.name} 업로드 실패 항목 닫기`}
|
|
onClick={() => {
|
|
dismissPendingComposerUpload(upload.key);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
{composerAttachments.map((attachment) => (
|
|
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
|
|
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-panel__composer-attachment-remove"
|
|
icon={<CloseOutlined />}
|
|
aria-label={`${attachment.name} 첨부 제거`}
|
|
onClick={() => {
|
|
onRemoveComposerAttachment(attachment.id);
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null;
|
|
const composerPlaceholder = isComposerDisabled
|
|
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
|
: isMobileViewport
|
|
? '메시지를 입력하세요.'
|
|
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
|
|
|
const renderActivityCard = (message: ChatMessage) => {
|
|
const requestId = message.clientRequestId?.trim() || String(message.id);
|
|
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
|
|
const lines = extractActivityLines(message);
|
|
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
|
|
const activityCountLabel = `${lines.length}개 로그`;
|
|
|
|
return (
|
|
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
|
|
<section
|
|
ref={(element) => {
|
|
setActivitySectionRef(requestId, element);
|
|
}}
|
|
className={`app-chat-preview-card app-chat-preview-card--activity${
|
|
isExpanded ? ' app-chat-preview-card--activity-expanded' : ' app-chat-preview-card--activity-collapsed'
|
|
}`}
|
|
>
|
|
<div className="app-chat-preview-card__header">
|
|
<div className="app-chat-preview-card__meta">
|
|
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--activity" aria-hidden="true">
|
|
<MessageOutlined />
|
|
</span>
|
|
<div className="app-chat-preview-card__titles">
|
|
<div className="app-chat-activity-card__title-row">
|
|
<span className="app-chat-preview-card__label">활동 로그</span>
|
|
<span className="app-chat-activity-card__badge">{activityCountLabel}</span>
|
|
</div>
|
|
<span className="app-chat-preview-card__kind app-chat-activity-card__request-id">{requestId}</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
className="app-chat-preview-card__toggle"
|
|
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
|
aria-label={isExpanded ? '활동 로그 접기' : '활동 로그 펼치기'}
|
|
iconPosition="end"
|
|
onClick={() => {
|
|
setCollapsedActivityRequestIds((current) => {
|
|
if (current.includes(requestId)) {
|
|
scrollActivitySectionIntoView(requestId);
|
|
return current.filter((currentRequestId) => currentRequestId !== requestId);
|
|
}
|
|
|
|
return [...current, requestId];
|
|
});
|
|
}}
|
|
>
|
|
{isExpanded ? '접기' : '펼치기'}
|
|
</Button>
|
|
</div>
|
|
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
|
|
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
|
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
|
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
|
</div>
|
|
</div>
|
|
{isExpanded ? (
|
|
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity">
|
|
<div className="app-chat-activity-log" role="log" aria-live="polite" aria-label="활동 로그 상세">
|
|
{lines.map((line, lineIndex) => (
|
|
<div key={`${requestId}-${lineIndex}`} className="app-chat-activity-log__item">
|
|
<span className="app-chat-activity-log__index" aria-hidden="true">
|
|
{lineIndex + 1}
|
|
</span>
|
|
<p className="app-chat-activity-log__line">{line}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="app-chat-panel__conversation-view">
|
|
{isConversationLoading ? (
|
|
<div className="app-chat-panel__conversation-loading" aria-live="polite">
|
|
<Spin size="large" />
|
|
<strong>{conversationLoadingLabel}</strong>
|
|
<span>재접속 중에는 채팅방 내용을 다시 맞춘 뒤 자연스럽게 표시합니다.</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{showBusyOverlay ? (
|
|
<div className="app-chat-panel__busy-overlay" aria-live="polite" aria-busy="true">
|
|
<Spin size="large" />
|
|
<strong>{busyOverlayLabel}</strong>
|
|
<span>처리가 끝나면 화면이 바로 갱신됩니다.</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={`app-chat-panel__conversation-view-inner${isConversationLoading ? ' is-loading' : ''}${
|
|
showBusyOverlay ? ' is-busy' : ''
|
|
}`}
|
|
>
|
|
<div className="app-chat-panel__conversation-toolbar">
|
|
<Button
|
|
type={isResourceStripOpen ? 'default' : 'text'}
|
|
size="small"
|
|
className="app-chat-panel__conversation-toggle"
|
|
icon={<PaperClipOutlined />}
|
|
aria-label={isResourceStripOpen ? '채팅 리소스 숨기기' : '채팅 리소스 보기'}
|
|
onClick={onToggleResourceStrip}
|
|
/>
|
|
</div>
|
|
|
|
{isResourceStripOpen ? (
|
|
<div className="app-chat-panel__resource-strip">
|
|
{previewItems.length > 0 ? (
|
|
<>
|
|
<label className="app-chat-panel__resource-strip-filter">
|
|
<Checkbox
|
|
checked={showLatestResourceOnly}
|
|
onChange={(event) => {
|
|
setShowLatestResourceOnly(event.target.checked);
|
|
}}
|
|
>
|
|
같은 파일명은 최종 리소스만 보기
|
|
</Checkbox>
|
|
</label>
|
|
<div className="app-chat-panel__resource-strip-list">
|
|
{visiblePreviewItems.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className="app-chat-panel__resource-chip"
|
|
onClick={() => {
|
|
onOpenPreview(item.id);
|
|
}}
|
|
>
|
|
<span className="app-chat-panel__resource-chip-main">
|
|
<span className="app-chat-panel__resource-chip-icon" aria-hidden="true">
|
|
{resolveChatPreviewGlyph(item.kind as ChatPreviewKind)}
|
|
</span>
|
|
<span className="app-chat-panel__resource-chip-label" title={item.label}>
|
|
{item.label}
|
|
</span>
|
|
</span>
|
|
<span
|
|
className="app-chat-panel__resource-chip-meta"
|
|
aria-label={`${resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)} 형식`}
|
|
>
|
|
{buildResourceChipMeta(item)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<span className="app-chat-panel__resource-strip-empty">
|
|
현재 대화에 바로 열 수 있는 리소스가 없습니다.
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
ref={viewportRef}
|
|
className="app-chat-panel__messages"
|
|
onScroll={onViewportScroll}
|
|
onTouchEnd={onViewportTouchEnd}
|
|
onTouchMove={onViewportTouchMove}
|
|
onTouchStart={onViewportTouchStart}
|
|
>
|
|
{hasOlderMessages || isLoadingOlderMessages || pullToLoadDistance > 0 ? (
|
|
<div
|
|
className={`app-chat-panel__history-loader${
|
|
isLoadingOlderMessages ? ' is-loading' : ''
|
|
}${isPullToLoadArmed ? ' is-armed' : ''}`}
|
|
style={{
|
|
maxHeight: `${Math.max(isLoadingOlderMessages ? 52 : 0, pullToLoadDistance)}px`,
|
|
opacity: isLoadingOlderMessages || pullToLoadDistance > 0 ? 1 : 0.72,
|
|
}}
|
|
>
|
|
<Spin size="small" spinning={isLoadingOlderMessages} />
|
|
<span>
|
|
{isLoadingOlderMessages
|
|
? '이전 대화를 동기화하는 중입니다.'
|
|
: isPullToLoadArmed
|
|
? '손을 놓으면 이전 대화를 더 불러옵니다.'
|
|
: hasOlderMessages
|
|
? '최상단에서 아래로 끌어당겨 이전 대화를 불러오세요.'
|
|
: '이전 대화가 없습니다.'}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{orderedMessages.map((message) => {
|
|
const canCollapseMessage = collapsibleMessageIds.includes(message.id);
|
|
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
|
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
|
const isRecoveredMissingRequest = isMissingRequestMessage(message);
|
|
const isRecoveredExecutionFailure = isExecutionFailureMessage(message);
|
|
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
|
|
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
|
|
}`;
|
|
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
|
|
const renderedText = isRecoveredMissingRequest
|
|
? getMissingRequestMessageText(message)
|
|
: isRecoveredExecutionFailure
|
|
? getExecutionFailureMessageText(message)
|
|
: visibleText;
|
|
|
|
if (isActivityLogMessage(message)) {
|
|
return renderActivityCard(message);
|
|
}
|
|
|
|
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
|
const hasPreviewCards =
|
|
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
|
|
const shouldRenderStandalonePreview =
|
|
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
|
const stackClassName = [
|
|
`app-chat-message-stack app-chat-message-stack--${message.author}`,
|
|
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
|
|
const requestStatusLabel = formatRequestStatusLabel(requestState);
|
|
const requestDetailText = getRequestDetailText(requestState);
|
|
|
|
return (
|
|
<div key={message.id} className={stackClassName}>
|
|
{shouldRenderStandalonePreview ? null : (
|
|
<article
|
|
className={`app-chat-message ${
|
|
isRecoveredMissingRequest || isRecoveredExecutionFailure
|
|
? 'app-chat-message--system-inline'
|
|
: `app-chat-message--${message.author}`
|
|
}`}
|
|
>
|
|
<div className="app-chat-message__header">
|
|
<div className="app-chat-message__header-meta">
|
|
<strong>
|
|
{isRecoveredMissingRequest
|
|
? '원문 누락'
|
|
: isRecoveredExecutionFailure
|
|
? '실행 실패'
|
|
: message.author === 'codex'
|
|
? 'Codex'
|
|
: message.author === 'user'
|
|
? 'You'
|
|
: 'System'}
|
|
</strong>
|
|
<span>{formatChatTimestamp(message.timestamp)}</span>
|
|
{message.author === 'user' && requestStatusLabel ? (
|
|
<span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}>
|
|
<span>{requestStatusLabel}</span>
|
|
</span>
|
|
) : null}
|
|
{message.author === 'user' && message.deliveryStatus === 'retrying' ? (
|
|
<span className="app-chat-message__status app-chat-message__status--retrying" aria-label="재전송 중">
|
|
<SyncOutlined spin />
|
|
<span>{message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'}</span>
|
|
</span>
|
|
) : null}
|
|
{message.author === 'user' && message.deliveryStatus === 'failed' ? (
|
|
<span className="app-chat-message__status app-chat-message__status--failed" aria-label="전송 실패">
|
|
<ExclamationCircleOutlined />
|
|
<span>전송 실패</span>
|
|
</span>
|
|
) : null}
|
|
{message.author === 'user' &&
|
|
(message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? (
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
danger
|
|
className="app-chat-message__cancel"
|
|
icon={<CloseOutlined />}
|
|
onClick={() => {
|
|
onCancelMessage(message);
|
|
}}
|
|
>
|
|
취소
|
|
</Button>
|
|
) : null}
|
|
{message.author === 'user' && message.deliveryStatus === 'failed' ? (
|
|
<>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
className="app-chat-message__retry"
|
|
icon={<RedoOutlined />}
|
|
onClick={() => {
|
|
onRetryMessage(message);
|
|
}}
|
|
>
|
|
재전송
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
{message.author === 'user' &&
|
|
requestState?.canDelete &&
|
|
requestState.status !== 'accepted' ? (
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
className="app-chat-message__retry app-chat-message__delete"
|
|
icon={<DeleteOutlined />}
|
|
aria-label="메시지 삭제"
|
|
onClick={() => {
|
|
onDeleteRequest(message);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{message.author !== 'system' ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-message__header-action"
|
|
icon={<CopyOutlined />}
|
|
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
|
|
onClick={() => {
|
|
onCopyMessage(message);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
ref={(element) => {
|
|
setMessageBodyRef(message.id, element);
|
|
}}
|
|
className={baseMessageBodyClassName}
|
|
>
|
|
{renderedText ? renderMessageBody(renderedText) : null}
|
|
</div>
|
|
{message.author === 'user' && requestDetailText ? (
|
|
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
|
|
{requestDetailText}
|
|
</div>
|
|
) : null}
|
|
{canCollapseMessage ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-message__expand"
|
|
icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
|
|
aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
|
|
onClick={() => {
|
|
setExpandedMessageIds((current) =>
|
|
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
|
|
);
|
|
}}
|
|
>
|
|
{isExpandedMessage ? '접기' : '펼치기'}
|
|
</Button>
|
|
) : null}
|
|
</article>
|
|
)}
|
|
{hasPreviewCards ? (
|
|
<div className="app-chat-message-stack__previews">
|
|
{linkCardTargets.map((target) => (
|
|
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
|
))}
|
|
{rankedLinkTargets.map((target) => (
|
|
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
|
))}
|
|
{diffBlocks.map((diffText, index) => {
|
|
const previewKey = `${message.id}-diff-${index}`;
|
|
|
|
return (
|
|
<DiffMessagePreview
|
|
key={previewKey}
|
|
diffText={diffText}
|
|
fileCount={Math.max(1, Array.from(diffText.matchAll(/^diff --git /gm)).length)}
|
|
isExpanded={expandedPreviewKey === previewKey}
|
|
isFullscreen={fullscreenPreviewKey === previewKey}
|
|
onToggle={() => {
|
|
setExpandedPreviewKey((current) => {
|
|
if (fullscreenPreviewKey === previewKey) {
|
|
setFullscreenPreviewKey(null);
|
|
return null;
|
|
}
|
|
|
|
return current === previewKey ? null : previewKey;
|
|
});
|
|
}}
|
|
onToggleFullscreen={() => {
|
|
setFullscreenPreviewKey((current) => {
|
|
const nextKey = current === previewKey ? null : previewKey;
|
|
|
|
if (nextKey) {
|
|
setExpandedPreviewKey(previewKey);
|
|
}
|
|
|
|
return nextKey;
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
{inlinePreviewTargets.map((target) => {
|
|
const previewKey = `${message.id}-${target.url}`;
|
|
const matchedPreview = previewItemsByUrl.get(target.url);
|
|
|
|
return (
|
|
<InlineMessagePreview
|
|
key={previewKey}
|
|
target={target}
|
|
isExpanded={expandedPreviewKey === previewKey}
|
|
hasModalPreview
|
|
onOpenModalPreview={() => {
|
|
onOpenPreview(
|
|
matchedPreview
|
|
? matchedPreview.id
|
|
: {
|
|
id: previewKey,
|
|
label: target.label,
|
|
url: target.url,
|
|
kind: target.kind,
|
|
source: 'message',
|
|
},
|
|
{ fullscreen: true },
|
|
);
|
|
}}
|
|
onToggle={() => {
|
|
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{activeSystemStatus ? (
|
|
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
|
<div
|
|
className={`app-chat-panel__system-status${
|
|
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
|
}`}
|
|
>
|
|
<span>{activeSystemStatus}</span>
|
|
{isSystemStatusPending ? (
|
|
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
|
<span className="app-chat-panel__system-status-dot" />
|
|
<span className="app-chat-panel__system-status-dot" />
|
|
<span className="app-chat-panel__system-status-dot" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{showScrollToBottom ? (
|
|
<div className="app-chat-panel__scroll-jump">
|
|
<Button type="primary" shape="circle" icon={<DownOutlined />} aria-label="최하단으로 이동" onClick={onScrollToBottom} />
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="app-chat-panel__composer">
|
|
<div className="app-chat-panel__composer-topline">
|
|
<div className="app-chat-panel__composer-utility-buttons">
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
aria-label="파일 첨부"
|
|
onClick={() => {
|
|
fileInputRef.current?.click();
|
|
}}
|
|
disabled={isComposerDisabled}
|
|
loading={isComposerAttachmentUploading}
|
|
/>
|
|
</div>
|
|
<div className="app-chat-panel__composer-type">
|
|
<Select
|
|
value={selectedChatTypeId ?? undefined}
|
|
placeholder="컨텍스트를 선택하세요."
|
|
options={chatTypeOptions.map((option) => ({
|
|
value: option.value,
|
|
disabled: option.disabled,
|
|
label: (
|
|
<div className="app-chat-panel__type-option">
|
|
<span>{option.label}</span>
|
|
</div>
|
|
),
|
|
}))}
|
|
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
|
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
|
|
onChange={onSelectChatType}
|
|
/>
|
|
</div>
|
|
<div className="app-chat-panel__composer-actions">
|
|
<div className="app-chat-panel__composer-action-buttons">
|
|
<Button
|
|
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
|
|
className={`app-chat-panel__composer-contextless-toggle${
|
|
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
|
|
}`}
|
|
icon={<DisconnectOutlined />}
|
|
aria-label={
|
|
isSendWithoutContextEnabled
|
|
? '다음 1회만 문맥 없이 보냄'
|
|
: '다음 전송을 문맥 없이 보내기'
|
|
}
|
|
title={
|
|
isSendWithoutContextEnabled
|
|
? '다음 1회만 문맥 없이 보냄'
|
|
: '다음 전송을 문맥 없이 보내기'
|
|
}
|
|
onClick={onToggleSendWithoutContext}
|
|
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
|
/>
|
|
<Button
|
|
icon={<ThunderboltOutlined />}
|
|
aria-label="즉시 요청"
|
|
onClick={() => {
|
|
lastReportedDraftRef.current = composerDraft;
|
|
onDraftChange(composerDraft);
|
|
onSendImmediate(composerDraft);
|
|
}}
|
|
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
icon={<SendOutlined />}
|
|
aria-label="큐로 보내기"
|
|
onClick={() => {
|
|
lastReportedDraftRef.current = composerDraft;
|
|
onDraftChange(composerDraft);
|
|
onSend(composerDraft);
|
|
}}
|
|
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{chatTypeOptions.length === 0 ? (
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
message="사용 가능한 컨텍스트가 없습니다."
|
|
description="관리 페이지에서 현재 사용자 권한에 맞는 컨텍스트를 등록하거나 권한을 부여하세요."
|
|
/>
|
|
) : null}
|
|
|
|
{composerAttachmentStrip}
|
|
|
|
<div
|
|
className={`app-chat-panel__composer-input-shell${
|
|
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
|
}`}
|
|
>
|
|
{queuedRequests.length > 0 ? (
|
|
<div className="app-chat-panel__composer-queue" aria-live="polite">
|
|
<div className="app-chat-panel__composer-queue-count">
|
|
<span>대기열 {queuedRequests.length}건</span>
|
|
</div>
|
|
<div className="app-chat-panel__composer-queue-list">
|
|
{queuedRequests.map((item) => (
|
|
<div key={item.requestId} className="app-chat-panel__composer-queue-chip">
|
|
<div className="app-chat-panel__composer-queue-chip-main">
|
|
<span className="app-chat-panel__composer-queue-order">{item.order}</span>
|
|
<span className="app-chat-panel__composer-queue-text">{summarizeQueuedText(item.text)}</span>
|
|
</div>
|
|
<div className="app-chat-panel__composer-queue-chip-actions">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
icon={<CloseOutlined />}
|
|
aria-label="대기 요청 취소"
|
|
onClick={() => {
|
|
onRemoveQueuedRequest(item.requestId);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<Input.TextArea
|
|
ref={composerRef}
|
|
value={composerDraft}
|
|
autoSize={false}
|
|
placeholder={composerPlaceholder}
|
|
disabled={isComposerDisabled}
|
|
onChange={(event) => {
|
|
setComposerDraft(event.target.value);
|
|
}}
|
|
onPaste={handleComposerPaste}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
|
|
return;
|
|
}
|
|
|
|
const hasSubmitModifier = event.ctrlKey || event.metaKey;
|
|
if (!hasSubmitModifier) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
if (isComposerAttachmentUploading) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
lastReportedDraftRef.current = event.currentTarget.value;
|
|
onDraftChange(event.currentTarget.value);
|
|
onSend(event.currentTarget.value);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className={`app-chat-panel__composer-clear${composerDraft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
|
|
aria-label="입력창 비우기"
|
|
onClick={onClearDraft}
|
|
disabled={!composerDraft.trim()}
|
|
>
|
|
clear
|
|
</Button>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed"
|
|
className="app-chat-panel__composer-file-input"
|
|
onChange={handleComposerFileChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|