Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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)} />;
}

View File

@@ -0,0 +1,8 @@
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
export type { ErrorReferenceResource, ErrorReferenceSummary };
export type ErrorReferenceCandidate = {
path: string;
value: string;
};

View 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';

View 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();
}

View 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;
};

View 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,
};
}

View 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,
};
}