Initial import
This commit is contained in:
1149
src/app/main/mainChatPanel/ChatConversationView.tsx
Executable file
1149
src/app/main/mainChatPanel/ChatConversationView.tsx
Executable file
File diff suppressed because it is too large
Load Diff
303
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file
303
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file
@@ -0,0 +1,303 @@
|
||||
import { DownloadOutlined, EyeOutlined } 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 '../../../components/previewer/PreviewerUI.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
|
||||
export type ChatPreviewTarget = {
|
||||
label: string;
|
||||
url: string;
|
||||
kind: ChatPreviewKind;
|
||||
};
|
||||
|
||||
function resolvePreviewErrorMessage(previewError: string) {
|
||||
const normalized = previewError.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^\s*403\b/.test(normalized) || normalized.includes('권한으로 열 수 없습니다')) {
|
||||
return '권한이 없거나 허용되지 않은 경로입니다. 세션 리소스 경로와 접근 권한을 확인해 주세요.';
|
||||
}
|
||||
|
||||
if (/^\s*404\b/.test(normalized) || normalized.includes('찾을 수 없습니다')) {
|
||||
return '파일이 이동되었거나 아직 세션 리소스 경로에 생성되지 않았습니다. 경로를 다시 확인해 주세요.';
|
||||
}
|
||||
|
||||
if (/^\s*401\b/.test(normalized) || normalized.includes('인증이 필요합니다')) {
|
||||
return '인증 정보가 없어서 문서를 열 수 없습니다.';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePreviewExtension(target: ChatPreviewTarget) {
|
||||
const raw = target.label || target.url;
|
||||
const normalized = raw.toLowerCase().split('?')[0] ?? '';
|
||||
const match = normalized.match(/\.([a-z0-9]+)$/i);
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
function resolveCodeLanguage(target: ChatPreviewTarget, previewText: string) {
|
||||
const extension = resolvePreviewExtension(target);
|
||||
|
||||
if (extension === 'tsx' || extension === 'ts') {
|
||||
return 'typescript';
|
||||
}
|
||||
|
||||
if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
if (extension === 'json') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (extension === 'css') {
|
||||
return 'css';
|
||||
}
|
||||
|
||||
if (extension === 'scss') {
|
||||
return 'scss';
|
||||
}
|
||||
|
||||
if (extension === 'html' || extension === 'htm') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (extension === 'md' || extension === 'markdown') {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (extension === 'java') {
|
||||
return 'java';
|
||||
}
|
||||
|
||||
if (extension === 'kt') {
|
||||
return 'kotlin';
|
||||
}
|
||||
|
||||
if (extension === 'py') {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (extension === 'go') {
|
||||
return 'go';
|
||||
}
|
||||
|
||||
if (extension === 'rs') {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
if (extension === 'sql') {
|
||||
return 'sql';
|
||||
}
|
||||
|
||||
if (extension === 'sh' || extension === 'bash' || extension === 'zsh') {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
if (extension === 'yml' || extension === 'yaml') {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
if (extension === 'xml') {
|
||||
return 'xml';
|
||||
}
|
||||
|
||||
if (extension === 'diff' || extension === 'patch') {
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
if (/^(diff --git|@@\s|--- a\/|\+\+\+ b\/)/m.test(previewText)) {
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
return inferCodeLanguage(extension || 'text');
|
||||
}
|
||||
|
||||
function isAppRouteUrl(url: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||
return parsed.origin === window.location.origin && !hasKnownFileExtension;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canRenderFramePreview(url: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
return parsed.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ChatPreviewBodyProps = {
|
||||
target: ChatPreviewTarget | null;
|
||||
previewText: string;
|
||||
isPreviewLoading: boolean;
|
||||
previewError: string;
|
||||
previewContentType?: string;
|
||||
};
|
||||
|
||||
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
||||
const extension = resolvePreviewExtension(target);
|
||||
const normalizedContentType = previewContentType?.toLowerCase() ?? '';
|
||||
const normalizedPreview = previewText.trimStart().toLowerCase();
|
||||
|
||||
if (extension === 'html' || extension === 'htm' || target.kind === 'markdown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const looksLikeHtml =
|
||||
normalizedContentType.includes('text/html') ||
|
||||
normalizedPreview.startsWith('<!doctype html') ||
|
||||
normalizedPreview.startsWith('<html') ||
|
||||
normalizedPreview.includes('<head') ||
|
||||
normalizedPreview.includes('<body');
|
||||
|
||||
return looksLikeHtml;
|
||||
}
|
||||
|
||||
export function ChatPreviewBody({
|
||||
target,
|
||||
previewText,
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
previewContentType,
|
||||
}: ChatPreviewBodyProps) {
|
||||
if (!target) {
|
||||
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
||||
}
|
||||
|
||||
if (isPreviewLoading) {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-loading">
|
||||
<Spin size="small" />
|
||||
<Text type="secondary">preview를 불러오는 중입니다.</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="preview를 불러오지 못했습니다."
|
||||
description={resolvePreviewErrorMessage(previewError)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHtmlFallbackPreview(target, previewText, previewContentType)) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="실제 소스 파일 대신 앱 HTML 화면이 반환되었습니다."
|
||||
description={`${target.label} 경로가 raw 파일이 아니라 현재 앱의 fallback HTML을 돌려주고 있습니다. 정적 파일 경로 또는 실제 다운로드 경로를 사용해야 코드 preview가 정확하게 표시됩니다.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'image') {
|
||||
return (
|
||||
<InlineImage
|
||||
src={target.url}
|
||||
alt={target.label}
|
||||
className="app-chat-panel__preview-image"
|
||||
fallbackText="이미지 preview를 불러오지 못했습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'video') {
|
||||
return <video src={target.url} className="app-chat-panel__preview-video" controls playsInline />;
|
||||
}
|
||||
|
||||
if (target.kind === 'pdf') {
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
if (target.kind === 'markdown') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
||||
<MarkdownPreviewContent content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'} maxBlocks={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'code' || target.kind === 'document') {
|
||||
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
||||
|
||||
if (resolvedLanguage === 'diff') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||
<CodexDiffBlock
|
||||
diffText={previewText || ''}
|
||||
summary={`${target.label} 기준 raw diff preview입니다.`}
|
||||
showToolbar={false}
|
||||
className="app-chat-panel__preview-diff"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||
{renderEditorBlock(previewText || '표시할 preview 본문이 없습니다.', resolvedLanguage, 'code')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAppRouteUrl(target.url)) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="앱 화면 경로는 preview iframe으로 열지 않습니다."
|
||||
description="현재 화면 문맥은 이미 WebSocket으로 서버에 전달됩니다. 이 경로를 다시 열면 앱만 새로 렌더링되어 preview처럼 보이지 않을 수 있습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (canRenderFramePreview(target.url)) {
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel__preview-file">
|
||||
<Paragraph>
|
||||
브라우저에서 직접 렌더링하지 않는 형식입니다. 아래 버튼으로 새 탭에서 열거나 바로 다운로드할 수 있습니다.
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}>
|
||||
새 탭 열기
|
||||
</Button>
|
||||
<Button href={target.url} download icon={<DownloadOutlined />}>
|
||||
다운로드
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
497
src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx
Executable file
497
src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx
Executable file
@@ -0,0 +1,497 @@
|
||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { Button, Drawer, Empty, Modal, Space, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
cancelChatRuntimeJob,
|
||||
fetchChatRuntimeJobDetail,
|
||||
removeChatRuntimeJob,
|
||||
} from './chatUtils';
|
||||
import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
function formatRuntimeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return parsed.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function buildTerminalLabel(terminalStatus: ChatRuntimeJobDetail['terminalStatus']) {
|
||||
if (!terminalStatus) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (terminalStatus === 'cancelled') {
|
||||
return '취소됨';
|
||||
}
|
||||
|
||||
if (terminalStatus === 'removed') {
|
||||
return '대기열 제거됨';
|
||||
}
|
||||
|
||||
if (terminalStatus === 'failed') {
|
||||
return '실패';
|
||||
}
|
||||
|
||||
return '완료';
|
||||
}
|
||||
|
||||
function RuntimeJobList({
|
||||
title,
|
||||
emptyDescription,
|
||||
items,
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
onOpenLog,
|
||||
onCancelJob,
|
||||
onRemoveJob,
|
||||
pendingActionRequestId,
|
||||
}: {
|
||||
title: string;
|
||||
emptyDescription: string;
|
||||
items: ChatRuntimeJobItem[];
|
||||
activeSessionId: string;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenLog: (requestId: string) => void;
|
||||
onCancelJob: (requestId: string) => void;
|
||||
onRemoveJob: (requestId: string) => void;
|
||||
pendingActionRequestId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="app-chat-runtime__section">
|
||||
<div className="app-chat-runtime__section-header">
|
||||
<Text strong>{title}</Text>
|
||||
<Text type="secondary">{items.length}건</Text>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="app-chat-runtime__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-runtime__list">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={`${item.status}-${item.requestId}`}
|
||||
className={`app-chat-runtime__job${item.sessionId === activeSessionId ? ' app-chat-runtime__job--active' : ''}`}
|
||||
>
|
||||
<div className="app-chat-runtime__job-top">
|
||||
<div className="app-chat-runtime__job-headline">
|
||||
<Text strong>{item.status === 'running' ? '실행 중' : '대기 중'}</Text>
|
||||
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
||||
</div>
|
||||
<Space size={8} wrap className="app-chat-runtime__job-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
세션 보기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onOpenLog(item.requestId);
|
||||
}}
|
||||
>
|
||||
로그
|
||||
</Button>
|
||||
{item.status === 'running' ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={pendingActionRequestId === item.requestId}
|
||||
onClick={() => {
|
||||
onCancelJob(item.requestId);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={pendingActionRequestId === item.requestId}
|
||||
onClick={() => {
|
||||
onRemoveJob(item.requestId);
|
||||
}}
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
<Text type="secondary">세션: {item.sessionId}</Text>
|
||||
<Text type="secondary">요청: {item.requestId}</Text>
|
||||
<Text type="secondary">대기 시작: {formatRuntimeTime(item.enqueuedAt)}</Text>
|
||||
<Text type="secondary">실행 시작: {formatRuntimeTime(item.startedAt)}</Text>
|
||||
<Text type="secondary">PID: {item.pid ?? '-'}</Text>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentRuntimeList({
|
||||
items,
|
||||
onSelectSession,
|
||||
onOpenLog,
|
||||
}: {
|
||||
items: ChatRuntimeSnapshot['recent'];
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenLog: (requestId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="app-chat-runtime__section app-chat-runtime__section--recent">
|
||||
<div className="app-chat-runtime__section-header">
|
||||
<Text strong>최근 작업</Text>
|
||||
<Text type="secondary">{items.length}건</Text>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="app-chat-runtime__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="아직 종료된 작업 이력이 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-runtime__list">
|
||||
{items.map((item) => (
|
||||
<article key={`recent-${item.requestId}-${item.lastUpdatedAt}`} className="app-chat-runtime__job">
|
||||
<div className="app-chat-runtime__job-top">
|
||||
<div className="app-chat-runtime__job-headline">
|
||||
<Text strong>{buildTerminalLabel(item.terminalStatus)}</Text>
|
||||
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onOpenLog(item.requestId);
|
||||
}}
|
||||
>
|
||||
로그
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
채팅방 이동
|
||||
</Button>
|
||||
</div>
|
||||
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
<Text type="secondary">세션: {item.sessionId}</Text>
|
||||
<Text type="secondary">요청: {item.requestId}</Text>
|
||||
<Text type="secondary">종료 시각: {formatRuntimeTime(item.lastUpdatedAt)}</Text>
|
||||
<Text type="secondary">PID: {item.pid ?? '-'}</Text>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatRuntimeDashboard({
|
||||
snapshot,
|
||||
activeSessionId,
|
||||
connectionState,
|
||||
onSelectSession,
|
||||
socketRef,
|
||||
liveDetail,
|
||||
requestedLogRequestId,
|
||||
onRequestedLogHandled,
|
||||
}: {
|
||||
snapshot: ChatRuntimeSnapshot | null;
|
||||
activeSessionId: string;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
socketRef: { current: WebSocket | null };
|
||||
liveDetail: ChatRuntimeJobDetail | null;
|
||||
requestedLogRequestId?: string | null;
|
||||
onRequestedLogHandled?: () => void;
|
||||
}) {
|
||||
const sessions = snapshot?.sessions ?? [];
|
||||
const [selectedDetail, setSelectedDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
||||
const [logLoadError, setLogLoadError] = useState('');
|
||||
const [isLogLoading, setIsLogLoading] = useState(false);
|
||||
const [pendingActionRequestId, setPendingActionRequestId] = useState<string | null>(null);
|
||||
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const loadLogDetail = async (requestId: string) => {
|
||||
setIsLogLoading(true);
|
||||
setLogLoadError('');
|
||||
|
||||
try {
|
||||
const detail = await fetchChatRuntimeJobDetail(requestId);
|
||||
setSelectedDetail(detail);
|
||||
} catch (error) {
|
||||
setSelectedDetail(null);
|
||||
setLogLoadError(error instanceof Error ? error.message : '실행 로그를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setIsLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openLog = async (requestId: string) => {
|
||||
setIsLogModalOpen(true);
|
||||
await loadLogDetail(requestId);
|
||||
};
|
||||
|
||||
const handleCancel = async (requestId: string) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '실행 중 요청을 취소할까요?',
|
||||
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
|
||||
okText: '취소 실행',
|
||||
cancelText: '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingActionRequestId(requestId);
|
||||
|
||||
try {
|
||||
await cancelChatRuntimeJob(requestId);
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (requestId: string) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '대기열 요청을 제거할까요?',
|
||||
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
|
||||
okText: '제거',
|
||||
cancelText: '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingActionRequestId(requestId);
|
||||
|
||||
try {
|
||||
await removeChatRuntimeJob(requestId);
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogModalOpen || !selectedDetail?.item?.requestId || !liveDetail?.item?.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDetail.item.requestId !== liveDetail.item.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDetail(liveDetail);
|
||||
}, [isLogModalOpen, liveDetail, selectedDetail?.item?.requestId]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'runtime:watch',
|
||||
payload: {
|
||||
requestId: isLogModalOpen ? selectedDetail?.item?.requestId ?? null : null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [isLogModalOpen, selectedDetail?.item?.requestId, socketRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogModalOpen || !logViewerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
logViewerRef.current.scrollTop = logViewerRef.current.scrollHeight;
|
||||
}, [isLogModalOpen, selectedDetail?.logs]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = requestedLogRequestId?.trim();
|
||||
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLogModalOpen(true);
|
||||
void loadLogDetail(requestId).finally(() => {
|
||||
onRequestedLogHandled?.();
|
||||
});
|
||||
}, [onRequestedLogHandled, requestedLogRequestId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-chat-runtime">
|
||||
<div className="app-chat-runtime__summary-strip">
|
||||
<div className="app-chat-runtime__summary-card">
|
||||
<div className="app-chat-runtime__summary-metric">
|
||||
<LoadingOutlined />
|
||||
<Text type="secondary">실행 중</Text>
|
||||
<Text strong>{snapshot?.runningCount ?? 0}건</Text>
|
||||
</div>
|
||||
<div className="app-chat-runtime__summary-metric">
|
||||
<ClockCircleOutlined />
|
||||
<Text type="secondary">대기열</Text>
|
||||
<Text strong>{snapshot?.queuedCount ?? 0}건</Text>
|
||||
</div>
|
||||
<div className="app-chat-runtime__summary-metric">
|
||||
<EyeOutlined />
|
||||
<Text type="secondary">활성 세션</Text>
|
||||
<Text strong>{snapshot?.sessionCount ?? 0}개</Text>
|
||||
</div>
|
||||
<Text type="secondary" className="app-chat-runtime__summary-status">
|
||||
{connectionState === 'connected' ? '실시간' : connectionState === 'connecting' ? '재연결 중' : '연결 끊김'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-chat-runtime__session-strip">
|
||||
{sessions.length === 0 ? (
|
||||
<Text type="secondary">현재 활성 큐/실행 세션이 없습니다.</Text>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
type="button"
|
||||
className={`app-chat-runtime__session-chip${
|
||||
session.sessionId === activeSessionId ? ' app-chat-runtime__session-chip--active' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
<span>{session.sessionId}</span>
|
||||
<span>{`실행 ${session.runningCount} · 대기 ${session.queuedCount}`}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-chat-runtime__content">
|
||||
<RuntimeJobList
|
||||
title="실제 실행 중인 요청"
|
||||
emptyDescription="현재 실행 중인 Codex 요청이 없습니다."
|
||||
items={snapshot?.running ?? []}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onOpenLog={openLog}
|
||||
onCancelJob={handleCancel}
|
||||
onRemoveJob={handleRemove}
|
||||
pendingActionRequestId={pendingActionRequestId}
|
||||
/>
|
||||
<RuntimeJobList
|
||||
title="실제 대기열"
|
||||
emptyDescription="현재 대기 중인 요청이 없습니다."
|
||||
items={snapshot?.queued ?? []}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onOpenLog={openLog}
|
||||
onCancelJob={handleCancel}
|
||||
onRemoveJob={handleRemove}
|
||||
pendingActionRequestId={pendingActionRequestId}
|
||||
/>
|
||||
<RecentRuntimeList items={snapshot?.recent ?? []} onSelectSession={onSelectSession} onOpenLog={openLog} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={isLogModalOpen}
|
||||
title="실행 로그"
|
||||
placement="right"
|
||||
width="100vw"
|
||||
rootClassName="app-chat-runtime__drawer"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsLogModalOpen(false);
|
||||
setSelectedDetail(null);
|
||||
}}
|
||||
>
|
||||
{isLogLoading ? (
|
||||
<div className="app-chat-runtime__log-state">
|
||||
<Text type="secondary">로그를 불러오는 중입니다.</Text>
|
||||
</div>
|
||||
) : logLoadError ? (
|
||||
<div className="app-chat-runtime__log-state">
|
||||
<Text type="danger">{logLoadError}</Text>
|
||||
</div>
|
||||
) : selectedDetail ? (
|
||||
<div className="app-chat-runtime__log-modal">
|
||||
{selectedDetail.item?.sessionId ? (
|
||||
<Button
|
||||
size="small"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => {
|
||||
onSelectSession(selectedDetail.item?.sessionId ?? '');
|
||||
setIsLogModalOpen(false);
|
||||
}}
|
||||
>
|
||||
채팅방 이동
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
<Text type="secondary">요청: {selectedDetail.item?.requestId ?? '-'}</Text>
|
||||
<Text type="secondary">세션: {selectedDetail.item?.sessionId ?? '-'}</Text>
|
||||
<Text type="secondary">마지막 갱신: {formatRuntimeTime(selectedDetail.lastUpdatedAt)}</Text>
|
||||
<Text type="secondary">종료 상태: {buildTerminalLabel(selectedDetail.terminalStatus) || '-'}</Text>
|
||||
</div>
|
||||
<Paragraph className="app-chat-runtime__job-summary">
|
||||
{selectedDetail.item?.summary ?? '요약 없음'}
|
||||
</Paragraph>
|
||||
<pre ref={logViewerRef} className="app-chat-runtime__log-viewer">
|
||||
{selectedDetail.logs.length > 0 ? selectedDetail.logs.join('\n') : '아직 기록된 로그가 없습니다.'}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-runtime__log-state">
|
||||
<Text type="secondary">표시할 로그가 없습니다.</Text>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
327
src/app/main/mainChatPanel/ErrorLogViewer.tsx
Executable file
327
src/app/main/mainChatPanel/ErrorLogViewer.tsx
Executable file
@@ -0,0 +1,327 @@
|
||||
import { CloseOutlined, ExpandOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { PreviewerUI } from '../../../components/previewer';
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
import {
|
||||
buildErrorListPreviewLine,
|
||||
buildErrorMetaRows,
|
||||
buildErrorReferenceSummary,
|
||||
formatErrorLogTime,
|
||||
formatErrorSummary,
|
||||
getErrorResourceKindLabel,
|
||||
getErrorSourceColor,
|
||||
getErrorSourceLabel,
|
||||
renderErrorTabs,
|
||||
} from './errorLogUtils';
|
||||
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type ErrorLogViewerProps = {
|
||||
errorLogs: ErrorLogItem[];
|
||||
selectedErrorLog: ErrorLogItem | null;
|
||||
selectedErrorLogReferenceSummary: ErrorReferenceSummary | null;
|
||||
activeErrorResource: ErrorReferenceResource | null;
|
||||
isErrorDetailExpanded: boolean;
|
||||
isLoadingErrorLogs: boolean;
|
||||
errorLogLoadError: string;
|
||||
errorSourceSummary: Record<string, number>;
|
||||
onRefresh: () => void;
|
||||
onSelectErrorLog: (id: number) => void;
|
||||
onSelectResource: (url: string) => void;
|
||||
onToggleExpanded: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
function ErrorDetailContent({
|
||||
selectedErrorLog,
|
||||
selectedErrorLogReferenceSummary,
|
||||
activeErrorResource,
|
||||
onSelectResource,
|
||||
}: {
|
||||
selectedErrorLog: ErrorLogItem;
|
||||
selectedErrorLogReferenceSummary: ErrorReferenceSummary | null;
|
||||
activeErrorResource: ErrorReferenceResource | null;
|
||||
onSelectResource: (url: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{selectedErrorLogReferenceSummary ? (
|
||||
<div className="app-chat-panel__error-summary-strip">
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">preview</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.previewCount}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">image</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.imageCount}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">link</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.linkCount}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">참조 합계</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.resources.length}</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedErrorLogReferenceSummary?.resources.length ? (
|
||||
<div className="app-chat-panel__error-reference-stage">
|
||||
<div className="app-chat-panel__error-reference-rail">
|
||||
{selectedErrorLogReferenceSummary.resources.map((resource, index) => (
|
||||
<button
|
||||
key={`${selectedErrorLog.id}-${resource.url}`}
|
||||
type="button"
|
||||
className={
|
||||
resource.url === activeErrorResource?.url
|
||||
? 'app-chat-panel__error-reference-item app-chat-panel__error-reference-item--active'
|
||||
: 'app-chat-panel__error-reference-item'
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectResource(resource.url);
|
||||
}}
|
||||
>
|
||||
<div className="app-chat-panel__error-reference-item-top">
|
||||
<Text strong>{index + 1}</Text>
|
||||
<Tag>{getErrorResourceKindLabel(resource.kind)}</Tag>
|
||||
</div>
|
||||
<Text strong>{resource.label}</Text>
|
||||
<Text type="secondary">{resource.sourcePath}</Text>
|
||||
<Text type="secondary" className="app-chat-panel__error-reference-item-snippet">
|
||||
{resource.sourcePreview}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeErrorResource ? (
|
||||
<div className="app-chat-panel__error-reference-main">
|
||||
<div className="app-chat-panel__error-reference-main-top">
|
||||
<div>
|
||||
<Text strong>{activeErrorResource.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
{getErrorResourceKindLabel(activeErrorResource.kind)} · {activeErrorResource.sourcePath}
|
||||
</Text>
|
||||
</div>
|
||||
<Button size="small" type="primary" href={activeErrorResource.url} target="_blank" rel="noreferrer">
|
||||
새 탭에서 열기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="app-chat-panel__error-reference-main-meta">
|
||||
<div className="app-chat-panel__error-reference-main-meta-item">
|
||||
<Text type="secondary">종류</Text>
|
||||
<Text>{getErrorResourceKindLabel(activeErrorResource.kind)}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-reference-main-meta-item">
|
||||
<Text type="secondary">추출 경로</Text>
|
||||
<Text>{activeErrorResource.sourcePath}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-reference-main-meta-item app-chat-panel__error-reference-main-meta-item--wide">
|
||||
<Text type="secondary">URL</Text>
|
||||
<Text>{activeErrorResource.url}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeErrorResource.kind === 'image' ? (
|
||||
<PreviewerUI
|
||||
type="image"
|
||||
title={activeErrorResource.label}
|
||||
description={activeErrorResource.url}
|
||||
value={activeErrorResource.url}
|
||||
imageAlt={activeErrorResource.label}
|
||||
height={420}
|
||||
/>
|
||||
) : activeErrorResource.kind === 'preview' ? (
|
||||
<div className="app-chat-panel__error-preview-card app-chat-panel__error-preview-card--stage">
|
||||
<div className="app-chat-panel__error-preview-frame-wrap">
|
||||
<iframe
|
||||
className="app-chat-panel__error-preview-frame app-chat-panel__error-preview-frame--stage"
|
||||
src={activeErrorResource.url}
|
||||
title={activeErrorResource.label}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PreviewerUI
|
||||
type="text"
|
||||
title={activeErrorResource.label}
|
||||
description="외부 링크 참조"
|
||||
value={activeErrorResource.url}
|
||||
height="auto"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text type="secondary" className="app-chat-panel__error-preview-url">
|
||||
{activeErrorResource.url}
|
||||
</Text>
|
||||
|
||||
<div className="app-chat-panel__error-source-preview">
|
||||
<Text strong>참조 추출 위치</Text>
|
||||
<Text type="secondary">{activeErrorResource.sourcePath}</Text>
|
||||
<pre>{activeErrorResource.sourcePreview}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-chat-panel__error-detail-meta">
|
||||
{buildErrorMetaRows(selectedErrorLog).map((row) => (
|
||||
<div key={`${selectedErrorLog.id}-${row.label}`} className="app-chat-panel__error-detail-meta-row">
|
||||
<Text type="secondary">{row.label}</Text>
|
||||
<Text>{row.value}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Paragraph className="app-chat-panel__error-detail-line">
|
||||
<Text strong>에러 내용</Text>
|
||||
<br />
|
||||
{selectedErrorLog.errorMessage}
|
||||
</Paragraph>
|
||||
|
||||
{renderErrorTabs(selectedErrorLog)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorLogViewer({
|
||||
errorLogs,
|
||||
selectedErrorLog,
|
||||
selectedErrorLogReferenceSummary,
|
||||
activeErrorResource,
|
||||
isErrorDetailExpanded,
|
||||
isLoadingErrorLogs,
|
||||
errorLogLoadError,
|
||||
errorSourceSummary,
|
||||
onRefresh,
|
||||
onSelectErrorLog,
|
||||
onSelectResource,
|
||||
onToggleExpanded,
|
||||
}: ErrorLogViewerProps) {
|
||||
const errorDetailHeader = selectedErrorLog ? (
|
||||
<div className="app-chat-panel__error-detail-header">
|
||||
<div className="app-chat-panel__error-detail-header-meta">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color={getErrorSourceColor(selectedErrorLog.source)}>{getErrorSourceLabel(selectedErrorLog)}</Tag>
|
||||
<Tag>{selectedErrorLog.errorType}</Tag>
|
||||
{selectedErrorLog.errorName ? <Tag>{selectedErrorLog.errorName}</Tag> : null}
|
||||
{selectedErrorLog.statusCode ? <Tag>{selectedErrorLog.statusCode}</Tag> : null}
|
||||
{selectedErrorLog.relatedPlanId ? <Tag>Plan #{selectedErrorLog.relatedPlanId}</Tag> : null}
|
||||
{selectedErrorLog.relatedWorkId ? <Tag>{selectedErrorLog.relatedWorkId}</Tag> : null}
|
||||
</Space>
|
||||
<Text type="secondary">로그 #{selectedErrorLog.id}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-detail-actions">
|
||||
{isErrorDetailExpanded ? (
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} aria-label="상세영역 닫기" onClick={() => onToggleExpanded(false)} />
|
||||
) : (
|
||||
<Button type="text" size="small" icon={<ExpandOutlined />} aria-label="상세영역 최대화" onClick={() => onToggleExpanded(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel__error-layout">
|
||||
{isErrorDetailExpanded && selectedErrorLog ? (
|
||||
<div className="app-chat-panel__error-detail-screen">
|
||||
<div className="app-chat-panel__error-detail app-chat-panel__error-detail--expanded">
|
||||
{errorDetailHeader}
|
||||
<ErrorDetailContent
|
||||
selectedErrorLog={selectedErrorLog}
|
||||
selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
|
||||
activeErrorResource={activeErrorResource}
|
||||
onSelectResource={onSelectResource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-chat-panel__error-toolbar">
|
||||
<div className="app-chat-panel__error-toolbar-copy">
|
||||
<Text strong>최근 에러 로그 50건</Text>
|
||||
<Text type="secondary">프론트엔드, 워크서버 API, 작업 자동화 워커 오류를 함께 봅니다.</Text>
|
||||
</div>
|
||||
<Space size={[8, 8]} wrap>
|
||||
{Object.entries(errorSourceSummary).map(([label, count]) => (
|
||||
<Tag key={label}>{label} {count}</Tag>
|
||||
))}
|
||||
<Button size="small" icon={<ReloadOutlined />} loading={isLoadingErrorLogs} onClick={onRefresh}>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{errorLogLoadError ? <Alert showIcon type="error" message={errorLogLoadError} /> : null}
|
||||
|
||||
{isLoadingErrorLogs ? (
|
||||
<div className="app-chat-panel__error-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : errorLogs.length === 0 ? (
|
||||
<div className="app-chat-panel__error-empty">
|
||||
<Empty description="저장된 에러 로그가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-panel__error-content">
|
||||
<div className="app-chat-panel__error-list">
|
||||
{errorLogs.map((item) => {
|
||||
const referenceSummary = buildErrorReferenceSummary(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
item.id === selectedErrorLog?.id
|
||||
? 'app-chat-panel__error-item app-chat-panel__error-item--active'
|
||||
: 'app-chat-panel__error-item'
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectErrorLog(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="app-chat-panel__error-item-top">
|
||||
<Text type="secondary">{formatErrorLogTime(item.createdAt)}</Text>
|
||||
<Tag bordered={false} color={getErrorSourceColor(item.source)}>
|
||||
{getErrorSourceLabel(item)}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text strong className="app-chat-panel__error-item-title">{formatErrorSummary(item)}</Text>
|
||||
<Text type="secondary" className="app-chat-panel__error-item-message">
|
||||
{buildErrorListPreviewLine(item, referenceSummary)}
|
||||
</Text>
|
||||
<div className="app-chat-panel__error-item-badges">
|
||||
{referenceSummary.previewCount ? <Tag bordered={false}>preview {referenceSummary.previewCount}</Tag> : null}
|
||||
{referenceSummary.imageCount ? <Tag bordered={false}>image {referenceSummary.imageCount}</Tag> : null}
|
||||
{referenceSummary.linkCount ? <Tag bordered={false}>link {referenceSummary.linkCount}</Tag> : null}
|
||||
{item.statusCode ? <Tag bordered={false}>{item.statusCode}</Tag> : null}
|
||||
{item.requestMethod ? <Tag bordered={false}>{item.requestMethod}</Tag> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedErrorLog ? (
|
||||
<div className="app-chat-panel__error-detail">
|
||||
{errorDetailHeader}
|
||||
<ErrorDetailContent
|
||||
selectedErrorLog={selectedErrorLog}
|
||||
selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
|
||||
activeErrorResource={activeErrorResource}
|
||||
onSelectResource={onSelectResource}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/main/mainChatPanel/chatResourceUrl.ts
Normal file
38
src/app/main/mainChatPanel/chatResourceUrl.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
|
||||
|
||||
function extractEmbeddedResourcePath(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
|
||||
if (apiMarkerIndex >= 0) {
|
||||
return normalized.slice(apiMarkerIndex);
|
||||
}
|
||||
|
||||
const publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
|
||||
|
||||
if (publicMarkerIndex >= 0) {
|
||||
return normalized.slice(publicMarkerIndex);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeChatResourceUrl(value: string) {
|
||||
const normalized = extractEmbeddedResourcePath(value);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(normalized, window.location.href).toString();
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
1283
src/app/main/mainChatPanel/chatUtils.ts
Normal file
1283
src/app/main/mainChatPanel/chatUtils.ts
Normal file
File diff suppressed because it is too large
Load Diff
468
src/app/main/mainChatPanel/errorLogUtils.tsx
Executable file
468
src/app/main/mainChatPanel/errorLogUtils.tsx
Executable file
@@ -0,0 +1,468 @@
|
||||
import { Button, Tabs, Typography } from 'antd';
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
import { PreviewerUI } from '../../../components/previewer';
|
||||
import type { ErrorReferenceCandidate, ErrorReferenceResource, ErrorReferenceSummary } from './errorLogUtils.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
const URL_PATTERN = /https?:\/\/[^\s)]+/gi;
|
||||
const IMAGE_URL_PATTERN = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:[?#].*)?$/i;
|
||||
const PREVIEW_KEY_PATTERN = /(preview|deploy|vercel|netlify|storybook|localhost|127\.0\.0\.1|pageUrl)/i;
|
||||
|
||||
export function formatErrorLogTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
export function getErrorSourceColor(source: string) {
|
||||
if (source === 'automation') {
|
||||
return 'volcano';
|
||||
}
|
||||
|
||||
if (source === 'server') {
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
return 'gold';
|
||||
}
|
||||
|
||||
export function getErrorSourceLabel(item: ErrorLogItem) {
|
||||
return item.sourceLabel || item.source;
|
||||
}
|
||||
|
||||
export function formatErrorSummary(item: ErrorLogItem) {
|
||||
const label = item.errorName || item.errorType;
|
||||
const planLabel =
|
||||
item.relatedPlanId || item.relatedWorkId
|
||||
? [item.relatedPlanId ? `#${item.relatedPlanId}` : null, item.relatedWorkId].filter(Boolean).join(' ')
|
||||
: null;
|
||||
|
||||
return planLabel ? `${label} · ${planLabel}` : label;
|
||||
}
|
||||
|
||||
export function formatCompactPath(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value.length <= 42) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `...${value.slice(-39)}`;
|
||||
}
|
||||
|
||||
export function stringifyErrorDetail(value: unknown) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildErrorMetaRows(item: ErrorLogItem) {
|
||||
return [
|
||||
{ label: '발생 시각', value: formatErrorLogTime(item.createdAt) },
|
||||
{ label: '출처', value: getErrorSourceLabel(item) },
|
||||
{ label: '유형', value: item.errorType },
|
||||
item.errorName ? { label: '이름', value: item.errorName } : null,
|
||||
item.statusCode ? { label: '상태 코드', value: String(item.statusCode) } : null,
|
||||
item.requestMethod || item.requestPath
|
||||
? { label: '요청', value: [item.requestMethod, item.requestPath].filter(Boolean).join(' ') }
|
||||
: null,
|
||||
item.relatedPlanId ? { label: '작업', value: `#${item.relatedPlanId}` } : null,
|
||||
item.relatedWorkId ? { label: '작업 ID', value: item.relatedWorkId } : null,
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
function collectReferenceCandidates(value: unknown, path = 'context'): ErrorReferenceCandidate[] {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [{ path, value: trimmed }] : [];
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return [{ path, value: String(value) }];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry, index) => collectReferenceCandidates(entry, `${path}[${index}]`));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.entries(value).flatMap(([key, entry]) => collectReferenceCandidates(entry, `${path}.${key}`));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatReferenceLabel(path: string, kind: ErrorReferenceResource['kind'], index: number) {
|
||||
const pathLabel = path.split('.').pop()?.replace(/\[\d+\]/g, '') || '참조';
|
||||
|
||||
if (kind === 'preview') {
|
||||
return `${pathLabel} preview ${index}`;
|
||||
}
|
||||
|
||||
if (kind === 'image') {
|
||||
return `${pathLabel} image ${index}`;
|
||||
}
|
||||
|
||||
return `${pathLabel} link ${index}`;
|
||||
}
|
||||
|
||||
function buildReferenceSourcePreview(sourceText: string, url: string) {
|
||||
const normalized = sourceText.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const matchIndex = normalized.indexOf(url);
|
||||
|
||||
if (matchIndex < 0) {
|
||||
return normalized.length > 220 ? `${normalized.slice(0, 220)}...` : normalized;
|
||||
}
|
||||
|
||||
const start = Math.max(0, matchIndex - 96);
|
||||
const end = Math.min(normalized.length, matchIndex + url.length + 96);
|
||||
const prefix = start > 0 ? '... ' : '';
|
||||
const suffix = end < normalized.length ? ' ...' : '';
|
||||
|
||||
return `${prefix}${normalized.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
function buildErrorReferenceResources(item: ErrorLogItem): ErrorReferenceResource[] {
|
||||
const sourceTexts: ErrorReferenceCandidate[] = [
|
||||
{ path: 'errorMessage', value: item.errorMessage },
|
||||
item.detail ? { path: 'detail', value: item.detail } : null,
|
||||
item.stackTrace ? { path: 'stackTrace', value: item.stackTrace } : null,
|
||||
item.requestPath ? { path: 'requestPath', value: item.requestPath } : null,
|
||||
...(item.context ? collectReferenceCandidates(item.context) : []),
|
||||
].filter(Boolean) as ErrorReferenceCandidate[];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const resources: ErrorReferenceResource[] = [];
|
||||
|
||||
sourceTexts.forEach(({ path, value }) => {
|
||||
const matches = value.match(URL_PATTERN) ?? [];
|
||||
|
||||
matches.forEach((rawUrl) => {
|
||||
const url = rawUrl.replace(/[),.;]+$/g, '');
|
||||
|
||||
if (!url || seen.has(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = IMAGE_URL_PATTERN.test(url)
|
||||
? 'image'
|
||||
: PREVIEW_KEY_PATTERN.test(path) || PREVIEW_KEY_PATTERN.test(url)
|
||||
? 'preview'
|
||||
: 'link';
|
||||
|
||||
seen.add(url);
|
||||
resources.push({
|
||||
url,
|
||||
label: formatReferenceLabel(path, kind, resources.length + 1),
|
||||
kind,
|
||||
sourcePath: path,
|
||||
sourcePreview: buildReferenceSourcePreview(value, url),
|
||||
});
|
||||
});
|
||||
|
||||
if (!matches.length && /^https?:\/\//i.test(value.trim()) && !seen.has(value.trim())) {
|
||||
const url = value.trim();
|
||||
const kind = IMAGE_URL_PATTERN.test(url)
|
||||
? 'image'
|
||||
: PREVIEW_KEY_PATTERN.test(path) || PREVIEW_KEY_PATTERN.test(url)
|
||||
? 'preview'
|
||||
: 'link';
|
||||
|
||||
seen.add(url);
|
||||
resources.push({
|
||||
url,
|
||||
label: formatReferenceLabel(path, kind, resources.length + 1),
|
||||
kind,
|
||||
sourcePath: path,
|
||||
sourcePreview: buildReferenceSourcePreview(value, url),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
export function buildErrorReferenceSummary(item: ErrorLogItem): ErrorReferenceSummary {
|
||||
const resources = buildErrorReferenceResources(item);
|
||||
const previewCount = resources.filter((resource) => resource.kind === 'preview').length;
|
||||
const imageCount = resources.filter((resource) => resource.kind === 'image').length;
|
||||
const linkCount = resources.filter((resource) => resource.kind === 'link').length;
|
||||
return { resources, previewCount, imageCount, linkCount };
|
||||
}
|
||||
|
||||
export function getDefaultErrorResource(resources: ErrorReferenceResource[]) {
|
||||
return resources.find((resource) => resource.kind === 'preview')
|
||||
?? resources.find((resource) => resource.kind === 'image')
|
||||
?? resources[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function getErrorResourceKindLabel(kind: ErrorReferenceResource['kind']) {
|
||||
if (kind === 'preview') {
|
||||
return 'preview';
|
||||
}
|
||||
|
||||
if (kind === 'image') {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'link';
|
||||
}
|
||||
|
||||
export function buildErrorListPreviewLine(item: ErrorLogItem, summary: ErrorReferenceSummary) {
|
||||
const compactMessage = item.errorMessage.replace(/\s+/g, ' ').trim();
|
||||
const focusLabel = item.requestPath || compactMessage;
|
||||
const referenceTotal = summary.resources.length;
|
||||
|
||||
return [formatCompactPath(focusLabel), referenceTotal ? `참조 ${referenceTotal}` : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function buildErrorOverviewText(item: ErrorLogItem) {
|
||||
return [
|
||||
`에러: ${item.errorMessage}`,
|
||||
...buildErrorMetaRows(item).map((row) => `${row.label}: ${row.value}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildErrorDetailTabs(item: ErrorLogItem) {
|
||||
const resources = buildErrorReferenceSummary(item).resources;
|
||||
const imageResources = resources.filter((resource) => resource.kind === 'image');
|
||||
const previewResources = resources.filter((resource) => resource.kind === 'preview');
|
||||
const linkResources = resources.filter((resource) => resource.kind === 'link');
|
||||
const tabs = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '개요',
|
||||
children: (
|
||||
<div className="app-chat-panel__error-tab-stack">
|
||||
{previewResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{previewResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<PreviewerUI
|
||||
type="text"
|
||||
title={formatErrorSummary(item)}
|
||||
description="선택한 로그의 핵심 정보"
|
||||
value={buildErrorOverviewText(item)}
|
||||
height="auto"
|
||||
/>
|
||||
{linkResources.length || imageResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{linkResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (item.detail) {
|
||||
tabs.push({
|
||||
key: 'detail',
|
||||
label: '상세',
|
||||
children: (
|
||||
<PreviewerUI
|
||||
type="code"
|
||||
title="Error Detail"
|
||||
description="상세 에러 데이터"
|
||||
value={stringifyErrorDetail(item.detail)}
|
||||
language="json"
|
||||
height="auto"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (item.context) {
|
||||
tabs.push({
|
||||
key: 'context',
|
||||
label: '컨텍스트',
|
||||
children: (
|
||||
<PreviewerUI
|
||||
type="json"
|
||||
title="Error Context"
|
||||
description="추가 컨텍스트 데이터"
|
||||
value={item.context}
|
||||
height="auto"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (item.stackTrace) {
|
||||
tabs.push({
|
||||
key: 'stack',
|
||||
label: '스택',
|
||||
children: (
|
||||
<PreviewerUI
|
||||
type="code"
|
||||
title="Stack Trace"
|
||||
description="에러 스택"
|
||||
value={item.stackTrace}
|
||||
language="bash"
|
||||
height="auto"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (imageResources.length || linkResources.length) {
|
||||
tabs.push({
|
||||
key: 'references',
|
||||
label: '참조',
|
||||
children: (
|
||||
<div className="app-chat-panel__error-tab-stack">
|
||||
{imageResources.length ? (
|
||||
<div className="app-chat-panel__error-image-grid">
|
||||
{imageResources.map((resource) => (
|
||||
<PreviewerUI
|
||||
key={resource.url}
|
||||
type="image"
|
||||
title={resource.label}
|
||||
description={`${resource.sourcePath} · ${resource.url}`}
|
||||
value={resource.url}
|
||||
imageAlt={resource.label}
|
||||
height={320}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{previewResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{previewResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{linkResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{linkResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (previewResources.length) {
|
||||
tabs.push({
|
||||
key: 'preview',
|
||||
label: '미리보기',
|
||||
children: (
|
||||
<div className="app-chat-panel__error-preview-grid">
|
||||
{previewResources.map((resource) => (
|
||||
<section key={resource.url} className="app-chat-panel__error-preview-card">
|
||||
<div className="app-chat-panel__error-preview-card-top">
|
||||
<div>
|
||||
<Text strong>{resource.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary">{resource.sourcePath}</Text>
|
||||
</div>
|
||||
<Button size="small" type="primary" href={resource.url} target="_blank" rel="noreferrer">
|
||||
열기
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-preview-frame-wrap">
|
||||
<iframe
|
||||
className="app-chat-panel__error-preview-frame"
|
||||
src={resource.url}
|
||||
title={resource.label}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" className="app-chat-panel__error-preview-url">
|
||||
{resource.url}
|
||||
</Text>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export function buildErrorSourceSummary(errorLogs: ErrorLogItem[]) {
|
||||
return errorLogs.reduce<Record<string, number>>((acc, item) => {
|
||||
const key = getErrorSourceLabel(item);
|
||||
acc[key] = (acc[key] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function renderErrorTabs(item: ErrorLogItem) {
|
||||
return <Tabs className="app-chat-panel__error-tabs" items={buildErrorDetailTabs(item)} />;
|
||||
}
|
||||
8
src/app/main/mainChatPanel/errorLogUtils.types.ts
Executable file
8
src/app/main/mainChatPanel/errorLogUtils.types.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
|
||||
|
||||
export type { ErrorReferenceResource, ErrorReferenceSummary };
|
||||
|
||||
export type ErrorReferenceCandidate = {
|
||||
path: string;
|
||||
value: string;
|
||||
};
|
||||
42
src/app/main/mainChatPanel/index.ts
Normal file
42
src/app/main/mainChatPanel/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export { ChatConversationView } from './ChatConversationView';
|
||||
export { ChatRuntimeDashboard } from './ChatRuntimeDashboard';
|
||||
export { ErrorLogViewer } from './ErrorLogViewer';
|
||||
export {
|
||||
buildOfflineReply,
|
||||
clearStoredChatClientConversationState,
|
||||
copyText,
|
||||
createActivityLogPlaceholder,
|
||||
createChatConversationRoom,
|
||||
createChatMessage,
|
||||
createIntroMessage,
|
||||
createLocalMessage,
|
||||
cancelChatRuntimeJob,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
fetchChatConversations,
|
||||
fetchChatRuntimeJobDetail,
|
||||
fetchChatRuntimeSnapshot,
|
||||
getStoredChatSessionLastTypeId,
|
||||
isPreparingChatReplyText,
|
||||
getChatClientSessionId,
|
||||
loadStoredChatMessages,
|
||||
markChatConversationResponsesRead,
|
||||
mergeRecoveredChatMessages,
|
||||
persistStoredChatMessages,
|
||||
renameChatConversationRoom,
|
||||
removeChatRuntimeJob,
|
||||
resetLastReceivedChatEventId,
|
||||
setStoredChatSessionLastTypeId,
|
||||
setChatClientSessionId,
|
||||
uploadChatComposerFile,
|
||||
upsertChatMessage,
|
||||
updateChatConversationRoom,
|
||||
} from './chatUtils';
|
||||
export {
|
||||
getSharedChatRuntimeSnapshot,
|
||||
setSharedChatRuntimeSnapshot,
|
||||
subscribeChatConnection,
|
||||
useChatConnection,
|
||||
} from './useChatConnection';
|
||||
export { useErrorLogs } from './useErrorLogs';
|
||||
44
src/app/main/mainChatPanel/sharedActiveConversation.ts
Normal file
44
src/app/main/mainChatPanel/sharedActiveConversation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type SharedActiveConversationSnapshot = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const sharedActiveConversation = {
|
||||
sessionId: '',
|
||||
title: '',
|
||||
subscribers: new Set<() => void>(),
|
||||
};
|
||||
|
||||
function emitSharedActiveConversation() {
|
||||
sharedActiveConversation.subscribers.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
export function getSharedActiveConversationSnapshot(): SharedActiveConversationSnapshot {
|
||||
return {
|
||||
sessionId: sharedActiveConversation.sessionId,
|
||||
title: sharedActiveConversation.title,
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeSharedActiveConversation(listener: () => void) {
|
||||
sharedActiveConversation.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
sharedActiveConversation.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function setSharedActiveConversationSnapshot(snapshot: SharedActiveConversationSnapshot) {
|
||||
const nextSessionId = snapshot.sessionId.trim();
|
||||
const nextTitle = snapshot.title.trim();
|
||||
|
||||
if (sharedActiveConversation.sessionId === nextSessionId && sharedActiveConversation.title === nextTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedActiveConversation.sessionId = nextSessionId;
|
||||
sharedActiveConversation.title = nextTitle;
|
||||
emitSharedActiveConversation();
|
||||
}
|
||||
251
src/app/main/mainChatPanel/types.ts
Executable file
251
src/app/main/mainChatPanel/types.ts
Executable file
@@ -0,0 +1,251 @@
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
author: 'codex' | 'system' | 'user';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
clientRequestId?: string | null;
|
||||
deliveryStatus?: 'retrying' | 'failed' | null;
|
||||
retryCount?: number;
|
||||
};
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
publicUrl: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type ChatViewContext = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
topMenu: string;
|
||||
focusedComponentId: string | null;
|
||||
pageUrl: string;
|
||||
isStandaloneMode: boolean;
|
||||
pageVisibilityState: 'visible' | 'hidden';
|
||||
chatTypeId: string | null;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ChatConversationSummary = {
|
||||
sessionId: string;
|
||||
clientId: string | null;
|
||||
title: string;
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
notifyOffline: boolean;
|
||||
hasUnreadResponse: boolean;
|
||||
currentRequestId: string | null;
|
||||
currentJobStatus: ChatJobStatus | null;
|
||||
currentJobMessage: string | null;
|
||||
currentQueueSize: number;
|
||||
currentStatusUpdatedAt: string | null;
|
||||
lastMessagePreview: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastMessageAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatConversationRequestStatus =
|
||||
| 'accepted'
|
||||
| 'queued'
|
||||
| 'started'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'removed';
|
||||
|
||||
export type ChatConversationRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
status: ChatConversationRequestStatus;
|
||||
statusMessage: string | null;
|
||||
userMessageId: number | null;
|
||||
userText: string;
|
||||
responseMessageId: number | null;
|
||||
responseText: string;
|
||||
hasResponse: boolean;
|
||||
canDelete: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
answeredAt: string | null;
|
||||
terminalAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatConversationActivityLog = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
lines: string[];
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatActivityEvent = {
|
||||
requestId: string;
|
||||
line: string;
|
||||
lineCount: number;
|
||||
};
|
||||
|
||||
export type ChatPanelView = 'chat' | 'runtime' | 'errors';
|
||||
|
||||
export type ChatJobMode = 'queue' | 'direct';
|
||||
export type ChatJobStatus = 'queued' | 'started' | 'completed' | 'failed';
|
||||
|
||||
export type ChatRuntimeJobStatus = 'queued' | 'running';
|
||||
|
||||
export type ChatJobEvent = {
|
||||
requestId: string;
|
||||
status: ChatJobStatus;
|
||||
mode: ChatJobMode;
|
||||
queueSize: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ChatRuntimeJobItem = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
mode: ChatJobMode;
|
||||
status: ChatRuntimeJobStatus;
|
||||
summary: string;
|
||||
enqueuedAt: string;
|
||||
startedAt: string | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSessionSummary = {
|
||||
sessionId: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
latestRequestId: string | null;
|
||||
latestStatus: ChatRuntimeJobStatus | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSnapshot = {
|
||||
generatedAt: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
sessionCount: number;
|
||||
running: ChatRuntimeJobItem[];
|
||||
queued: ChatRuntimeJobItem[];
|
||||
sessions: ChatRuntimeSessionSummary[];
|
||||
recent: Array<ChatRuntimeJobItem & { terminalStatus: ChatRuntimeTerminalStatus; lastUpdatedAt: string }>;
|
||||
};
|
||||
|
||||
export type ChatRuntimeTerminalStatus = 'completed' | 'failed' | 'cancelled' | 'removed';
|
||||
|
||||
export type ChatRuntimeJobDetail = {
|
||||
item: ChatRuntimeJobItem | null;
|
||||
logs: string[];
|
||||
lastUpdatedAt: string | null;
|
||||
terminalStatus: ChatRuntimeTerminalStatus | null;
|
||||
availableActions: {
|
||||
cancel: boolean;
|
||||
remove: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ErrorReferenceResource = {
|
||||
url: string;
|
||||
label: string;
|
||||
kind: 'image' | 'link' | 'preview';
|
||||
sourcePath: string;
|
||||
sourcePreview: string;
|
||||
};
|
||||
|
||||
export type ErrorReferenceSummary = {
|
||||
resources: ErrorReferenceResource[];
|
||||
previewCount: number;
|
||||
imageCount: number;
|
||||
linkCount: number;
|
||||
};
|
||||
|
||||
export type MainChatPanelProps = {
|
||||
initialView?: 'live' | 'errors';
|
||||
lockOuterScrollOnMobile?: boolean;
|
||||
};
|
||||
|
||||
export type ChatServerEvent =
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:init';
|
||||
payload: {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:message';
|
||||
payload: ChatMessage;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:message:update';
|
||||
payload: ChatMessage;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:status';
|
||||
payload: {
|
||||
connectedAt: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:error';
|
||||
payload: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:job';
|
||||
payload: ChatJobEvent;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:runtime';
|
||||
payload: ChatRuntimeSnapshot;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:runtime:detail';
|
||||
payload: ChatRuntimeJobDetail;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:activity';
|
||||
payload: ChatActivityEvent;
|
||||
};
|
||||
|
||||
export type ChatConversationDetailResponse = {
|
||||
ok: boolean;
|
||||
item: ChatConversationSummary;
|
||||
messages: ChatMessage[];
|
||||
requests: ChatConversationRequest[];
|
||||
activityLogs: ChatConversationActivityLog[];
|
||||
oldestLoadedMessageId: number | null;
|
||||
hasOlderMessages: boolean;
|
||||
};
|
||||
|
||||
export type ErrorLogViewerState = {
|
||||
errorLogs: ErrorLogItem[];
|
||||
selectedErrorLogId: number | null;
|
||||
isLoadingErrorLogs: boolean;
|
||||
errorLogLoadError: string;
|
||||
activeErrorResourceUrl: string;
|
||||
isErrorDetailExpanded: boolean;
|
||||
};
|
||||
689
src/app/main/mainChatPanel/useChatConnection.ts
Executable file
689
src/app/main/mainChatPanel/useChatConnection.ts
Executable file
@@ -0,0 +1,689 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
CHAT_CONNECTION,
|
||||
diagnoseConnectionFailure,
|
||||
getLastReceivedChatEventId,
|
||||
handleChatServerEvent,
|
||||
persistLastReceivedChatEventId,
|
||||
resolveChatWebSocketUrl,
|
||||
} from './chatUtils';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
ChatJobEvent,
|
||||
ChatMessage,
|
||||
ChatRuntimeJobDetail,
|
||||
ChatRuntimeSnapshot,
|
||||
ChatViewContext,
|
||||
} from './types';
|
||||
|
||||
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';
|
||||
|
||||
type UseChatConnectionOptions = {
|
||||
sessionId: string;
|
||||
currentContext: ChatViewContext;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
onMessageEvent?: (message: ChatMessage, sessionId: string) => void;
|
||||
onJobEvent?: (event: ChatJobEvent, sessionId: string) => void;
|
||||
onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void;
|
||||
onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void;
|
||||
onActivityEvent?: (event: ChatActivityEvent) => void;
|
||||
};
|
||||
|
||||
type SharedChatConnectionState = {
|
||||
connectionState: ConnectionState;
|
||||
connectionErrorDetail: string;
|
||||
runtimeSnapshot: ChatRuntimeSnapshot | null;
|
||||
};
|
||||
|
||||
type SharedChatConnection = SharedChatConnectionState & {
|
||||
socketRef: { current: WebSocket | null };
|
||||
reconnectTimerId: number | null;
|
||||
disconnectUiTimerId: number | null;
|
||||
connectTimeoutId: number | null;
|
||||
sessionId: string;
|
||||
currentContext: ChatViewContext | null;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>> | null;
|
||||
onMessageEvent?: ((message: ChatMessage, sessionId: string) => void) | undefined;
|
||||
onJobEvent?: ((event: ChatJobEvent, sessionId: string) => void) | undefined;
|
||||
onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined;
|
||||
onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined;
|
||||
onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined;
|
||||
lastEventId: number;
|
||||
websocketUrl: string;
|
||||
subscribers: Set<() => void>;
|
||||
pingSubscriberCount: number;
|
||||
consumerCount: number;
|
||||
pingIntervalId: number | null;
|
||||
visibilityHandlerInstalled: boolean;
|
||||
pageShowHandlerInstalled: boolean;
|
||||
focusHandlerInstalled: boolean;
|
||||
onlineHandlerInstalled: boolean;
|
||||
hasConnectedOnce: boolean;
|
||||
suppressDisconnectNotification: boolean;
|
||||
lastBackgroundAt: number | null;
|
||||
};
|
||||
|
||||
const sharedChatConnection: SharedChatConnection = {
|
||||
connectionState: 'connecting',
|
||||
connectionErrorDetail: '',
|
||||
runtimeSnapshot: null,
|
||||
socketRef: { current: null },
|
||||
reconnectTimerId: null,
|
||||
disconnectUiTimerId: null,
|
||||
connectTimeoutId: null,
|
||||
sessionId: '',
|
||||
currentContext: null,
|
||||
setMessages: null,
|
||||
onMessageEvent: undefined,
|
||||
onJobEvent: undefined,
|
||||
onRuntimeEvent: undefined,
|
||||
onRuntimeDetailEvent: undefined,
|
||||
onActivityEvent: undefined,
|
||||
lastEventId: 0,
|
||||
websocketUrl: '',
|
||||
subscribers: new Set(),
|
||||
pingSubscriberCount: 0,
|
||||
consumerCount: 0,
|
||||
pingIntervalId: null,
|
||||
visibilityHandlerInstalled: false,
|
||||
pageShowHandlerInstalled: false,
|
||||
focusHandlerInstalled: false,
|
||||
onlineHandlerInstalled: false,
|
||||
hasConnectedOnce: false,
|
||||
suppressDisconnectNotification: false,
|
||||
lastBackgroundAt: null,
|
||||
};
|
||||
|
||||
function emitSharedState() {
|
||||
sharedChatConnection.subscribers.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
function getSnapshot(): SharedChatConnectionState {
|
||||
return {
|
||||
connectionState: sharedChatConnection.connectionState,
|
||||
connectionErrorDetail: sharedChatConnection.connectionErrorDetail,
|
||||
runtimeSnapshot: sharedChatConnection.runtimeSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function getChatConnectionSnapshot() {
|
||||
return getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeChatConnection(listener: () => void) {
|
||||
sharedChatConnection.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
sharedChatConnection.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSharedChatRuntimeSnapshot() {
|
||||
return sharedChatConnection.runtimeSnapshot;
|
||||
}
|
||||
|
||||
export function setSharedChatRuntimeSnapshot(snapshot: ChatRuntimeSnapshot | null) {
|
||||
if (sharedChatConnection.runtimeSnapshot === snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.runtimeSnapshot = snapshot;
|
||||
emitSharedState();
|
||||
}
|
||||
|
||||
function setSharedConnectionState(nextState: ConnectionState) {
|
||||
if (sharedChatConnection.connectionState === nextState) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.connectionState = nextState;
|
||||
emitSharedState();
|
||||
}
|
||||
|
||||
function setSharedConnectionError(detail: string) {
|
||||
if (sharedChatConnection.connectionErrorDetail === detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.connectionErrorDetail = detail;
|
||||
emitSharedState();
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (sharedChatConnection.reconnectTimerId !== null) {
|
||||
window.clearTimeout(sharedChatConnection.reconnectTimerId);
|
||||
sharedChatConnection.reconnectTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDisconnectUiTimer() {
|
||||
if (sharedChatConnection.disconnectUiTimerId !== null) {
|
||||
window.clearTimeout(sharedChatConnection.disconnectUiTimerId);
|
||||
sharedChatConnection.disconnectUiTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectTimeout() {
|
||||
if (sharedChatConnection.connectTimeoutId !== null) {
|
||||
window.clearTimeout(sharedChatConnection.connectTimeoutId);
|
||||
sharedChatConnection.connectTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendContextUpdate(context: ChatViewContext | null = sharedChatConnection.currentContext) {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveVisibilityState =
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
|
||||
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'context:update',
|
||||
payload: {
|
||||
pageId: context.pageId,
|
||||
pageTitle: context.pageTitle,
|
||||
topMenu: context.topMenu,
|
||||
focusedComponentId: context.focusedComponentId,
|
||||
pageUrl: livePageUrl,
|
||||
isStandaloneMode: context.isStandaloneMode,
|
||||
pageVisibilityState: liveVisibilityState,
|
||||
chatTypeId: context.chatTypeId,
|
||||
chatTypeLabel: context.chatTypeLabel,
|
||||
chatTypeDescription: context.chatTypeDescription,
|
||||
chatTypeIsTemplate: context.chatTypeIsTemplate,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function sendPresencePing() {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'presence:ping',
|
||||
payload: {
|
||||
at: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function refreshSharedSocket() {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (socket && socket.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectSharedSocket();
|
||||
connectSharedSocket();
|
||||
}
|
||||
|
||||
function sendEventReceived(eventId: number) {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !Number.isFinite(eventId) || eventId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'event:received',
|
||||
payload: {
|
||||
eventId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stopPresenceMonitoring() {
|
||||
if (sharedChatConnection.pingIntervalId !== null) {
|
||||
window.clearInterval(sharedChatConnection.pingIntervalId);
|
||||
sharedChatConnection.pingIntervalId = null;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.visibilityHandlerInstalled) {
|
||||
window.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
sharedChatConnection.visibilityHandlerInstalled = false;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.pageShowHandlerInstalled) {
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
sharedChatConnection.pageShowHandlerInstalled = false;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.focusHandlerInstalled) {
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
sharedChatConnection.focusHandlerInstalled = false;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.onlineHandlerInstalled) {
|
||||
window.removeEventListener('online', handleWindowOnline);
|
||||
sharedChatConnection.onlineHandlerInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
sendPresencePing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
sendPresencePing();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
}
|
||||
|
||||
function handlePageShow() {
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
sendPresencePing();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
}
|
||||
|
||||
function handleWindowFocus() {
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
sendPresencePing();
|
||||
}
|
||||
|
||||
function handleWindowOnline() {
|
||||
refreshSharedSocket();
|
||||
}
|
||||
|
||||
function startPresenceMonitoring() {
|
||||
if (sharedChatConnection.pingSubscriberCount <= 0 || sharedChatConnection.connectionState !== 'connected') {
|
||||
stopPresenceMonitoring();
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.lastBackgroundAt = null;
|
||||
sendPresencePing();
|
||||
|
||||
if (sharedChatConnection.pingIntervalId === null) {
|
||||
sharedChatConnection.pingIntervalId = window.setInterval(() => {
|
||||
sendPresencePing();
|
||||
}, PRESENCE_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.visibilityHandlerInstalled) {
|
||||
window.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
sharedChatConnection.visibilityHandlerInstalled = true;
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.pageShowHandlerInstalled) {
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
sharedChatConnection.pageShowHandlerInstalled = true;
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.focusHandlerInstalled) {
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
sharedChatConnection.focusHandlerInstalled = true;
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.onlineHandlerInstalled) {
|
||||
window.addEventListener('online', handleWindowOnline);
|
||||
sharedChatConnection.onlineHandlerInstalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (sharedChatConnection.reconnectTimerId !== null || !sharedChatConnection.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.reconnectTimerId = window.setTimeout(() => {
|
||||
sharedChatConnection.reconnectTimerId = null;
|
||||
connectSharedSocket();
|
||||
}, CHAT_CONNECTION.reconnectDelayMs);
|
||||
}
|
||||
|
||||
function handleSharedDisconnect(message?: string, detail?: string) {
|
||||
setSharedConnectionError(detail ?? '');
|
||||
clearDisconnectUiTimer();
|
||||
|
||||
if (sharedChatConnection.connectionState !== 'connected') {
|
||||
setSharedConnectionState('disconnected');
|
||||
} else {
|
||||
sharedChatConnection.disconnectUiTimerId = window.setTimeout(() => {
|
||||
sharedChatConnection.disconnectUiTimerId = null;
|
||||
|
||||
if (sharedChatConnection.socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSharedConnectionState('disconnected');
|
||||
}, DISCONNECT_UI_DELAY_MS);
|
||||
}
|
||||
|
||||
if (message) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSharedSocket() {
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
stopPresenceMonitoring();
|
||||
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
sharedChatConnection.suppressDisconnectNotification = true;
|
||||
sharedChatConnection.socketRef.current = null;
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
function releaseSharedConnectionConsumer() {
|
||||
sharedChatConnection.consumerCount = Math.max(0, sharedChatConnection.consumerCount - 1);
|
||||
|
||||
if (sharedChatConnection.consumerCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.currentContext = null;
|
||||
sharedChatConnection.setMessages = null;
|
||||
sharedChatConnection.onMessageEvent = undefined;
|
||||
sharedChatConnection.onJobEvent = undefined;
|
||||
sharedChatConnection.onRuntimeEvent = undefined;
|
||||
sharedChatConnection.onRuntimeDetailEvent = undefined;
|
||||
setSharedChatRuntimeSnapshot(null);
|
||||
disconnectSharedSocket();
|
||||
setSharedConnectionError('');
|
||||
setSharedConnectionState('disconnected');
|
||||
}
|
||||
|
||||
function connectSharedSocket() {
|
||||
if (!sharedChatConnection.sessionId || !sharedChatConnection.setMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSocket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
|
||||
if (sharedChatConnection.connectionState !== 'connected') {
|
||||
setSharedConnectionState('connecting');
|
||||
}
|
||||
|
||||
sharedChatConnection.websocketUrl = resolveChatWebSocketUrl(sharedChatConnection.sessionId, sharedChatConnection.lastEventId);
|
||||
|
||||
let socket: WebSocket;
|
||||
|
||||
try {
|
||||
socket = new WebSocket(sharedChatConnection.websocketUrl);
|
||||
} catch {
|
||||
handleSharedDisconnect(
|
||||
`워크서버 WebSocket 주소가 올바르지 않습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`,
|
||||
'WebSocket 객체를 생성하지 못했습니다. 대상 주소 형식과 환경변수를 확인해 주세요.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.socketRef.current = socket;
|
||||
sharedChatConnection.suppressDisconnectNotification = false;
|
||||
let disconnectHandled = false;
|
||||
|
||||
const reportDisconnect = (message?: string, closeEvent?: CloseEvent) => {
|
||||
if (disconnectHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectHandled = true;
|
||||
|
||||
const wasSuppressed = sharedChatConnection.suppressDisconnectNotification;
|
||||
|
||||
if (sharedChatConnection.socketRef.current === socket) {
|
||||
sharedChatConnection.socketRef.current = null;
|
||||
}
|
||||
|
||||
sharedChatConnection.suppressDisconnectNotification = false;
|
||||
|
||||
if (wasSuppressed) {
|
||||
setSharedConnectionError('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeEvent?.code === 1000 && !message) {
|
||||
setSharedConnectionError('');
|
||||
return;
|
||||
}
|
||||
|
||||
void diagnoseConnectionFailure(sharedChatConnection.websocketUrl, closeEvent).then((detail) => {
|
||||
handleSharedDisconnect(message, detail);
|
||||
});
|
||||
};
|
||||
|
||||
sharedChatConnection.connectTimeoutId = window.setTimeout(() => {
|
||||
if (sharedChatConnection.socketRef.current !== socket || socket.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.socketRef.current = null;
|
||||
socket.close();
|
||||
reportDisconnect(
|
||||
`워크서버 연결 시간이 초과되었습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`,
|
||||
);
|
||||
}, CHAT_CONNECTION.connectTimeoutMs);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
sharedChatConnection.hasConnectedOnce = true;
|
||||
sharedChatConnection.suppressDisconnectNotification = false;
|
||||
setSharedConnectionState('connected');
|
||||
setSharedConnectionError('');
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
startPresenceMonitoring();
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const setMessages = sharedChatConnection.setMessages;
|
||||
|
||||
if (!setMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleChatServerEvent({
|
||||
eventData: String(event.data),
|
||||
currentPageUrl: sharedChatConnection.currentContext?.pageUrl ?? '',
|
||||
expectedSessionId: sharedChatConnection.sessionId,
|
||||
setMessages,
|
||||
onMessageEvent: sharedChatConnection.onMessageEvent,
|
||||
onJobEvent: sharedChatConnection.onJobEvent,
|
||||
onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
|
||||
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
|
||||
onActivityEvent: sharedChatConnection.onActivityEvent,
|
||||
onEventReceived: (eventId) => {
|
||||
sharedChatConnection.lastEventId = eventId;
|
||||
persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId);
|
||||
sendEventReceived(eventId);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const parsedEvent = JSON.parse(String(event.data)) as ChatRuntimeEventEnvelope | null;
|
||||
|
||||
if (parsedEvent?.type === 'chat:runtime') {
|
||||
setSharedChatRuntimeSnapshot(parsedEvent.payload);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads here; detailed parsing is already handled downstream
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
clearConnectTimeout();
|
||||
stopPresenceMonitoring();
|
||||
reportDisconnect(
|
||||
event.code === 1000 ? undefined : '워크서버 연결이 끊어졌습니다. 자동으로 다시 연결합니다.',
|
||||
event,
|
||||
);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
clearConnectTimeout();
|
||||
stopPresenceMonitoring();
|
||||
reportDisconnect('워크서버 WebSocket 연결에 실패했습니다. 자동으로 다시 연결합니다.');
|
||||
});
|
||||
}
|
||||
|
||||
type ChatRuntimeEventEnvelope = {
|
||||
type: 'chat:runtime';
|
||||
payload: ChatRuntimeSnapshot;
|
||||
};
|
||||
|
||||
function ensureSharedConnection(options: UseChatConnectionOptions) {
|
||||
const sessionChanged = sharedChatConnection.sessionId !== options.sessionId;
|
||||
|
||||
sharedChatConnection.currentContext = options.currentContext;
|
||||
sharedChatConnection.setMessages = options.setMessages;
|
||||
sharedChatConnection.onMessageEvent = options.onMessageEvent;
|
||||
sharedChatConnection.onJobEvent = options.onJobEvent;
|
||||
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
|
||||
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
|
||||
sharedChatConnection.onActivityEvent = options.onActivityEvent;
|
||||
|
||||
if (sessionChanged) {
|
||||
sharedChatConnection.sessionId = options.sessionId;
|
||||
sharedChatConnection.lastEventId = getLastReceivedChatEventId(options.sessionId);
|
||||
sharedChatConnection.hasConnectedOnce = false;
|
||||
disconnectSharedSocket();
|
||||
}
|
||||
|
||||
connectSharedSocket();
|
||||
}
|
||||
|
||||
export function useChatConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
}: UseChatConnectionOptions) {
|
||||
const [snapshot, setSnapshot] = useState<SharedChatConnectionState>(() => getSnapshot());
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.consumerCount += 1;
|
||||
|
||||
return () => {
|
||||
releaseSharedConnectionConsumer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSnapshotChange = () => {
|
||||
setSnapshot(getSnapshot());
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeChatConnection(handleSnapshotChange);
|
||||
ensureSharedConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
});
|
||||
handleSnapshotChange();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [sessionId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.currentContext = currentContext;
|
||||
sharedChatConnection.setMessages = setMessages;
|
||||
sharedChatConnection.onMessageEvent = onMessageEvent;
|
||||
sharedChatConnection.onJobEvent = onJobEvent;
|
||||
sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
|
||||
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
|
||||
sharedChatConnection.onActivityEvent = onActivityEvent;
|
||||
sendContextUpdate(currentContext);
|
||||
}, [
|
||||
currentContext,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.pingSubscriberCount += 1;
|
||||
startPresenceMonitoring();
|
||||
|
||||
return () => {
|
||||
sharedChatConnection.pingSubscriberCount = Math.max(0, sharedChatConnection.pingSubscriberCount - 1);
|
||||
|
||||
if (sharedChatConnection.pingSubscriberCount === 0) {
|
||||
stopPresenceMonitoring();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connectionState: snapshot.connectionState,
|
||||
connectionErrorDetail: snapshot.connectionErrorDetail,
|
||||
socketRef: sharedChatConnection.socketRef,
|
||||
};
|
||||
}
|
||||
83
src/app/main/mainChatPanel/useErrorLogs.ts
Executable file
83
src/app/main/mainChatPanel/useErrorLogs.ts
Executable file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchErrorLogs } from '../errorLogApi';
|
||||
import { buildErrorReferenceSummary, buildErrorSourceSummary, getDefaultErrorResource } from './errorLogUtils';
|
||||
import type { ChatPanelView } from './types';
|
||||
|
||||
type UseErrorLogsOptions = {
|
||||
activeView: ChatPanelView;
|
||||
hasAccess: boolean;
|
||||
};
|
||||
|
||||
export function useErrorLogs({ activeView, hasAccess }: UseErrorLogsOptions) {
|
||||
const [errorLogs, setErrorLogs] = useState<Awaited<ReturnType<typeof fetchErrorLogs>>>([]);
|
||||
const [selectedErrorLogId, setSelectedErrorLogId] = useState<number | null>(null);
|
||||
const [isLoadingErrorLogs, setIsLoadingErrorLogs] = useState(false);
|
||||
const [errorLogLoadError, setErrorLogLoadError] = useState('');
|
||||
const [activeErrorResourceUrl, setActiveErrorResourceUrl] = useState('');
|
||||
const [isErrorDetailExpanded, setIsErrorDetailExpanded] = useState(false);
|
||||
|
||||
const loadErrorLogs = async () => {
|
||||
if (!hasAccess || isLoadingErrorLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingErrorLogs(true);
|
||||
setErrorLogLoadError('');
|
||||
|
||||
try {
|
||||
const items = await fetchErrorLogs(50);
|
||||
setErrorLogs(items);
|
||||
setSelectedErrorLogId((current) => current ?? items[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
setErrorLogLoadError(error instanceof Error ? error.message : '에러 로그를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setIsLoadingErrorLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'errors' || !hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadErrorLogs();
|
||||
}, [activeView, hasAccess]);
|
||||
|
||||
const selectedErrorLog = useMemo(
|
||||
() => errorLogs.find((item) => item.id === selectedErrorLogId) ?? errorLogs[0] ?? null,
|
||||
[errorLogs, selectedErrorLogId],
|
||||
);
|
||||
const selectedErrorLogReferenceSummary = useMemo(
|
||||
() => (selectedErrorLog ? buildErrorReferenceSummary(selectedErrorLog) : null),
|
||||
[selectedErrorLog],
|
||||
);
|
||||
const activeErrorResource = useMemo(
|
||||
() =>
|
||||
selectedErrorLogReferenceSummary?.resources.find((resource) => resource.url === activeErrorResourceUrl)
|
||||
?? getDefaultErrorResource(selectedErrorLogReferenceSummary?.resources ?? []),
|
||||
[activeErrorResourceUrl, selectedErrorLogReferenceSummary],
|
||||
);
|
||||
const errorSourceSummary = useMemo(() => buildErrorSourceSummary(errorLogs), [errorLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = getDefaultErrorResource(selectedErrorLogReferenceSummary?.resources ?? [])?.url ?? '';
|
||||
setActiveErrorResourceUrl(nextUrl);
|
||||
}, [selectedErrorLog?.id, selectedErrorLogReferenceSummary]);
|
||||
|
||||
return {
|
||||
errorLogs,
|
||||
selectedErrorLog,
|
||||
selectedErrorLogId,
|
||||
selectedErrorLogReferenceSummary,
|
||||
activeErrorResource,
|
||||
errorSourceSummary,
|
||||
isLoadingErrorLogs,
|
||||
errorLogLoadError,
|
||||
activeErrorResourceUrl,
|
||||
isErrorDetailExpanded,
|
||||
setSelectedErrorLogId,
|
||||
setActiveErrorResourceUrl,
|
||||
setIsErrorDetailExpanded,
|
||||
loadErrorLogs,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user