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

@@ -1,10 +1,13 @@
import {
CodeOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined,
ExclamationCircleOutlined,
LinkOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
MessageOutlined,
PaperClipOutlined,
PlusOutlined,
@@ -14,11 +17,25 @@ import {
ThunderboltOutlined,
UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Input, Select, Spin } from 'antd';
import { Alert, Button, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type RefObject, type TouchEvent } from 'react';
import { ChatPreviewBody } from './ChatPreviewBody';
import {
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 { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul';
@@ -44,6 +61,7 @@ type ChatTypeOption = {
type PreviewOption = {
id: string;
label: string;
url: string;
kind: string;
};
@@ -53,7 +71,7 @@ type QueuedRequestOption = {
text: string;
};
type InlinePreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
type InlinePreviewKind = ChatPreviewKind;
type InlinePreviewTarget = {
url: string;
@@ -66,9 +84,17 @@ type PreviewFetchError = Error & {
};
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
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 = {
visibleText: string;
diffBlocks: string[];
};
function normalizeInlinePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
@@ -89,6 +115,10 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
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';
}
@@ -104,6 +134,22 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
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);
@@ -143,7 +189,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
@@ -170,6 +216,81 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
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">
{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(text: string): MessageRenderPayload {
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
return {
visibleText,
diffBlocks,
};
}
function summarizeQueuedText(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
@@ -289,10 +410,14 @@ function getRequestDetailText(request: ChatConversationRequest | undefined) {
function InlineMessagePreview({
target,
isExpanded,
hasModalPreview,
onOpenModalPreview,
onToggle,
}: {
target: InlinePreviewTarget;
isExpanded: boolean;
hasModalPreview: boolean;
onOpenModalPreview: () => void;
onToggle: () => void;
}) {
const [textPreview, setTextPreview] = useState('');
@@ -347,26 +472,77 @@ function InlineMessagePreview({
};
}, [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">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<LinkOutlined />
{resolveChatPreviewGlyph(target.kind)}
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.label}</span>
<span className="app-chat-preview-card__kind">{target.kind}</span>
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
</div>
</div>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
onClick={onToggle}
/>
<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 다운로드"
href={target.url}
download
/>
<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 ? (
@@ -384,6 +560,100 @@ function InlineMessagePreview({
);
}
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">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<CodeOutlined />
</span>
<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>;
@@ -418,9 +688,10 @@ type ChatConversationViewProps = {
onSelectChatType: (value: string) => void;
onSend: () => void;
onSendImmediate: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
onToggleResourceStrip: () => void;
onOpenPreview: (previewId: string) => void;
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void;
@@ -462,6 +733,7 @@ export function ChatConversationView({
onSelectChatType,
onSend,
onSendImmediate,
onClearDraft,
onScrollToBottom,
onToggleResourceStrip,
onOpenPreview,
@@ -473,34 +745,88 @@ export function ChatConversationView({
}: 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 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 orderedMessages = useMemo(() => {
const lastActivityIndexByKey = new Map<string, number>();
visibleMessages.forEach((message, index) => {
if (!isActivityLogMessage(message)) {
return;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
lastActivityIndexByKey.set(activityKey, index);
});
return visibleMessages.filter((message, index) => {
const latestActivityByRequestId = new Map<string, ChatMessage>();
const orphanActivityMessages: ChatMessage[] = [];
const baseMessages = visibleMessages.filter((message) => {
if (!isActivityLogMessage(message)) {
return true;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
return lastActivityIndexByKey.get(activityKey) === index;
const activityKey = message.clientRequestId?.trim();
if (!activityKey) {
orphanActivityMessages.push(message);
return false;
}
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];
}, [visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
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) {
@@ -651,6 +977,28 @@ export function ChatConversationView({
};
}, [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]);
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
@@ -662,6 +1010,32 @@ export function ChatConversationView({
void onPickComposerFiles(files);
};
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const clipboardData = event.clipboardData;
if (!clipboardData) {
return;
}
const itemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []);
if (files.length === 0) {
return;
}
event.preventDefault();
const uniqueFiles = Array.from(
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
);
void onPickComposerFiles(uniqueFiles);
};
const renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id);
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
@@ -748,7 +1122,19 @@ export function ChatConversationView({
</div>
) : null}
<div className={`app-chat-panel__conversation-view-inner${isConversationLoading ? ' is-loading' : ''}`}>
{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'}
@@ -821,141 +1207,198 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
}
const inlinePreviewTargets = extractInlinePreviewTargets(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.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={`app-chat-message-stack app-chat-message-stack--${message.author}`}>
<article className={`app-chat-message app-chat-message--${message.author}`}>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{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' ? (
<>
<div key={message.id} className={stackClassName}>
{shouldRenderStandalonePreview ? null : (
<article className={`app-chat-message app-chat-message--${message.author}`}>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{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"
className="app-chat-message__retry"
icon={<RedoOutlined />}
danger
className="app-chat-message__cancel"
icon={<CloseOutlined />}
onClick={() => {
onRetryMessage(message);
onCancelMessage(message);
}}
>
</Button>
</>
) : null}
{message.author === 'user' &&
requestState?.canDelete &&
requestState.status !== 'accepted' ? (
) : 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="link"
type="text"
size="small"
className="app-chat-message__retry app-chat-message__delete"
icon={<DeleteOutlined />}
aria-label="메시지 삭제"
className="app-chat-message__header-action"
icon={<CopyOutlined />}
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
onClick={() => {
onDeleteRequest(message);
onCopyMessage(message);
}}
/>
) : null}
</div>
{message.author !== 'system' ? (
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{visibleText ? renderMessageBody(visibleText) : 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__header-action"
icon={<CopyOutlined />}
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '메시지 복사' : '답변 복사'}
className="app-chat-message__expand"
icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
onClick={() => {
onCopyMessage(message);
setExpandedMessageIds((current) =>
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
);
}}
/>
>
{isExpandedMessage ? '접기' : '펼치기'}
</Button>
) : null}
</div>
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{message.text}
</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>
{inlinePreviewTargets.length > 0 ? (
</article>
)}
{hasPreviewCards ? (
<div className="app-chat-message-stack__previews">
{inlinePreviewTargets.map((target) => (
<InlineMessagePreview
key={`${message.id}-${target.url}`}
target={target}
isExpanded={expandedPreviewKey === `${message.id}-${target.url}`}
onToggle={() => {
const nextKey = `${message.id}-${target.url}`;
setExpandedPreviewKey((current) => (current === nextKey ? null : nextKey));
}}
/>
))}
{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={Boolean(matchedPreview)}
onOpenModalPreview={() => {
if (matchedPreview) {
onOpenPreview(matchedPreview.id, { fullscreen: true });
return;
}
}}
onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
}}
/>
);
})}
</div>
) : null}
</div>
@@ -1094,6 +1537,7 @@ export function ChatConversationView({
onChange={(event) => {
onDraftChange(event.target.value);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return;
@@ -1113,6 +1557,16 @@ export function ChatConversationView({
onSend();
}}
/>
<Button
type="text"
size="small"
className={`app-chat-panel__composer-clear${draft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
aria-label="입력창 비우기"
onClick={onClearDraft}
disabled={!draft.trim()}
>
clear
</Button>
<input
ref={fileInputRef}

View File

@@ -1,14 +1,25 @@
import { DownloadOutlined, EyeOutlined } from '@ant-design/icons';
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' | 'document' | 'pdf' | 'file';
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
export type ChatPreviewTarget = {
label: string;
@@ -16,6 +27,47 @@ export type ChatPreviewTarget = {
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();
@@ -247,10 +299,10 @@ export function ChatPreviewBody({
);
}
if (target.kind === 'code' || target.kind === 'document') {
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (resolvedLanguage === 'diff') {
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
<CodexDiffBlock
@@ -291,12 +343,16 @@ export function ChatPreviewBody({
. .
</Paragraph>
<Space wrap>
<Button href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}>
</Button>
<Button href={target.url} download icon={<DownloadOutlined />}>
</Button>
<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>
);

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
import { getRegisteredAccessToken } from '../tokenAccess';
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
import { reportClientError } from '../errorLogApi';
import type {
ChatActivityEvent,
@@ -17,28 +17,66 @@ import type {
ChatViewContext,
} from './types';
const CONNECT_TIMEOUT_MS = 8000;
const CONNECT_TIMEOUT_MS = 20000;
const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id';
const CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:';
const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
const CHAT_SESSION_MESSAGES_STORAGE_PREFIX = 'main-chat-panel:messages:';
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const KST_TIME_ZONE = 'Asia/Seoul';
const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500;
const chatSessionLastTypeMemory = new Map<string, string>();
const chatLastEventIdMemory = new Map<string, number>();
const chatOfflineNotificationMemory = new Map<string, boolean>();
let chatClientSessionIdMemory = '';
let localMessageSequence = 0;
let cachedChatConversationList: ChatConversationSummary[] | null = null;
let cachedChatConversationListAt = 0;
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;
export function invalidateChatConversationListCache() {
cachedChatConversationList = null;
cachedChatConversationListAt = 0;
chatConversationListRequestPromise = null;
}
function toConversationSortTime(value: string | null | undefined) {
if (typeof value !== 'string' || !value.trim()) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
return [...items].sort((left, right) => {
const leftTime = Math.max(
toConversationSortTime(left.lastMessageAt),
toConversationSortTime(left.updatedAt),
toConversationSortTime(left.createdAt),
);
const rightTime = Math.max(
toConversationSortTime(right.lastMessageAt),
toConversationSortTime(right.updatedAt),
toConversationSortTime(right.createdAt),
);
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.sessionId.localeCompare(right.sessionId, 'ko-KR');
});
}
export const CHAT_CONNECTION = {
reconnectDelayMs: 3000,
reconnectDelayMs: 1500,
connectTimeoutMs: CONNECT_TIMEOUT_MS,
sessionIdKey: CHAT_SESSION_ID_KEY,
lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX,
notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX,
sessionMessagesStoragePrefix: CHAT_SESSION_MESSAGES_STORAGE_PREFIX,
sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX,
introMessage: CHAT_INTRO_MESSAGE,
} as const;
@@ -49,21 +87,6 @@ function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | nul
return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`;
}
function buildLastEventIdStorageKey(sessionId: string) {
const normalizedSessionId = sessionId.trim() || 'default';
return `${CHAT_CONNECTION.lastEventIdStoragePrefix}${normalizedSessionId}`;
}
function buildSessionMessagesStorageKey(sessionId: string) {
const normalizedSessionId = sessionId.trim() || 'default';
return `${CHAT_CONNECTION.sessionMessagesStoragePrefix}${normalizedSessionId}`;
}
function buildSessionLastChatTypeStorageKey(sessionId: string) {
const normalizedSessionId = sessionId.trim() || 'default';
return `${CHAT_CONNECTION.sessionLastTypeStoragePrefix}${normalizedSessionId}`;
}
function createBrowserSessionId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -77,29 +100,10 @@ export function clearStoredChatClientConversationState() {
return;
}
const keysToRemove: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (!key) {
continue;
}
if (
key === CHAT_CONNECTION.sessionIdKey ||
key.startsWith(CHAT_CONNECTION.lastEventIdStoragePrefix) ||
key.startsWith(CHAT_CONNECTION.notifyOfflineStoragePrefix) ||
key.startsWith(CHAT_CONNECTION.sessionMessagesStoragePrefix) ||
key.startsWith(CHAT_CONNECTION.sessionLastTypeStoragePrefix)
) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => {
window.localStorage.removeItem(key);
});
chatClientSessionIdMemory = '';
chatSessionLastTypeMemory.clear();
chatLastEventIdMemory.clear();
chatOfflineNotificationMemory.clear();
}
function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest {
@@ -123,15 +127,12 @@ export function getChatClientSessionId() {
return '';
}
const existing = window.localStorage.getItem(CHAT_CONNECTION.sessionIdKey)?.trim();
if (existing) {
return existing;
if (chatClientSessionIdMemory) {
return chatClientSessionIdMemory;
}
const nextSessionId = createBrowserSessionId();
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, nextSessionId);
return nextSessionId;
chatClientSessionIdMemory = createBrowserSessionId();
return chatClientSessionIdMemory;
}
export function setChatClientSessionId(sessionId: string) {
@@ -145,7 +146,7 @@ export function setChatClientSessionId(sessionId: string) {
return;
}
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, normalizedSessionId);
chatClientSessionIdMemory = normalizedSessionId;
}
export function getLastReceivedChatEventId(sessionId: string) {
@@ -159,9 +160,7 @@ export function getLastReceivedChatEventId(sessionId: string) {
return 0;
}
const raw = window.localStorage.getItem(buildLastEventIdStorageKey(normalizedSessionId));
const parsed = raw ? Number(raw) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
return chatLastEventIdMemory.get(normalizedSessionId) ?? 0;
}
export function persistLastReceivedChatEventId(sessionId: string, eventId: number) {
@@ -181,7 +180,7 @@ export function persistLastReceivedChatEventId(sessionId: string, eventId: numbe
return;
}
window.localStorage.setItem(buildLastEventIdStorageKey(normalizedSessionId), String(eventId));
chatLastEventIdMemory.set(normalizedSessionId, eventId);
}
export function resetLastReceivedChatEventId(sessionId: string) {
@@ -195,7 +194,7 @@ export function resetLastReceivedChatEventId(sessionId: string) {
return;
}
window.localStorage.removeItem(buildLastEventIdStorageKey(normalizedSessionId));
chatLastEventIdMemory.delete(normalizedSessionId);
}
export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
@@ -203,13 +202,13 @@ export function getStoredChatOfflineNotificationSetting(sessionId: string, clien
return null;
}
const raw = window.localStorage.getItem(buildNotifyOfflineStorageKey(sessionId, clientId));
const key = buildNotifyOfflineStorageKey(sessionId, clientId);
if (raw === null) {
if (!chatOfflineNotificationMemory.has(key)) {
return null;
}
return raw === 'true';
return chatOfflineNotificationMemory.get(key) ?? null;
}
export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) {
@@ -217,7 +216,7 @@ export function setStoredChatOfflineNotificationSetting(sessionId: string, enabl
return;
}
window.localStorage.setItem(buildNotifyOfflineStorageKey(sessionId, clientId), enabled ? 'true' : 'false');
chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled);
}
export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
@@ -225,7 +224,7 @@ export function clearStoredChatOfflineNotificationSetting(sessionId: string, cli
return;
}
window.localStorage.removeItem(buildNotifyOfflineStorageKey(sessionId, clientId));
chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId));
}
function resolveSyncedChatOfflineNotificationSetting(
@@ -247,106 +246,31 @@ function resolveSyncedChatOfflineNotificationSetting(
return serverValue;
}
export function loadStoredChatMessages(sessionId: string) {
if (typeof window === 'undefined') {
return [] as ChatMessage[];
}
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return [] as ChatMessage[];
}
try {
const raw = window.localStorage.getItem(buildSessionMessagesStorageKey(normalizedSessionId));
if (!raw) {
return [] as ChatMessage[];
}
const parsed = JSON.parse(raw) as ChatMessage[];
if (!Array.isArray(parsed)) {
return [] as ChatMessage[];
}
return parsed
.filter((message) =>
Boolean(message) &&
(message.author === 'codex' || message.author === 'system' || message.author === 'user') &&
typeof message.text === 'string' &&
typeof message.timestamp === 'string' &&
typeof message.id === 'number',
)
.filter((message) => message.author !== 'system' || isActivityLogMessage(message));
} catch {
return [] as ChatMessage[];
}
}
export function getStoredChatSessionLastTypeId(sessionId: string) {
if (typeof window === 'undefined') {
return null;
}
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return null;
}
const raw = window.localStorage.getItem(buildSessionLastChatTypeStorageKey(normalizedSessionId))?.trim() ?? '';
const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? '';
return raw || null;
}
export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) {
if (typeof window === 'undefined') {
return;
}
const normalizedSessionId = sessionId.trim();
const normalizedChatTypeId = chatTypeId.trim();
if (!normalizedSessionId || !normalizedChatTypeId) {
return;
}
window.localStorage.setItem(
buildSessionLastChatTypeStorageKey(normalizedSessionId),
normalizedChatTypeId,
);
}
export function persistStoredChatMessages(sessionId: string, messages: ChatMessage[]) {
if (typeof window === 'undefined') {
return;
}
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
window.localStorage.setItem(
buildSessionMessagesStorageKey(normalizedSessionId),
JSON.stringify(messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message))),
);
}
export function clearStoredChatMessages(sessionId: string) {
if (typeof window === 'undefined') {
if (!normalizedChatTypeId) {
chatSessionLastTypeMemory.delete(normalizedSessionId);
return;
}
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
window.localStorage.removeItem(buildSessionMessagesStorageKey(normalizedSessionId));
chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId);
}
export function formatTime(date: Date) {
@@ -369,6 +293,20 @@ function createLocalMessageId() {
return Date.now() * 1_000 + localMessageSequence;
}
function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') {
const baseId = hashRequestId(requestId) * 10;
if (variant === 'user') {
return -(baseId + 3);
}
if (variant === 'activity') {
return -(baseId + 2);
}
return -(baseId + 1);
}
function hashRequestId(value: string) {
let hash = 0;
@@ -606,6 +544,7 @@ export function buildOfflineReply(context: ChatViewContext, input: string) {
export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string) {
const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL;
const resolvedClientId = clientId || getOrCreateClientId();
const accessToken = getRegisteredAccessToken();
if (typeof window === 'undefined') {
return '';
@@ -626,6 +565,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
if (resolvedClientId) {
normalizedUrl.searchParams.set('clientId', resolvedClientId);
}
if (accessToken) {
normalizedUrl.searchParams.set('accessToken', accessToken);
}
if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
normalizedUrl.searchParams.set('lastEventId', String(lastEventId));
}
@@ -641,6 +583,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
if (resolvedClientId) {
url.searchParams.set('clientId', resolvedClientId);
}
if (accessToken) {
url.searchParams.set('accessToken', accessToken);
}
if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
url.searchParams.set('lastEventId', String(lastEventId));
}
@@ -736,6 +681,106 @@ export async function copyText(text: string) {
}
}
export type PreviewCopyResult = 'text' | 'image' | 'url';
async function copyImagePreview(url: string): Promise<PreviewCopyResult> {
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`preview 이미지를 가져오지 못했습니다. (${response.status})`);
}
const imageBlob = await response.blob();
if (!imageBlob.type.startsWith('image/')) {
throw new Error('이미지 preview만 이미지 자체를 복사할 수 있습니다.');
}
if (typeof navigator !== 'undefined' && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
await navigator.clipboard.write([
new ClipboardItem({
[imageBlob.type]: imageBlob,
}),
]);
return 'image';
}
await copyText(url);
return 'url';
}
function canCopyPreviewBody(kind: string | null | undefined) {
return !['image', 'video', 'pdf', 'file'].includes(String(kind ?? '').trim().toLowerCase());
}
export async function copyPreviewContent({
kind,
url,
fallbackText,
}: {
kind: string | null | undefined;
url: string;
fallbackText?: string | null;
}): Promise<PreviewCopyResult> {
const normalizedKind = String(kind ?? '').trim().toLowerCase();
if (normalizedKind === 'image') {
return copyImagePreview(url);
}
const previewBody = await resolvePreviewBodyForCopy({
kind,
url,
fallbackText,
});
await copyText(previewBody);
return 'text';
}
export async function resolvePreviewBodyForCopy({
kind,
url,
fallbackText,
}: {
kind: string | null | undefined;
url: string;
fallbackText?: string | null;
}) {
const normalizedFallbackText = String(fallbackText ?? '');
if (!canCopyPreviewBody(kind)) {
throw new Error('이 미리보기는 본문 텍스트를 복사할 수 없습니다.');
}
try {
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`preview 본문을 가져오지 못했습니다. (${response.status})`);
}
const bodyText = await response.text();
if (bodyText.trim()) {
return bodyText;
}
} catch (error) {
if (!normalizedFallbackText.trim()) {
throw error;
}
}
if (normalizedFallbackText.trim()) {
return normalizedFallbackText;
}
throw new Error('복사할 preview 본문이 없습니다.');
}
function resolveChatApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
@@ -751,6 +796,11 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
if (!hasRegisteredAccessTokenAccess()) {
window.clearTimeout(timeoutId);
throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.');
}
if (accessToken && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', accessToken);
}
@@ -775,17 +825,43 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
throw new Error('채팅 서버 응답이 지연됩니다.');
}
throw error;
throw new Error('채팅 서버 연결에 실패했습니다.');
}
window.clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text();
throw new Error(text || '채팅 API 요청에 실패했습니다.');
if (text.trim()) {
try {
const payload = JSON.parse(text) as { message?: string };
const normalizedMessage = String(payload.message ?? '').trim();
if (normalizedMessage) {
throw new Error(normalizedMessage === 'fetch failed' ? '채팅 서버 연결에 실패했습니다.' : normalizedMessage);
}
} catch (error) {
if (error instanceof Error && error.message) {
throw error;
}
}
}
throw new Error('채팅 API 요청에 실패했습니다.');
}
return response.json() as Promise<T>;
const text = await response.text();
if (!text.trim()) {
throw new Error('채팅 서버 응답이 비어 있습니다.');
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error('채팅 서버 응답을 해석하지 못했습니다.');
}
}
async function readFileAsBase64(file: File) {
@@ -827,10 +903,12 @@ export async function fetchChatConversations() {
const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
.then((response) => {
const items = response.items.map((item) => ({
...item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
}));
const items = sortChatConversationSummaries(
response.items.map((item) => ({
...item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
})),
);
cachedChatConversationList = items;
cachedChatConversationListAt = Date.now();
@@ -864,19 +942,23 @@ export async function fetchChatConversationDetail(
const response = await requestChatApi<ChatConversationDetailResponse>(
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
);
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
const visibleRequestIds = new Set(
response.messages
.map((message) => message.clientRequestId?.trim() ?? '')
.filter(Boolean),
);
const hydratedMessages = hydrateActivityLogMessages(
response.messages,
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) => message.author !== 'system' || isActivityLogMessage(message),
);
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
return {
...response,
messages: hydrateActivityLogMessages(
response.messages,
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) => message.author !== 'system' || isActivityLogMessage(message),
),
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
item: {
...response.item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
@@ -885,7 +967,7 @@ export async function fetchChatConversationDetail(
clientId,
),
},
requests: response.requests.map((item) => normalizeChatConversationRequest(item)),
requests: normalizedRequests,
};
}
@@ -945,6 +1027,7 @@ export async function uploadChatComposerFile(sessionId: string, file: File) {
export async function createChatConversationRoom(args: {
sessionId: string;
title?: string;
chatTypeId?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -956,6 +1039,7 @@ export async function createChatConversationRoom(args: {
body: JSON.stringify({
sessionId: args.sessionId,
title: args.title ?? '새 대화',
chatTypeId: args.chatTypeId ?? null,
contextLabel: args.contextLabel ?? null,
contextDescription: args.contextDescription ?? null,
notifyOffline,
@@ -963,6 +1047,8 @@ export async function createChatConversationRoom(args: {
}),
});
invalidateChatConversationListCache();
return {
...response.item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
@@ -980,6 +1066,8 @@ export async function renameChatConversationRoom(sessionId: string, title: strin
},
);
invalidateChatConversationListCache();
return response.item;
}
@@ -987,6 +1075,9 @@ export async function updateChatConversationRoom(
sessionId: string,
payload: {
title?: string;
chatTypeId?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean;
},
) {
@@ -999,6 +1090,8 @@ export async function updateChatConversationRoom(
},
);
invalidateChatConversationListCache();
return {
...response.item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
@@ -1025,6 +1118,8 @@ export async function deleteChatConversationRoom(sessionId: string) {
},
);
invalidateChatConversationListCache();
return response;
}
@@ -1116,8 +1211,8 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
}
return Boolean(
left.author === 'user' &&
right.author === 'user' &&
(left.author === 'user' || left.author === 'codex') &&
left.author === right.author &&
left.clientRequestId &&
right.clientRequestId &&
left.clientRequestId === right.clientRequestId,
@@ -1133,9 +1228,78 @@ function buildComparableChatMessageKey(message: ChatMessage) {
return `user-request:${message.clientRequestId}`;
}
if (message.author === 'codex' && message.clientRequestId) {
return `codex-request:${message.clientRequestId}`;
}
return `id:${message.id}`;
}
function getComparableChatMessageTime(message: ChatMessage) {
const parsed = Date.parse(String(message.timestamp ?? '').trim());
return Number.isFinite(parsed) ? parsed : 0;
}
function buildRecoveredMessagesFromConversationDetail(
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
) {
const nextMessages: ChatMessage[] = [];
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
requests.forEach((request) => {
const requestId = request.requestId.trim();
if (!requestId || request.status === 'removed') {
return;
}
const userText = String(request.userText ?? '').trim();
const responseText = String(request.responseText ?? '').trim();
const activityLog = activityLogMap.get(requestId);
if (userText) {
nextMessages.push({
id: request.userMessageId ?? createRecoveredMessageId(requestId, 'user'),
author: 'user',
text: userText,
timestamp: request.createdAt || request.updatedAt || '',
clientRequestId: requestId,
});
}
if (responseText) {
nextMessages.push({
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
author: 'codex',
text: responseText,
timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
clientRequestId: requestId,
});
}
if (activityLog && activityLog.lines.length > 0) {
nextMessages.push({
id: createRecoveredMessageId(requestId, 'activity'),
author: 'system',
text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${activityLog.lines.join('\n\n')}`,
timestamp: request.createdAt || request.updatedAt || activityLog.updatedAt || '',
clientRequestId: requestId,
});
}
});
return nextMessages.sort((left, right) => {
const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
if (timeDiff !== 0) {
return timeDiff;
}
return left.id - right.id;
});
}
export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
if (previous.length === 0) {
return incoming;

View File

@@ -0,0 +1,47 @@
function isStandaloneDisplayMode() {
if (typeof window === 'undefined') {
return false;
}
return (
window.matchMedia?.('(display-mode: standalone)').matches === true ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isMobileLikeViewport() {
if (typeof window === 'undefined') {
return false;
}
return (
window.matchMedia?.('(max-width: 1180px)').matches === true ||
window.matchMedia?.('(pointer: coarse)').matches === true
);
}
export function shouldOpenDownloadInNewWindow() {
return isStandaloneDisplayMode() && isMobileLikeViewport();
}
export function triggerResourceDownload(url: string, fileName?: string) {
if (typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const link = document.createElement('a');
link.href = url;
if (shouldOpenDownloadInNewWindow()) {
link.target = '_blank';
link.rel = 'noreferrer';
} else if (typeof fileName === 'string' && fileName.trim()) {
link.download = fileName.trim();
} else {
link.download = '';
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

View File

@@ -4,7 +4,9 @@ export { ErrorLogViewer } from './ErrorLogViewer';
export {
buildOfflineReply,
clearStoredChatClientConversationState,
copyPreviewContent,
copyText,
resolvePreviewBodyForCopy,
createActivityLogPlaceholder,
createChatConversationRoom,
createChatMessage,
@@ -20,15 +22,14 @@ export {
getStoredChatSessionLastTypeId,
isPreparingChatReplyText,
getChatClientSessionId,
loadStoredChatMessages,
markChatConversationResponsesRead,
mergeRecoveredChatMessages,
persistStoredChatMessages,
renameChatConversationRoom,
removeChatRuntimeJob,
resetLastReceivedChatEventId,
setStoredChatSessionLastTypeId,
setChatClientSessionId,
sortChatConversationSummaries,
uploadChatComposerFile,
upsertChatMessage,
updateChatConversationRoom,

View File

@@ -0,0 +1,14 @@
const HIDDEN_PREVIEW_TAG_PATTERN = /\[\[preview:([^\]\n]+)\]\]/g;
export function extractHiddenPreviewUrls(text: string) {
return Array.from(String(text ?? '').matchAll(HIDDEN_PREVIEW_TAG_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
}
export function stripHiddenPreviewTags(text: string) {
return String(text ?? '')
.replace(HIDDEN_PREVIEW_TAG_PATTERN, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}

View File

@@ -37,6 +37,7 @@ export type ChatConversationSummary = {
sessionId: string;
clientId: string | null;
title: string;
chatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;

View File

@@ -8,6 +8,7 @@ import {
persistLastReceivedChatEventId,
resolveChatWebSocketUrl,
} from './chatUtils';
import { hasRegisteredAccessTokenAccess } from '../tokenAccess';
import type {
ChatActivityEvent,
ChatJobEvent,
@@ -19,7 +20,6 @@ import type {
const DISCONNECT_UI_DELAY_MS = 1500;
const PRESENCE_PING_INTERVAL_MS = 20_000;
const BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS = 15_000;
type ConnectionState = 'connecting' | 'connected' | 'disconnected';
@@ -225,14 +225,13 @@ function sendPresencePing() {
);
}
function refreshSharedSocket() {
function ensureSharedSocket() {
const socket = sharedChatConnection.socketRef.current;
if (socket && socket.readyState === WebSocket.CONNECTING) {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
disconnectSharedSocket();
connectSharedSocket();
}
@@ -280,24 +279,6 @@ function stopPresenceMonitoring() {
}
}
function shouldRefreshSocketAfterResume() {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return false;
}
const socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return true;
}
if (sharedChatConnection.lastBackgroundAt === null) {
return false;
}
return Date.now() - sharedChatConnection.lastBackgroundAt >= BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS;
}
function handleVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
sharedChatConnection.lastBackgroundAt = Date.now();
@@ -306,36 +287,24 @@ function handleVisibilityChange() {
return;
}
if (shouldRefreshSocketAfterResume()) {
refreshSharedSocket();
return;
}
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handlePageShow() {
if (shouldRefreshSocketAfterResume()) {
refreshSharedSocket();
return;
}
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handleWindowFocus() {
if (shouldRefreshSocketAfterResume()) {
refreshSharedSocket();
return;
}
ensureSharedSocket();
sendPresencePing();
}
function handleWindowOnline() {
refreshSharedSocket();
ensureSharedSocket();
}
function startPresenceMonitoring() {
@@ -444,6 +413,16 @@ function connectSharedSocket() {
return;
}
if (!hasRegisteredAccessTokenAccess()) {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.');
setSharedConnectionState('disconnected');
return;
}
const currentSocket = sharedChatConnection.socketRef.current;
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
@@ -496,6 +475,13 @@ function connectSharedSocket() {
return;
}
if (closeEvent?.code === 1008) {
clearReconnectTimer();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.');
setSharedConnectionState('disconnected');
return;
}
if (closeEvent?.code === 1000 && !message) {
setSharedConnectionError('');
return;