feat: update codex live chat workflow
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
src/app/main/mainChatPanel/downloadUtils.ts
Normal file
47
src/app/main/mainChatPanel/downloadUtils.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
14
src/app/main/mainChatPanel/previewMarkers.ts
Normal file
14
src/app/main/mainChatPanel/previewMarkers.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user