chore: update live chat and work server changes
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CodeOutlined,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
UpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd';
|
||||
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import {
|
||||
useEffect,
|
||||
@@ -32,7 +31,8 @@ import {
|
||||
} from 'react';
|
||||
import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
||||
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
@@ -40,7 +40,6 @@ import { copyPreviewContent, copyText } from './chatUtils';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
|
||||
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
const { Text } = Typography;
|
||||
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
@@ -81,6 +80,13 @@ type InlinePreviewTarget = {
|
||||
kind: InlinePreviewKind;
|
||||
};
|
||||
|
||||
type PendingComposerUpload = {
|
||||
key: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'uploaded' | 'failed';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type PreviewFetchError = Error & {
|
||||
status?: number;
|
||||
};
|
||||
@@ -171,19 +177,8 @@ function buildPreviewFileName(item: PreviewOption) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'markdown':
|
||||
case 'code':
|
||||
case 'diff':
|
||||
case 'document':
|
||||
case 'pdf':
|
||||
return kind;
|
||||
default:
|
||||
return 'file';
|
||||
}
|
||||
function buildComposerFilePickKey(file: File) {
|
||||
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||
@@ -459,7 +454,7 @@ function InlineMessagePreview({
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') {
|
||||
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -529,9 +524,6 @@ function InlineMessagePreview({
|
||||
<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">
|
||||
{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">{resolveChatPreviewKindLabel(target.kind)}</span>
|
||||
@@ -623,9 +615,6 @@ function DiffMessagePreview({
|
||||
>
|
||||
<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>
|
||||
@@ -707,6 +696,7 @@ type ChatConversationViewProps = {
|
||||
previewItems: PreviewOption[];
|
||||
isResourceStripOpen: boolean;
|
||||
isComposerDisabled: boolean;
|
||||
isMobileViewport: boolean;
|
||||
isChatTypeSelectionLocked: boolean;
|
||||
isComposerAttachmentUploading: boolean;
|
||||
onViewportScroll: () => void;
|
||||
@@ -714,7 +704,7 @@ type ChatConversationViewProps = {
|
||||
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
||||
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
onPickComposerFiles: (files: File[]) => void | Promise<void>;
|
||||
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
|
||||
onRemoveComposerAttachment: (attachmentId: string) => void;
|
||||
onSelectChatType: (value: string) => void;
|
||||
onSend: () => void;
|
||||
@@ -753,6 +743,7 @@ export function ChatConversationView({
|
||||
previewItems,
|
||||
isResourceStripOpen,
|
||||
isComposerDisabled,
|
||||
isMobileViewport,
|
||||
isChatTypeSelectionLocked,
|
||||
isComposerAttachmentUploading,
|
||||
onViewportScroll,
|
||||
@@ -777,12 +768,12 @@ export function ChatConversationView({
|
||||
}: ChatConversationViewProps) {
|
||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
|
||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
||||
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
||||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||||
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||
@@ -841,11 +832,6 @@ export function ChatConversationView({
|
||||
return [...ordered, ...orphanActivityMessages];
|
||||
}, [visibleMessages]);
|
||||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||||
const selectedChatTypeOption = useMemo(
|
||||
() => chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null,
|
||||
[chatTypeOptions, selectedChatTypeId],
|
||||
);
|
||||
const normalizedSelectedChatTypeLabel = selectedChatTypeOption?.label?.trim() ?? '';
|
||||
const isChatTypeReadonly = useMemo(() => {
|
||||
if (isChatTypeSelectionLocked) {
|
||||
return true;
|
||||
@@ -1064,8 +1050,74 @@ export function ChatConversationView({
|
||||
};
|
||||
}, [isComposerAttachmentUploading, isConversationLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingComposerUploads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedAttachmentNames = new Set(
|
||||
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
|
||||
);
|
||||
const resolvedUploads = pendingComposerUploads.filter(
|
||||
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
|
||||
);
|
||||
|
||||
if (resolvedUploads.length > 0) {
|
||||
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
|
||||
setPendingComposerUploads((current) => current.filter((item) => !resolvedKeys.has(item.key)));
|
||||
}
|
||||
}, [composerAttachments, pendingComposerUploads]);
|
||||
|
||||
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
||||
|
||||
const syncPendingComposerUploads = async (files: File[]) => {
|
||||
const nextPendingUploads = files.map((file) => ({
|
||||
key: buildComposerFilePickKey(file),
|
||||
name: file.name,
|
||||
status: 'uploading' as const,
|
||||
}));
|
||||
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
||||
|
||||
setPendingComposerUploads((current) => [
|
||||
...current.filter((item) => !pendingKeys.has(item.key)),
|
||||
...nextPendingUploads,
|
||||
]);
|
||||
|
||||
let result: ComposerFilePickResult = { items: [] };
|
||||
|
||||
try {
|
||||
result = (await onPickComposerFiles(files)) ?? { items: [] };
|
||||
} catch {
|
||||
result = {
|
||||
items: nextPendingUploads.map((item) => ({
|
||||
key: item.key,
|
||||
fileName: item.name,
|
||||
status: 'failed',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const resultByKey = new Map<string, ComposerFilePickResult['items'][number]>(
|
||||
result.items.map((item) => [item.key, item]),
|
||||
);
|
||||
|
||||
setPendingComposerUploads((current) =>
|
||||
current.flatMap((item) => {
|
||||
if (!pendingKeys.has(item.key)) {
|
||||
return [item];
|
||||
}
|
||||
|
||||
const matched = resultByKey.get(item.key);
|
||||
|
||||
if (!matched || matched.status === 'failed') {
|
||||
return [{ ...item, status: 'failed', reason: matched?.reason }];
|
||||
}
|
||||
|
||||
return [{ ...item, status: 'uploaded', reason: undefined }];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = '';
|
||||
@@ -1074,7 +1126,7 @@ export function ChatConversationView({
|
||||
return;
|
||||
}
|
||||
|
||||
void onPickComposerFiles(files);
|
||||
void syncPendingComposerUploads(files);
|
||||
};
|
||||
|
||||
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -1100,9 +1152,69 @@ export function ChatConversationView({
|
||||
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
|
||||
);
|
||||
|
||||
void onPickComposerFiles(uniqueFiles);
|
||||
void syncPendingComposerUploads(uniqueFiles);
|
||||
};
|
||||
|
||||
const dismissPendingComposerUpload = (key: string) => {
|
||||
setPendingComposerUploads((current) => current.filter((item) => item.key !== key));
|
||||
};
|
||||
|
||||
const composerAttachmentStrip =
|
||||
pendingComposerUploads.length > 0 || composerAttachments.length > 0 ? (
|
||||
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
||||
{pendingComposerUploads.map((upload) => (
|
||||
<div
|
||||
key={`pending:${upload.key}`}
|
||||
className={`app-chat-panel__composer-attachment-chip app-chat-panel__composer-attachment-chip--pending${
|
||||
upload.status === 'failed' ? ' app-chat-panel__composer-attachment-chip--failed' : ''
|
||||
}`}
|
||||
title={upload.status === 'failed' ? upload.reason ?? '업로드 실패' : undefined}
|
||||
>
|
||||
<span className="app-chat-panel__composer-attachment-name">{upload.name}</span>
|
||||
<span className="app-chat-panel__composer-attachment-pending-label">
|
||||
{upload.status === 'failed'
|
||||
? upload.reason ?? '업로드 실패'
|
||||
: upload.status === 'uploaded'
|
||||
? '첨부 반영 중'
|
||||
: '업로드 중'}
|
||||
</span>
|
||||
{upload.status === 'failed' ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="app-chat-panel__composer-attachment-remove"
|
||||
icon={<CloseOutlined />}
|
||||
aria-label={`${upload.name} 업로드 실패 항목 닫기`}
|
||||
onClick={() => {
|
||||
dismissPendingComposerUpload(upload.key);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{composerAttachments.map((attachment) => (
|
||||
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
|
||||
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="app-chat-panel__composer-attachment-remove"
|
||||
icon={<CloseOutlined />}
|
||||
aria-label={`${attachment.name} 첨부 제거`}
|
||||
onClick={() => {
|
||||
onRemoveComposerAttachment(attachment.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
const composerPlaceholder = isComposerDisabled
|
||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||
: isMobileViewport
|
||||
? '메시지를 입력하세요.'
|
||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||
|
||||
const renderActivityCard = (message: ChatMessage) => {
|
||||
const requestId = message.clientRequestId?.trim() || String(message.id);
|
||||
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
|
||||
@@ -1229,22 +1341,17 @@ export function ChatConversationView({
|
||||
</label>
|
||||
<div className="app-chat-panel__resource-strip-list">
|
||||
{visiblePreviewItems.map((item) => (
|
||||
<InlineMessagePreview
|
||||
<button
|
||||
key={item.id}
|
||||
target={{
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
kind: normalizePreviewOptionKind(item.kind),
|
||||
type="button"
|
||||
className="app-chat-panel__resource-chip"
|
||||
onClick={() => {
|
||||
onOpenPreview(item.id);
|
||||
}}
|
||||
isExpanded={expandedResourcePreviewKey === item.id}
|
||||
hasModalPreview
|
||||
onOpenModalPreview={() => {
|
||||
onOpenPreview(item.id, { fullscreen: true });
|
||||
}}
|
||||
onToggle={() => {
|
||||
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<span title={item.label}>{item.label}</span>
|
||||
<span>{item.kind}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -1491,22 +1598,24 @@ export function ChatConversationView({
|
||||
|
||||
</div>
|
||||
|
||||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||||
<div
|
||||
className={`app-chat-panel__system-status${
|
||||
activeSystemStatus ? '' : ' app-chat-panel__system-status--hidden'
|
||||
}${isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''}`}
|
||||
>
|
||||
<span>{activeSystemStatus ?? ''}</span>
|
||||
{activeSystemStatus && isSystemStatusPending ? (
|
||||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||||
<span className="app-chat-panel__system-status-dot" />
|
||||
<span className="app-chat-panel__system-status-dot" />
|
||||
<span className="app-chat-panel__system-status-dot" />
|
||||
</div>
|
||||
) : null}
|
||||
{activeSystemStatus ? (
|
||||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||||
<div
|
||||
className={`app-chat-panel__system-status${
|
||||
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
||||
}`}
|
||||
>
|
||||
<span>{activeSystemStatus}</span>
|
||||
{isSystemStatusPending ? (
|
||||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||||
<span className="app-chat-panel__system-status-dot" />
|
||||
<span className="app-chat-panel__system-status-dot" />
|
||||
<span className="app-chat-panel__system-status-dot" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showScrollToBottom ? (
|
||||
<div className="app-chat-panel__scroll-jump">
|
||||
@@ -1544,11 +1653,6 @@ export function ChatConversationView({
|
||||
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
|
||||
onChange={onSelectChatType}
|
||||
/>
|
||||
{normalizedSelectedChatTypeLabel && normalizedSelectedChatTypeLabel !== '일반 요청' ? (
|
||||
<Text type="secondary" className="app-chat-panel__composer-type-note">
|
||||
현재 채팅유형: {normalizedSelectedChatTypeLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="app-chat-panel__composer-actions">
|
||||
<div className="app-chat-panel__composer-action-buttons">
|
||||
@@ -1578,6 +1682,8 @@ export function ChatConversationView({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{composerAttachmentStrip}
|
||||
|
||||
<div
|
||||
className={`app-chat-panel__composer-input-shell${
|
||||
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
||||
@@ -1616,12 +1722,8 @@ export function ChatConversationView({
|
||||
<Input.TextArea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
placeholder={
|
||||
isComposerDisabled
|
||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.'
|
||||
}
|
||||
autoSize={false}
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isComposerDisabled}
|
||||
onChange={(event) => {
|
||||
onDraftChange(event.target.value);
|
||||
@@ -1632,7 +1734,8 @@ export function ChatConversationView({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.ctrlKey) {
|
||||
const hasSubmitModifier = event.ctrlKey || event.metaKey;
|
||||
if (!hasSubmitModifier) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -1661,30 +1764,11 @@ export function ChatConversationView({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed"
|
||||
className="app-chat-panel__composer-file-input"
|
||||
onChange={handleComposerFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{composerAttachments.length > 0 ? (
|
||||
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
||||
{composerAttachments.map((attachment) => (
|
||||
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
|
||||
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="app-chat-panel__composer-attachment-remove"
|
||||
icon={<CloseOutlined />}
|
||||
aria-label={`${attachment.name} 첨부 제거`}
|
||||
onClick={() => {
|
||||
onRemoveComposerAttachment(attachment.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,8 @@ export function resolveChatPreviewGlyph(kind: ChatPreviewKind) {
|
||||
return <FileTextOutlined />;
|
||||
case 'pdf':
|
||||
return <FilePdfOutlined />;
|
||||
case 'file':
|
||||
return <DownloadOutlined />;
|
||||
default:
|
||||
return <LinkOutlined />;
|
||||
}
|
||||
@@ -63,6 +65,8 @@ export function resolveChatPreviewKindLabel(kind: ChatPreviewKind) {
|
||||
return 'document preview';
|
||||
case 'pdf':
|
||||
return 'pdf preview';
|
||||
case 'file':
|
||||
return 'file download';
|
||||
default:
|
||||
return 'resource preview';
|
||||
}
|
||||
@@ -322,6 +326,28 @@ export function ChatPreviewBody({
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
if (target.kind === 'file') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-file">
|
||||
<Paragraph>
|
||||
브라우저에서 바로 미리보기하지 않는 파일입니다. 아래 버튼으로 새 탭에서 열거나 다운로드할 수 있습니다.
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'markdown') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderModalWithEnterConfirm } from '../modalKeyboard';
|
||||
import {
|
||||
cancelChatRuntimeJob,
|
||||
fetchChatRuntimeJobDetail,
|
||||
@@ -198,7 +199,7 @@ function RecentRuntimeList({
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={item.terminalStatus !== 'completed'}
|
||||
disabled={item.terminalStatus !== 'completed' && item.terminalStatus !== 'failed'}
|
||||
loading={pendingActionRequestId === item.requestId}
|
||||
onClick={() => {
|
||||
onRollbackJob(item.requestId, item.sessionId);
|
||||
@@ -272,6 +273,8 @@ export function ChatRuntimeDashboard({
|
||||
content: options.content,
|
||||
okText: options.okText,
|
||||
cancelText: options.cancelText ?? '닫기',
|
||||
autoFocusButton: 'ok',
|
||||
modalRender: renderModalWithEnterConfirm,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
|
||||
@@ -24,20 +24,16 @@ const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
|
||||
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 CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -817,6 +813,16 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
if (!headers.has('Cache-Control')) {
|
||||
headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
|
||||
}
|
||||
|
||||
if (!headers.has('Pragma')) {
|
||||
headers.set('Pragma', 'no-cache');
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
@@ -894,16 +900,35 @@ async function readFileAsBase64(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchChatConversations() {
|
||||
const now = Date.now();
|
||||
const FALLBACK_UPLOAD_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
zip: 'application/zip',
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
if (
|
||||
cachedChatConversationList &&
|
||||
now - cachedChatConversationListAt < CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS
|
||||
) {
|
||||
return cachedChatConversationList;
|
||||
function resolveUploadMimeType(file: File) {
|
||||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||||
const extension = normalizedName.includes('.') ? normalizedName.split('.').pop()?.trim() ?? '' : '';
|
||||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||||
|
||||
if (normalizedType && normalizedType !== 'application/octet-stream') {
|
||||
return normalizedType;
|
||||
}
|
||||
|
||||
if (extension && FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension]) {
|
||||
return FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension];
|
||||
}
|
||||
|
||||
return normalizedType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function fetchChatConversations() {
|
||||
if (chatConversationListRequestPromise) {
|
||||
return chatConversationListRequestPromise;
|
||||
}
|
||||
@@ -911,16 +936,12 @@ export async function fetchChatConversations() {
|
||||
const clientId = getOrCreateClientId();
|
||||
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
|
||||
.then((response) => {
|
||||
const items = sortChatConversationSummaries(
|
||||
return sortChatConversationSummaries(
|
||||
response.items.map((item) => ({
|
||||
...item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||
})),
|
||||
);
|
||||
|
||||
cachedChatConversationList = items;
|
||||
cachedChatConversationListAt = Date.now();
|
||||
return items;
|
||||
})
|
||||
.finally(() => {
|
||||
chatConversationListRequestPromise = null;
|
||||
@@ -1026,23 +1047,75 @@ export async function rollbackChatRuntimeJob(requestId: string, sessionId?: stri
|
||||
|
||||
export async function uploadChatComposerFile(sessionId: string, file: File) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
const resolvedMimeType = resolveUploadMimeType(file);
|
||||
const reportUploadFailure = async (stage: string, error: Error) => {
|
||||
await reportClientError({
|
||||
errorType: 'chat:composer-upload',
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/api/chat/attachments',
|
||||
context: {
|
||||
stage,
|
||||
sessionId: normalizedSessionId || null,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type || null,
|
||||
resolvedMimeType,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
throw new Error('채팅 세션이 준비되지 않았습니다.');
|
||||
const uploadError = new Error('채팅 세션이 준비되지 않았습니다.');
|
||||
await reportUploadFailure('validate-session', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
const contentBase64 = await readFileAsBase64(file);
|
||||
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: normalizedSessionId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
contentBase64,
|
||||
}),
|
||||
});
|
||||
if (file.size <= 0) {
|
||||
const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.');
|
||||
await reportUploadFailure('validate-file', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
return response.item;
|
||||
if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) {
|
||||
const uploadError = new Error(`첨부 파일은 10MB 이하만 업로드할 수 있습니다. (${file.name})`);
|
||||
await reportUploadFailure('validate-file', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
let contentBase64 = '';
|
||||
|
||||
try {
|
||||
contentBase64 = await readFileAsBase64(file);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message.trim() ? error.message.trim() : '파일 내용을 읽지 못했습니다.';
|
||||
const uploadError = new Error(`${message} (${file.name})`);
|
||||
uploadError.name = error instanceof Error && error.name ? error.name : 'FileReadError';
|
||||
await reportUploadFailure('read-file', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: normalizedSessionId,
|
||||
fileName: file.name,
|
||||
mimeType: resolvedMimeType,
|
||||
contentBase64,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
const uploadError =
|
||||
error instanceof Error && error.message.trim()
|
||||
? error
|
||||
: new Error(`${file.name} 업로드에 실패했습니다.`);
|
||||
await reportUploadFailure('upload-request', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createChatConversationRoom(args: {
|
||||
|
||||
131
src/app/main/mainChatPanel/previewItems.ts
Normal file
131
src/app/main/mainChatPanel/previewItems.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls } from './previewMarkers';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
export type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
||||
|
||||
export type PreviewItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: PreviewKind;
|
||||
source: 'message' | 'context';
|
||||
};
|
||||
|
||||
function normalizePreviewUrl(value: string) {
|
||||
return normalizeChatResourceUrl(value);
|
||||
}
|
||||
|
||||
function isPreviewRouteUrl(url: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyPreviewKind(url: string): PreviewKind {
|
||||
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||||
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (/\.(md|markdown)$/i.test(pathname)) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (/\.(diff|patch)$/i.test(pathname)) {
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
|
||||
return 'code';
|
||||
}
|
||||
|
||||
if (/\.(txt|log|csv)$/i.test(pathname)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
if (/\.pdf$/i.test(pathname)) {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (isPreviewRouteUrl(url)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export function buildPreviewLabel(url: string, source: PreviewItem['source']) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1);
|
||||
|
||||
if (lastSegment) {
|
||||
return source === 'context' ? `현재 화면 · ${lastSegment}` : lastSegment;
|
||||
}
|
||||
|
||||
return source === 'context' ? '현재 화면 미리보기' : parsed.hostname;
|
||||
} catch {
|
||||
return source === 'context' ? '현재 화면 미리보기' : url;
|
||||
}
|
||||
}
|
||||
|
||||
export function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
|
||||
if (!item || item.kind !== 'code') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||
} catch {
|
||||
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
|
||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const seen = new Set<string>();
|
||||
const items: PreviewItem[] = [];
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
||||
|
||||
matches.forEach((matchedUrl) => {
|
||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||
const kind = classifyPreviewKind(normalizedUrl);
|
||||
|
||||
if (seen.has(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(normalizedUrl);
|
||||
items.push({
|
||||
id: `${message.id}-${normalizedUrl}`,
|
||||
label: buildPreviewLabel(normalizedUrl, 'message'),
|
||||
url: normalizedUrl,
|
||||
kind,
|
||||
source: 'message',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items.slice(0, 12);
|
||||
}
|
||||
Reference in New Issue
Block a user