import { AppstoreOutlined, PlusOutlined, CheckOutlined, CopyOutlined, EyeOutlined, LeftOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, } from '@ant-design/icons'; import { App, Alert, Button, Dropdown, Input, Modal, Select, Tag, Typography, type MenuProps } from 'antd'; import type { ChangeEvent } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ChatLinkCardPreview } from './mainChatPanel/ChatLinkCardPreview'; import { ChatPreviewBody, type ChatPreviewTarget } from './mainChatPanel/ChatPreviewBody'; import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload, } from './mainChatPanel/ChatPromptCard'; import { extractChatMessageParts } from './mainChatPanel/messageParts'; import { extractPreviewItems, type PreviewItem } from './mainChatPanel/previewItems'; import { stripHiddenPreviewTags } from './mainChatPanel/previewMarkers'; import type { ChatMessage, ChatMessagePart } from './mainChatPanel/types'; import './systemChatStyles/MainChatPanel.conversation.css'; import './systemChatStyles/MainChatPanel.preview-runtime.css'; import './SystemChatPage.css'; const { Paragraph, Text, Title } = Typography; type MockRequestStatus = 'completed' | 'pending'; type MockRequest = { requestId: string; createdAt: string; status: MockRequestStatus; statusLabel: string; statusColor: string; question: string; answer: string; answerParts?: ChatMessagePart[]; }; type SearchPanelMode = 'all' | 'apps'; type ExpandMode = 'latest' | 'pending' | 'all'; type SearchResult = { key: string; title: string; description: string; category: 'request' | 'response' | 'resource' | 'activity'; }; type SystemRenderedMessage = { visibleText: string; linkCardParts: Extract[]; promptParts: Extract[]; previewItems: PreviewItem[]; }; const INITIAL_REQUESTS: MockRequest[] = [ { requestId: 'system-request-001', createdAt: '05. 25. 21:30:00', status: 'completed', statusLabel: '완료', statusColor: 'green', question: '시스템 채팅 전용 기본 화면을 공유채팅과 같은 감성으로 맞춰 주세요.', answer: [ '현재 화면은 UI 전용 mock 입니다. 서버 호출 없이 공유채팅과 같은 구조를 그대로 보이도록 구성했습니다.', '', '[[link-card:시스템 채팅 화면 열기|/chat/system|열기]]', '', '[[preview:/src/app/main/SystemChatPage.tsx]]', ].join('\n'), }, { requestId: 'system-request-002', createdAt: '05. 25. 21:31:10', status: 'pending', statusLabel: '처리중·미확인', statusColor: 'gold', question: 'Codex Live, 공유채팅과 소스를 분리한 상태에서 이후 시스템 채팅 전용 기능만 추가할 수 있게 해 주세요.', answer: '이 페이지는 별도 파일과 시스템 채팅 전용 CSS 레이어를 사용합니다. 렌더 표현은 Codex Live와 같은 컴포넌트를 쓰더라도 상태와 배치는 여기서만 분리합니다.', answerParts: [ { type: 'prompt', title: '답변 방식 선택', description: '시스템 채팅 mock prompt 입니다.', submitLabel: 'mock 전송', mode: 'queue', freeTextLabel: '추가 요청', freeTextPlaceholder: '시스템 채팅에서 이어서 확인할 내용을 입력하세요.', options: [ { value: 'brief', label: '짧게 정리', description: '핵심만 바로 답합니다.', preview: { type: 'markdown', title: '짧게 정리 예시', content: '짧은 답변 예시: 핵심만 먼저 전달하고 필요시 후속 설명을 확장합니다.', url: null, alt: null, }, }, { value: 'full', label: '상세 설명', description: '배경과 이유까지 함께 답합니다.', preview: { type: 'markdown', title: '상세 설명 예시', content: '상세 설명 예시: 시스템 채팅 전용 흐름의 배경과 이유까지 함께 설명합니다.', url: null, alt: null, }, }, ], }, ], }, { requestId: 'system-request-003', createdAt: '05. 25. 21:33:40', status: 'completed', statusLabel: '완료', statusColor: 'green', question: '시스템 채팅 검색 모달과 토큰 관리 모달 배치를 실제 화면처럼 먼저 잡아 주세요.', answer: [ '검색 모달, 토큰 관리, 채팅방 설정 모달은 현재 mock 데이터로 열리며, 향후 시스템 채팅 전용 서버 연결만 붙일 수 있게 비워 두었습니다.', '', '[[link-card:토큰 관리 UI 안내|https://preview.sm-home.cloud/docs|열기]]', '', '[[preview:/src/app/main/SystemChatPage.css]]', ].join('\n'), }, { requestId: 'system-request-004', createdAt: '05. 25. 21:35:15', status: 'pending', statusLabel: '처리중·미확인', statusColor: 'gold', question: '이전 다음 이동이 현재 보이는 필터 기준으로만 동작하게 해 주세요.', answer: '필터가 전체면 전체 목록 기준, 처리중·미확인이면 해당 건들만 기준, 마지막건이면 현재 선택된 1건만 보이도록 맞춥니다.', }, { requestId: 'system-request-005', createdAt: '05. 25. 21:38:55', status: 'pending', statusLabel: '처리중·미확인', statusColor: 'gold', question: '웹소켓 상태는 제목 옆 점으로만 간단히 보여 주고, 현재 진행 상황 패널은 빼 주세요.', answer: '상태 표현은 헤더 옆 점으로 단순화하고, 별도 진행 상황 카드 없이 채팅 흐름만 보도록 구성할 수 있습니다.', }, ]; const SEARCH_RESULTS: SearchResult[] = [ { key: 'request-001', title: '질문 / 시스템 채팅 전용 기본 화면', description: '공유채팅과 같은 레이아웃으로 구성된 mock 질문 카드입니다.', category: 'request', }, { key: 'response-001', title: '답변 / UI 전용 mock 상태', description: '서버 호출 없이 동작하는 현재 화면 설명입니다.', category: 'response', }, { key: 'resource-001', title: '리소스 / 시스템 채팅 전용 스타일 복사본', description: '공유채팅과 분리된 전용 CSS 파일을 사용합니다.', category: 'resource', }, { key: 'activity-001', title: '활동 / 필터와 이전 다음 mock 흐름', description: '마지막건, 처리중·미확인, 전체 필터에 따라 보이는 흐름이 달라집니다.', category: 'activity', }, ]; function dedupePromptParts(parts: Extract[]) { const promptByKey = new Map>(); parts.forEach((part) => { const key = `${part.title}:${buildPromptTargetSignature(part)}`; if (!promptByKey.has(key)) { promptByKey.set(key, part); } }); return [...promptByKey.values()]; } function dedupeLinkCardParts(parts: Extract[]) { const linkCardByKey = new Map>(); parts.forEach((part) => { const key = `${part.title}:${part.url}:${part.actionLabel ?? ''}`; if (!linkCardByKey.has(key)) { linkCardByKey.set(key, part); } }); return [...linkCardByKey.values()]; } function extractSystemMessageRenderPayload(message: ChatMessage): SystemRenderedMessage { const extracted = extractChatMessageParts(message.text); const structuredParts = Array.isArray(message.parts) ? message.parts : []; const visibleText = stripHiddenPreviewTags(extracted.strippedText || message.text).trim(); const promptParts = dedupePromptParts([ ...structuredParts.filter((part): part is Extract => part.type === 'prompt'), ...extracted.parts.filter((part): part is Extract => part.type === 'prompt'), ]); const linkCardParts = dedupeLinkCardParts([ ...structuredParts.filter((part): part is Extract => part.type === 'link_card'), ...extracted.parts.filter((part): part is Extract => part.type === 'link_card'), ]); return { visibleText, linkCardParts, promptParts, previewItems: extractPreviewItems([message]), }; } function buildSystemPromptSelectionKey(messageId: number, promptIndex: number, target: Extract) { return `${messageId}:${promptIndex}:${target.title}:${buildPromptTargetSignature(target)}`; } function SystemChatPreviewCard({ item }: { item: PreviewItem }) { const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [previewText, setPreviewText] = useState(''); const [previewError, setPreviewError] = useState(''); const [previewContentType, setPreviewContentType] = useState(''); const [isLoading, setIsLoading] = useState(false); const target = useMemo( () => ({ label: item.label, url: item.url, kind: item.kind, }), [item.kind, item.label, item.url], ); useEffect(() => { if (!isPreviewOpen) { return undefined; } if (item.kind === 'image' || item.kind === 'video' || item.kind === 'pdf' || item.kind === 'file') { setPreviewText(''); setPreviewError(''); setPreviewContentType(''); setIsLoading(false); return undefined; } const controller = new AbortController(); setIsLoading(true); setPreviewError(''); fetch(item.url, { cache: 'no-store', credentials: 'include', signal: controller.signal, }) .then(async (response) => { if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`.trim()); } setPreviewContentType(response.headers.get('content-type') ?? ''); const text = await response.text(); setPreviewText(text); }) .catch((error: unknown) => { if (controller.signal.aborted) { return; } setPreviewText(''); setPreviewContentType(''); setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.'); }) .finally(() => { if (!controller.signal.aborted) { setIsLoading(false); } }); return () => controller.abort(); }, [isPreviewOpen, item.kind, item.url]); return (
{item.label} {item.kind} preview
{isPreviewOpen ? (
) : null}
); } function SystemChatMessageArtifacts({ message, promptDraftSelections, promptSubmittedSelections, onPromptSelectionChange, onPromptSubmitted, onPromptSubmit, }: { message: ChatMessage; promptDraftSelections: Record; promptSubmittedSelections: Record; onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void; onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void; onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise; }) { const { linkCardParts, promptParts, previewItems } = useMemo( () => extractSystemMessageRenderPayload(message), [message], ); if (linkCardParts.length === 0 && promptParts.length === 0 && previewItems.length === 0) { return null; } return (
{linkCardParts.length > 0 ? (
{linkCardParts.map((target) => ( ))}
) : null} {promptParts.length > 0 ? (
{promptParts.map((target, promptIndex) => { const selectionKey = buildSystemPromptSelectionKey(message.id, promptIndex, target); return ( onPromptSelectionChange(selectionKey, selection)} onSubmitted={(selection) => onPromptSubmitted(selectionKey, selection)} onSubmit={(payload) => onPromptSubmit(selectionKey, payload)} /> ); })}
) : null} {previewItems.length > 0 ? (
{previewItems.map((item) => ( ))}
) : null}
); } function SystemChatRequestCard({ request, isReplyActive, promptDraftSelections, promptSubmittedSelections, onReplyToggle, onComplete, onPromptSelectionChange, onPromptSubmitted, onPromptSubmit, }: { request: MockRequest; isReplyActive: boolean; promptDraftSelections: Record; promptSubmittedSelections: Record; onReplyToggle: (requestId: string) => void; onComplete: (requestId: string) => void; onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void; onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void; onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise; }) { const isCompleted = request.status === 'completed'; const questionMessage = useMemo( () => ({ id: Number(`${request.requestId.replace(/\D+/g, '') || '0'}1`), author: 'user', text: request.question, timestamp: request.createdAt, }), [request.createdAt, request.question, request.requestId], ); const answerMessage = useMemo( () => ({ id: Number(`${request.requestId.replace(/\D+/g, '') || '0'}2`), author: 'codex', text: request.answer, timestamp: request.createdAt, clientRequestId: request.requestId, parts: request.answerParts, }), [request.answer, request.answerParts, request.createdAt, request.requestId], ); return (
{request.createdAt}
질문 {extractSystemMessageRenderPayload(questionMessage).visibleText}
); } export function SystemChatPage() { const { message } = App.useApp(); const [requests, setRequests] = useState(INITIAL_REQUESTS); const [draftText, setDraftText] = useState(''); const [replyReferenceRequestId, setReplyReferenceRequestId] = useState('system-request-002'); const [promptDraftSelections, setPromptDraftSelections] = useState>({}); const [promptSubmittedSelections, setPromptSubmittedSelections] = useState>({}); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isTokenUsageOpen, setIsTokenUsageOpen] = useState(false); const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false); const [searchKeyword, setSearchKeyword] = useState(''); const [searchPanelMode, setSearchPanelMode] = useState('all'); const [expandMode, setExpandMode] = useState('pending'); const [selectedRequestId, setSelectedRequestId] = useState('system-request-002'); const [selectedAppEnvironment, setSelectedAppEnvironment] = useState<'preview' | 'test' | 'prod'>('preview'); const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState('system-chat'); const [editingRoomUseAccessPin, setEditingRoomUseAccessPin] = useState(true); const [editingRoomAccessPin, setEditingRoomAccessPin] = useState('1234'); const [editingRoomAccessPinPromptTtl, setEditingRoomAccessPinPromptTtl] = useState('30'); const attachInputRef = useRef(null); const filteredSearchResults = useMemo(() => { const normalizedKeyword = searchKeyword.trim().toLowerCase(); if (!normalizedKeyword) { return SEARCH_RESULTS; } return SEARCH_RESULTS.filter((item) => `${item.title} ${item.description} ${item.category}`.toLowerCase().includes(normalizedKeyword)); }, [searchKeyword]); const pendingRequests = useMemo(() => requests.filter((request) => request.status === 'pending'), [requests]); const navigationRequests = useMemo(() => (expandMode === 'pending' ? pendingRequests : requests), [expandMode, pendingRequests, requests]); useEffect(() => { if (navigationRequests.length === 0) { return; } if (!navigationRequests.some((request) => request.requestId === selectedRequestId)) { setSelectedRequestId(navigationRequests[0].requestId); } }, [navigationRequests, selectedRequestId]); const selectedRequest = useMemo( () => navigationRequests.find((request) => request.requestId === selectedRequestId) ?? navigationRequests[0] ?? requests[0], [navigationRequests, requests, selectedRequestId], ); const displayedRequests = useMemo(() => { if (expandMode === 'latest') { return selectedRequest ? [selectedRequest] : []; } return navigationRequests; }, [expandMode, navigationRequests, selectedRequest]); const selectedRequestIndex = useMemo( () => navigationRequests.findIndex((request) => request.requestId === selectedRequest?.requestId), [navigationRequests, selectedRequest], ); const canMoveToPreviousRequest = expandMode === 'latest' && selectedRequestIndex > 0; const canMoveToNextRequest = expandMode === 'latest' && selectedRequestIndex >= 0 && selectedRequestIndex < navigationRequests.length - 1; const hiddenPreviousCount = expandMode === 'latest' && selectedRequestIndex > 0 ? selectedRequestIndex : 0; const hiddenNextCount = expandMode === 'latest' && selectedRequestIndex >= 0 ? Math.max(0, navigationRequests.length - selectedRequestIndex - 1) : 0; const pendingCount = pendingRequests.length; const aggregateStatusTag = pendingCount > 0 ? { color: 'gold', label: '처리중·미확인' } : { color: 'green', label: '완료' }; const headerSummaryLabel = `입력 대기 · 처리 건수 ${requests.length}건 · 미확인 ${pendingCount}건`; const shareHeaderSettingsItems = useMemo( () => [ { key: 'conversation-summary', label: '현재 시스템 채팅방' }, { key: 'conversation-search', label: '통합검색' }, { key: 'conversation-refresh', label: '화면 새로고침' }, { key: 'conversation-apps', label: 'Apps' }, { type: 'divider' }, { key: 'conversation-filter-title', label: '콘텐츠 필터', disabled: true }, { key: 'conversation-filter-latest', label: '마지막건' }, { key: 'conversation-filter-pending', label: '처리중·미확인' }, { key: 'conversation-filter-all', label: '전체' }, { type: 'divider' }, { key: 'conversation-token-usage', label: '토큰 관리' }, { key: 'conversation-room-settings', label: '채팅방 설정' }, { key: 'conversation-clear', label: '채팅방 비우기' }, ], [], ); const handleHeaderMenuClick = ({ key }: { key: string }) => { if (key === 'conversation-search') { setIsSearchOpen(true); setSearchPanelMode('all'); return; } if (key === 'conversation-apps') { setIsSearchOpen(true); setSearchPanelMode('apps'); return; } if (key === 'conversation-filter-latest') { setExpandMode('latest'); return; } if (key === 'conversation-filter-pending') { setExpandMode('pending'); return; } if (key === 'conversation-filter-all') { setExpandMode('all'); return; } if (key === 'conversation-token-usage') { setIsTokenUsageOpen(true); return; } if (key === 'conversation-room-settings') { setIsRoomSettingsOpen(true); return; } message.info('시스템 채팅 UI mock 화면입니다.'); }; const handleSend = () => { message.info('시스템 채팅 UI mock 화면입니다. 현재는 서버 호출 없이 형태만 제공합니다.'); }; const handleOpenAttachPicker = () => { attachInputRef.current?.click(); }; const handleAttachSelection = (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (files.length > 0) { message.info('파일 또는 사진 ' + String(files.length) + '건 선택 mock 상태입니다.'); } event.target.value = ''; }; const handleMoveRequest = (direction: -1 | 1) => { const nextIndex = selectedRequestIndex + direction; const nextRequest = navigationRequests[nextIndex]; if (nextRequest) { setSelectedRequestId(nextRequest.requestId); } }; const handleCompleteRequest = (requestId: string) => { setRequests((current) => current.map((request) => request.requestId === requestId ? { ...request, status: 'completed', statusLabel: '완료', statusColor: 'green', } : request, ), ); message.success('완료 처리 mock 상태를 반영했습니다.'); }; const handleReplyToggle = (requestId: string) => { setReplyReferenceRequestId((current) => (current === requestId ? null : requestId)); }; const handlePromptSelectionChange = (key: string, selection: PromptDraftSelection | null) => { setPromptDraftSelections((current) => ({ ...current, [key]: selection, })); }; const handlePromptSubmitted = (key: string, selection: PromptDraftSelection) => { setPromptSubmittedSelections((current) => ({ ...current, [key]: selection, })); }; const handlePromptSubmit = async (key: string, payload: PromptSubmitPayload) => { setPromptSubmittedSelections((current) => ({ ...current, [key]: payload.selection, })); message.success(`${payload.promptTitle} mock 선택을 반영했습니다.`); return true; }; return (
채팅
{aggregateStatusTag.label} {headerSummaryLabel}
{hiddenPreviousCount > 0 ? (
) : null} {displayedRequests.map((request) => ( ))} {hiddenNextCount > 0 ? (
) : null}
방 전용 문맥 제목
방 전용 문맥 본문
공유 비밀번호 setEditingRoomAccessPin(event.target.value)} placeholder="숫자 4자리" /> 비밀번호 유지시간 } placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'} value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} />
{searchKeyword.trim() ? `검색 결과 ${filteredSearchResults.length}건` : searchPanelMode === 'apps' ? '시스템 채팅에서 허용할 앱 예시를 보여줍니다.' : '질문, 답변, 리소스, 활동 로그 mock 데이터를 함께 찾습니다.'}
{searchPanelMode === 'apps' ? (
실행 환경