feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

View File

@@ -0,0 +1,915 @@
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<ChatMessagePart, { type: 'link_card' }>[];
promptParts: Extract<ChatMessagePart, { type: 'prompt' }>[];
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<ChatMessagePart, { type: 'prompt' }>[]) {
const promptByKey = new Map<string, Extract<ChatMessagePart, { type: 'prompt' }>>();
parts.forEach((part) => {
const key = `${part.title}:${buildPromptTargetSignature(part)}`;
if (!promptByKey.has(key)) {
promptByKey.set(key, part);
}
});
return [...promptByKey.values()];
}
function dedupeLinkCardParts(parts: Extract<ChatMessagePart, { type: 'link_card' }>[]) {
const linkCardByKey = new Map<string, Extract<ChatMessagePart, { type: 'link_card' }>>();
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<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
...extracted.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
]);
const linkCardParts = dedupeLinkCardParts([
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
...extracted.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
]);
return {
visibleText,
linkCardParts,
promptParts,
previewItems: extractPreviewItems([message]),
};
}
function buildSystemPromptSelectionKey(messageId: number, promptIndex: number, target: Extract<ChatMessagePart, { type: 'prompt' }>) {
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<ChatPreviewTarget>(
() => ({
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 (
<section className="system-chat-page__preview-card app-chat-preview-card">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{item.label}</span>
<span className="app-chat-preview-card__kind">{item.kind} preview</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__open-link"
icon={<EyeOutlined />}
aria-label={isPreviewOpen ? `${item.label} 접기` : `${item.label} 미리보기`}
onClick={() => setIsPreviewOpen((current) => !current)}
/>
</div>
</div>
{isPreviewOpen ? (
<div className="app-chat-preview-card__body system-chat-page__preview-card-body">
<ChatPreviewBody
target={target}
previewText={previewText}
isPreviewLoading={isLoading}
previewError={previewError}
previewContentType={previewContentType || undefined}
renderHtmlAsFrame
maxMarkdownBlocks={5}
/>
</div>
) : null}
</section>
);
}
function SystemChatMessageArtifacts({
message,
promptDraftSelections,
promptSubmittedSelections,
onPromptSelectionChange,
onPromptSubmitted,
onPromptSubmit,
}: {
message: ChatMessage;
promptDraftSelections: Record<string, PromptDraftSelection | null>;
promptSubmittedSelections: Record<string, PromptDraftSelection | null>;
onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void;
onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void;
onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise<boolean>;
}) {
const { linkCardParts, promptParts, previewItems } = useMemo(
() => extractSystemMessageRenderPayload(message),
[message],
);
if (linkCardParts.length === 0 && promptParts.length === 0 && previewItems.length === 0) {
return null;
}
return (
<div className="system-chat-page__artifact-stack">
{linkCardParts.length > 0 ? (
<div className="system-chat-page__artifact-list">
{linkCardParts.map((target) => (
<ChatLinkCardPreview key={`${target.title}:${target.url}`} target={target} />
))}
</div>
) : null}
{promptParts.length > 0 ? (
<div className="system-chat-page__artifact-list">
{promptParts.map((target, promptIndex) => {
const selectionKey = buildSystemPromptSelectionKey(message.id, promptIndex, target);
return (
<ChatPromptCard
key={selectionKey}
target={target}
draftSelection={promptDraftSelections[selectionKey] ?? null}
submittedSelection={promptSubmittedSelections[selectionKey] ?? null}
onSelectionChange={(selection) => onPromptSelectionChange(selectionKey, selection)}
onSubmitted={(selection) => onPromptSubmitted(selectionKey, selection)}
onSubmit={(payload) => onPromptSubmit(selectionKey, payload)}
/>
);
})}
</div>
) : null}
{previewItems.length > 0 ? (
<div className="system-chat-page__artifact-list">
{previewItems.map((item) => (
<SystemChatPreviewCard key={item.id} item={item} />
))}
</div>
) : null}
</div>
);
}
function SystemChatRequestCard({
request,
isReplyActive,
promptDraftSelections,
promptSubmittedSelections,
onReplyToggle,
onComplete,
onPromptSelectionChange,
onPromptSubmitted,
onPromptSubmit,
}: {
request: MockRequest;
isReplyActive: boolean;
promptDraftSelections: Record<string, PromptDraftSelection | null>;
promptSubmittedSelections: Record<string, PromptDraftSelection | null>;
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<boolean>;
}) {
const isCompleted = request.status === 'completed';
const questionMessage = useMemo<ChatMessage>(
() => ({
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<ChatMessage>(
() => ({
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 (
<section id={`system-chat-request-${request.requestId}`} className="chat-share-page__request-block">
<span className="chat-share-page__message-time">{request.createdAt}</span>
<div className="chat-share-page__message-tone chat-share-page__message-tone--question">
<span className="chat-share-page__message-tone-label"></span>
<Paragraph className="chat-share-page__message-body system-chat-page__message-text" style={{ marginBottom: 0 }}>
{extractSystemMessageRenderPayload(questionMessage).visibleText}
</Paragraph>
</div>
<SystemChatMessageArtifacts
message={questionMessage}
promptDraftSelections={promptDraftSelections}
promptSubmittedSelections={promptSubmittedSelections}
onPromptSelectionChange={onPromptSelectionChange}
onPromptSubmitted={onPromptSubmitted}
onPromptSubmit={onPromptSubmit}
/>
<div className="chat-share-page__message-divider" aria-hidden="true" />
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
{!isCompleted ? (
<Button
type="text"
size="small"
className="chat-share-page__prompt-complete-button"
icon={<CheckOutlined />}
onClick={() => onComplete(request.requestId)}
>
</Button>
) : null}
<Button
type="text"
size="small"
className={`chat-share-page__prompt-complete-button chat-share-page__response-reply-button${isReplyActive ? ' chat-share-page__response-reply-button--active' : ''}`}
icon={<SendOutlined />}
onClick={() => onReplyToggle(request.requestId)}
>
{isReplyActive ? '답변 참조 중' : '답변하기'}
</Button>
</div>
<div className="chat-share-page__message-tone chat-share-page__message-tone--answer">
<span className="chat-share-page__message-tone-label"></span>
<Paragraph className="chat-share-page__message-body system-chat-page__message-text" style={{ marginBottom: 0 }}>
{extractSystemMessageRenderPayload(answerMessage).visibleText}
</Paragraph>
</div>
<SystemChatMessageArtifacts
message={answerMessage}
promptDraftSelections={promptDraftSelections}
promptSubmittedSelections={promptSubmittedSelections}
onPromptSelectionChange={onPromptSelectionChange}
onPromptSubmitted={onPromptSubmitted}
onPromptSubmit={onPromptSubmit}
/>
</section>
);
}
export function SystemChatPage() {
const { message } = App.useApp();
const [requests, setRequests] = useState<MockRequest[]>(INITIAL_REQUESTS);
const [draftText, setDraftText] = useState('');
const [replyReferenceRequestId, setReplyReferenceRequestId] = useState<string | null>('system-request-002');
const [promptDraftSelections, setPromptDraftSelections] = useState<Record<string, PromptDraftSelection | null>>({});
const [promptSubmittedSelections, setPromptSubmittedSelections] = useState<Record<string, PromptDraftSelection | null>>({});
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isTokenUsageOpen, setIsTokenUsageOpen] = useState(false);
const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchPanelMode, setSearchPanelMode] = useState<SearchPanelMode>('all');
const [expandMode, setExpandMode] = useState<ExpandMode>('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<HTMLInputElement | null>(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<MenuProps['items']>(
() => [
{ 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<HTMLInputElement>) => {
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 (
<div className="chat-share-page system-chat-page">
<div className="chat-share-page__shell">
<div className="chat-share-page__prompt-layout">
<section className="chat-share-page__panel chat-share-page__conversation-panel">
<div className="chat-share-page__section-head">
<div className="chat-share-page__section-copy">
<div className="chat-share-page__section-title-row">
<div className="system-chat-page__title-status">
<Title level={5}></Title>
<span className="system-chat-page__ws-indicator system-chat-page__ws-indicator--connected" aria-label="웹소켓 연결 정상" title="웹소켓 연결 정상" />
</div>
<Tag color={aggregateStatusTag.color}>{aggregateStatusTag.label}</Tag>
<Text type="secondary" className="chat-share-page__header-summary">{headerSummaryLabel}</Text>
</div>
</div>
<div className="chat-share-page__section-actions">
<div className="chat-share-page__request-nav" aria-label="요청 이동">
<Button type="text" size="small" className="chat-share-page__section-action" icon={<LeftOutlined />} disabled={!canMoveToPreviousRequest} onClick={() => handleMoveRequest(-1)}>
</Button>
<Button type="text" size="small" className="chat-share-page__section-action" icon={<RightOutlined />} iconPosition="end" disabled={!canMoveToNextRequest} onClick={() => handleMoveRequest(1)}>
</Button>
</div>
<Dropdown
trigger={['click']}
menu={{ items: shareHeaderSettingsItems, className: 'chat-share-page__settings-menu', onClick: handleHeaderMenuClick }}
placement="bottomRight"
>
<Button type="text" size="small" className="chat-share-page__section-action chat-share-page__section-action--tool system-chat-page__icon-tool-button" aria-label="채팅 설정" title="채팅 설정" icon={<SettingOutlined />} />
</Dropdown>
</div>
</div>
<div className="chat-share-page__message-list">
{hiddenPreviousCount > 0 ? (
<div className="system-chat-page__omitted-divider" aria-label={'이전 요청 ' + String(hiddenPreviousCount) + '건 생략'}>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
<span className="system-chat-page__omitted-divider-text"> {hiddenPreviousCount} </span>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
</div>
) : null}
{displayedRequests.map((request) => (
<SystemChatRequestCard
key={request.requestId}
request={request}
isReplyActive={replyReferenceRequestId === request.requestId}
promptDraftSelections={promptDraftSelections}
promptSubmittedSelections={promptSubmittedSelections}
onReplyToggle={handleReplyToggle}
onComplete={handleCompleteRequest}
onPromptSelectionChange={handlePromptSelectionChange}
onPromptSubmitted={handlePromptSubmitted}
onPromptSubmit={handlePromptSubmit}
/>
))}
{hiddenNextCount > 0 ? (
<div className="system-chat-page__omitted-divider" aria-label={'다음 요청 ' + String(hiddenNextCount) + '건 생략'}>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
<span className="system-chat-page__omitted-divider-text"> {hiddenNextCount} </span>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
</div>
) : null}
</div>
</section>
<section className="chat-share-page__panel chat-share-page__composer-panel">
<div className="chat-share-page__composer-shell app-chat-panel__composer">
<input ref={attachInputRef} className="chat-share-page__composer-file-input app-chat-panel__composer-file-input" type="file" multiple accept="image/*,.pdf,.txt,.md,.csv,.json,.zip,.heic,.heif" onChange={handleAttachSelection} style={{ display: 'none' }} />
<div className="chat-share-page__composer-topline">
<div className="app-chat-panel__composer-utility-buttons">
<Button className="system-chat-page__composer-icon-button system-chat-page__composer-attach-button" icon={<PlusOutlined />} aria-label="파일" title="파일" onClick={handleOpenAttachPicker} />
</div>
<div className="app-chat-panel__composer-type chat-share-page__composer-type-readonly">
<Select value="시스템 채팅" aria-label="현재 채팅유형" options={[{ value: '시스템 채팅', label: '시스템 채팅' }]} disabled />
</div>
<div className="app-chat-panel__composer-actions chat-share-page__composer-topline-actions">
<div className="app-chat-panel__composer-action-buttons system-chat-page__composer-action-buttons">
<Button className="system-chat-page__composer-icon-button system-chat-page__composer-icon-button--instant" icon={<ThunderboltOutlined />} aria-label="즉시전송" title="즉시전송" onClick={handleSend} />
<Button type="primary" className="system-chat-page__composer-icon-button system-chat-page__composer-icon-button--send" icon={<SendOutlined />} aria-label="답변 전송" title="답변 전송" onClick={handleSend} />
</div>
</div>
</div>
{replyReferenceRequestId ? (
<div className="chat-share-page__reply-reference system-chat-page__reply-reference">
<div className="chat-share-page__reply-reference-copy">
<span className="chat-share-page__reply-reference-label"> </span>
<span className="chat-share-page__reply-reference-text">{requests.find((request) => request.requestId === replyReferenceRequestId)?.question ?? '선택된 요청'}</span>
</div>
<Button type="text" size="small" className="chat-share-page__reply-reference-clear" onClick={() => setReplyReferenceRequestId(null)}></Button>
</div>
) : null}
<div className="chat-share-page__composer-entry-row">
<div className="app-chat-panel__composer-input-shell chat-share-page__composer-input-shell">
<Input.TextArea value={draftText} onChange={(event) => setDraftText(event.target.value)} placeholder="시스템 채팅에 보낼 내용을 입력하세요. 현재는 UI 형태만 제공됩니다." rows={6} maxLength={20000} autoSize={{ minRows: 6, maxRows: 10 }} />
</div>
</div>
</div>
</section>
</div>
</div>
<Modal open={isTokenUsageOpen} footer={null} title="토큰 관리" className="chat-share-page__token-usage-modal" onCancel={() => setIsTokenUsageOpen(false)}>
<div className="chat-share-page__token-usage-modal-body">
<div className="chat-share-page__token-usage-select-row">
<Text type="secondary"> </Text>
<Text strong> UI mock-admin</Text>
</div>
<div className="chat-share-page__token-usage-overview-card" aria-label="토큰 집계 요약">
<div className="chat-share-page__token-usage-overview-head">
<div>
<div className="chat-share-page__token-usage-overview-label"> </div>
<div className="chat-share-page__token-usage-overview-value">100,000</div>
</div>
<Tag color="gold">5 12%</Tag>
</div>
<div className="chat-share-page__token-usage-meter-card">
<div className="chat-share-page__token-usage-meter-track chat-share-page__token-usage-meter-track--merged" aria-hidden="true">
<span className="chat-share-page__token-usage-meter-fill chat-share-page__token-usage-meter-fill--overall" style={{ width: '12%' }} />
</div>
<div className="chat-share-page__token-usage-meter-legend">
<div className="chat-share-page__token-usage-meter-row">
<span className="chat-share-page__token-usage-meter-dot chat-share-page__token-usage-meter-fill--overall" aria-hidden="true" />
<span className="chat-share-page__token-usage-meter-label"> </span>
<span className="chat-share-page__token-usage-meter-value">12,000 / 100,000</span>
</div>
</div>
</div>
<div className="chat-share-page__token-usage-summary-copy"> UI mock .</div>
</div>
<div className="chat-share-page__token-usage-select-row">
<Text type="secondary"> </Text>
<div className="chat-share-page__token-usage-share-url-row">
<Paragraph className="chat-share-page__token-usage-share-url" style={{ maxWidth: '100%', marginBottom: 0 }}>
https://preview.sm-home.cloud/chat/system
</Paragraph>
<Button type="text" size="small" className="chat-share-page__token-usage-copy-button" icon={<CopyOutlined />} onClick={() => message.success('예시 URL을 복사했습니다.')} />
</div>
<div className="chat-share-page__token-usage-token-meta">
<Text type="secondary">UI mock · </Text>
</div>
</div>
</div>
</Modal>
<Modal
open={isRoomSettingsOpen}
title="시스템 채팅 설정"
okText="저장"
cancelText="취소"
onCancel={() => setIsRoomSettingsOpen(false)}
onOk={() => {
message.success('시스템 채팅 UI 설정을 저장한 것처럼 표시합니다. 현재는 mock 상태입니다.');
setIsRoomSettingsOpen(false);
}}
>
<div className="chat-share-page__token-usage-modal">
<Alert showIcon type="info" message="현재 화면은 서버 연결 없는 UI 전용 시스템 채팅입니다." />
<div className="chat-share-page__token-usage-panel">
<Text strong></Text>
<Select value={editingRoomChatTypeId} onChange={setEditingRoomChatTypeId} options={[{ value: 'system-chat', label: '시스템 채팅' }]} />
</div>
<div className="chat-share-page__token-usage-panel">
<Text strong> </Text>
<Input value="시스템 채팅 기본 규칙" readOnly />
</div>
<div className="chat-share-page__token-usage-panel">
<Text strong> </Text>
<Input.TextArea rows={8} value="이 화면은 공유채팅과 동일한 구조의 UI mock이며, 향후 시스템 채팅 전용 기능을 여기에만 추가합니다." readOnly />
</div>
<div className="chat-share-page__token-usage-panel">
<Text strong> </Text>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input type="checkbox" checked={editingRoomUseAccessPin} onChange={(event) => setEditingRoomUseAccessPin(event.target.checked)} />
<span> 4 </span>
</label>
<Input.Password value={editingRoomAccessPin} maxLength={4} onChange={(event) => setEditingRoomAccessPin(event.target.value)} placeholder="숫자 4자리" />
<Text strong style={{ marginTop: 12 }}> </Text>
<Select value={editingRoomAccessPinPromptTtl} onChange={setEditingRoomAccessPinPromptTtl} options={[{ value: 'always', label: '매번 묻기' }, { value: '30', label: '30분 유지' }, { value: '60', label: '1시간 유지' }]} />
</div>
</div>
</Modal>
<Modal open={isSearchOpen} footer={null} title={searchPanelMode === 'apps' ? '시스템 채팅 Apps' : '시스템 채팅 통합검색'} className="chat-share-page__search-modal" onCancel={() => setIsSearchOpen(false)}>
<div className="chat-share-page__search-modal-body">
<Input autoFocus allowClear size="large" prefix={<SearchOutlined />} placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'} value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} />
<div className="chat-share-page__search-summary">
<Text type="secondary">{searchKeyword.trim() ? `검색 결과 ${filteredSearchResults.length}` : searchPanelMode === 'apps' ? '시스템 채팅에서 허용할 앱 예시를 보여줍니다.' : '질문, 답변, 리소스, 활동 로그 mock 데이터를 함께 찾습니다.'}</Text>
</div>
{searchPanelMode === 'apps' ? (
<div className="chat-share-page__search-app-environment">
<Text type="secondary"> </Text>
<Select value={selectedAppEnvironment} options={[{ value: 'preview', label: 'preview' }, { value: 'test', label: 'test' }, { value: 'prod', label: 'prod' }]} onChange={setSelectedAppEnvironment} />
</div>
) : null}
<div className="chat-share-page__search-results">
{filteredSearchResults.length > 0 ? (
filteredSearchResults.map((result) => (
<div key={result.key} className="chat-share-page__search-result">
<button type="button" className="chat-share-page__search-result-main" onClick={() => message.info(`${result.title} 항목은 UI mock 데이터입니다.`)}>
<span className="chat-share-page__search-result-title">{result.title}</span>
<span className="chat-share-page__search-result-description">{result.description}</span>
</button>
{searchPanelMode === 'apps' ? (
<div className="chat-share-page__search-result-action-group">
<Tag bordered={false} className="chat-share-page__search-result-tag"> {selectedAppEnvironment}</Tag>
<Button type="text" size="small" className="chat-share-page__search-result-action chat-share-page__search-result-action--environment" icon={<AppstoreOutlined />} onClick={() => message.info('앱 실행은 연결되지 않았습니다.')}> </Button>
</div>
) : null}
</div>
))
) : (
<div className="chat-share-page__search-empty">
<Text type="secondary"> .</Text>
</div>
)}
</div>
</div>
</Modal>
</div>
);
}