feat: refresh shared chat and server workflows
This commit is contained in:
915
src/app/main/SystemChatPage.tsx
Normal file
915
src/app/main/SystemChatPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user