543 lines
19 KiB
TypeScript
543 lines
19 KiB
TypeScript
import { ArrowsAltOutlined, DeleteOutlined, PlayCircleOutlined, PlusOutlined, SaveOutlined, ShrinkOutlined } from '@ant-design/icons';
|
|
import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message } from 'antd';
|
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
|
|
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
|
|
import { buildChatPath } from '../../../app/main/routes';
|
|
import { useTokenAccess } from '../../../app/main/tokenAccess';
|
|
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
|
|
import { listSavedLayouts, saveLayout, type SavedLayoutRecord } from '../../../views/play/layoutStorage';
|
|
import { isReusableLayoutConversation, resolveLayoutCodexRequestSocketUrl, resolveLayoutConversationTitle } from './featureMenu.chat';
|
|
import type { FeatureMenuTabKey, LayoutInteractionRule } from './featureMenu.types';
|
|
import { buildFeatureMenuPrompt, normalizeLayoutInteractions } from './featureMenu.utils';
|
|
import './FeatureMenuLayoutPage.css';
|
|
|
|
const { Paragraph, Text } = Typography;
|
|
|
|
type FeatureMenuLayoutPageProps = {
|
|
layoutId: string;
|
|
savedLayouts: SavedLayoutRecord[];
|
|
onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void;
|
|
};
|
|
|
|
export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsChange }: FeatureMenuLayoutPageProps) {
|
|
const navigate = useNavigate();
|
|
const { chatTypes } = useChatTypeRegistry();
|
|
const { hasAccess } = useTokenAccess();
|
|
const [messageApi, contextHolder] = message.useMessage();
|
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
|
const [selectedLayoutId, setSelectedLayoutId] = useState<string | null>(null);
|
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<FeatureMenuTabKey>('description');
|
|
const [isEditorMaximized, setIsEditorMaximized] = useState(false);
|
|
const [draftBody, setDraftBody] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(null);
|
|
|
|
const chatPermissionRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
|
const availableChatTypes = useMemo(
|
|
() => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)),
|
|
[chatPermissionRoles, chatTypes],
|
|
);
|
|
const selectedChatType = useMemo(
|
|
() => availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? availableChatTypes[0] ?? null,
|
|
[availableChatTypes, selectedChatTypeId],
|
|
);
|
|
|
|
const layoutOptions = useMemo<SelectOptionItem[]>(
|
|
() =>
|
|
[...savedLayouts]
|
|
.sort((left, right) => {
|
|
if (left.id === layoutId) {
|
|
return -1;
|
|
}
|
|
|
|
if (right.id === layoutId) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
})
|
|
.map((item) => ({
|
|
code: item.id,
|
|
value: item.name,
|
|
})),
|
|
[layoutId, savedLayouts],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (savedLayouts.length === 0) {
|
|
setSelectedLayoutId(null);
|
|
return;
|
|
}
|
|
|
|
if (selectedLayoutId && savedLayouts.some((item) => item.id === selectedLayoutId)) {
|
|
return;
|
|
}
|
|
|
|
const fallback = savedLayouts.find((item) => item.id === layoutId) ?? savedLayouts[0];
|
|
setSelectedLayoutId(fallback?.id ?? null);
|
|
}, [layoutId, savedLayouts, selectedLayoutId]);
|
|
|
|
useEffect(() => {
|
|
if (availableChatTypes.length === 0) {
|
|
setSelectedChatTypeId(null);
|
|
return;
|
|
}
|
|
|
|
if (selectedChatTypeId && availableChatTypes.some((item) => item.id === selectedChatTypeId)) {
|
|
return;
|
|
}
|
|
|
|
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
|
}, [availableChatTypes, selectedChatTypeId]);
|
|
|
|
const selectedLayout = selectedLayoutId ? savedLayouts.find((item) => item.id === selectedLayoutId) ?? null : null;
|
|
const selectedLayoutInteractions = useMemo<LayoutInteractionRule[]>(
|
|
() => normalizeLayoutInteractions(selectedLayout?.tree ?? null),
|
|
[selectedLayout?.tree],
|
|
);
|
|
|
|
const featureOptions = useMemo<SelectOptionItem[]>(
|
|
() =>
|
|
selectedLayoutInteractions.map((item, index) => ({
|
|
code: item.id,
|
|
value: item.title || `기능설명 ${index + 1}`,
|
|
})),
|
|
[selectedLayoutInteractions],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (featureOptions.length === 0) {
|
|
setSelectedFeatureId(null);
|
|
return;
|
|
}
|
|
|
|
if (selectedFeatureId && featureOptions.some((item) => item.code === selectedFeatureId)) {
|
|
return;
|
|
}
|
|
|
|
setSelectedFeatureId(featureOptions[0]?.code ?? null);
|
|
}, [featureOptions, selectedFeatureId]);
|
|
|
|
const selectedFeature =
|
|
selectedFeatureId ? selectedLayoutInteractions.find((item) => item.id === selectedFeatureId) ?? null : null;
|
|
|
|
useEffect(() => {
|
|
setDraftBody(selectedFeature?.description ?? '');
|
|
setActiveTab('description');
|
|
}, [selectedFeature?.description, selectedFeature?.id]);
|
|
|
|
const isDirty = Boolean(selectedFeature && selectedFeature.description !== draftBody);
|
|
|
|
const refreshLayouts = async () => {
|
|
const nextLayouts = await listSavedLayouts();
|
|
onSavedLayoutsChange?.(nextLayouts);
|
|
return nextLayouts;
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!selectedLayout || !selectedFeature) {
|
|
return;
|
|
}
|
|
|
|
const nextDescription = draftBody.trim();
|
|
|
|
if (!nextDescription) {
|
|
void messageApi.warning('기능설명 본문을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
const nextInteractions = selectedLayoutInteractions.map((item) =>
|
|
item.id === selectedFeature.id
|
|
? {
|
|
...item,
|
|
description: nextDescription,
|
|
}
|
|
: item,
|
|
);
|
|
const nextTree =
|
|
selectedLayout.tree && typeof selectedLayout.tree === 'object'
|
|
? {
|
|
...(selectedLayout.tree as Record<string, unknown>),
|
|
interactions: nextInteractions,
|
|
interactionMode: 'scoped-v2',
|
|
}
|
|
: {
|
|
root: null,
|
|
interactions: nextInteractions,
|
|
interactionMode: 'scoped-v2',
|
|
};
|
|
|
|
await saveLayout({
|
|
...selectedLayout,
|
|
updatedAt: new Date().toISOString(),
|
|
tree: nextTree,
|
|
});
|
|
|
|
await refreshLayouts();
|
|
void messageApi.success('기능설명을 저장했습니다.');
|
|
} catch (error) {
|
|
void messageApi.error(error instanceof Error ? error.message : '기능설명 저장에 실패했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
if (!selectedLayout) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = selectedLayoutInteractions.length + 1;
|
|
const nextFeatureId =
|
|
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
? crypto.randomUUID()
|
|
: `feature-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const nextFeature: LayoutInteractionRule = {
|
|
id: nextFeatureId,
|
|
title: `기능설명 ${nextIndex}`,
|
|
description: '',
|
|
implementationNotes: '',
|
|
};
|
|
const nextInteractions = [...selectedLayoutInteractions, nextFeature];
|
|
const nextTree =
|
|
selectedLayout.tree && typeof selectedLayout.tree === 'object'
|
|
? {
|
|
...(selectedLayout.tree as Record<string, unknown>),
|
|
interactions: nextInteractions,
|
|
interactionMode: 'scoped-v2',
|
|
}
|
|
: {
|
|
root: null,
|
|
interactions: nextInteractions,
|
|
interactionMode: 'scoped-v2',
|
|
};
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
await saveLayout({
|
|
...selectedLayout,
|
|
updatedAt: new Date().toISOString(),
|
|
tree: nextTree,
|
|
});
|
|
|
|
await refreshLayouts();
|
|
setSelectedFeatureId(nextFeatureId);
|
|
setDraftBody('');
|
|
setActiveTab('description');
|
|
void messageApi.success('기능설명 항목을 추가했습니다.');
|
|
} catch (error) {
|
|
void messageApi.error(error instanceof Error ? error.message : '기능설명 항목 추가에 실패했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (!selectedLayout || !selectedFeature) {
|
|
return;
|
|
}
|
|
|
|
void modalApi.confirm({
|
|
title: '선택한 기능설명을 삭제할까요?',
|
|
content: '삭제 후 되돌릴 수 없습니다.',
|
|
okText: '삭제',
|
|
cancelText: '취소',
|
|
okButtonProps: { danger: true },
|
|
async onOk() {
|
|
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
|
|
const nextTree =
|
|
selectedLayout.tree && typeof selectedLayout.tree === 'object'
|
|
? {
|
|
...(selectedLayout.tree as Record<string, unknown>),
|
|
interactions: nextInteractions,
|
|
interactionMode: 'scoped-v2',
|
|
}
|
|
: {
|
|
root: null,
|
|
interactions: nextInteractions,
|
|
interactionMode: 'scoped-v2',
|
|
};
|
|
|
|
await saveLayout({
|
|
...selectedLayout,
|
|
updatedAt: new Date().toISOString(),
|
|
tree: nextTree,
|
|
});
|
|
|
|
await refreshLayouts();
|
|
void messageApi.success('기능설명을 삭제했습니다.');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleCodexExecute = async () => {
|
|
if (!selectedLayout || !selectedChatType) {
|
|
if (!selectedChatType) {
|
|
void messageApi.error('사용 가능한 채팅유형이 없어 Codex 실행을 보낼 수 없습니다.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const rules = selectedFeature ? [selectedFeature] : selectedLayoutInteractions;
|
|
const prompt = buildFeatureMenuPrompt(selectedLayout, rules);
|
|
const conversationTitle = resolveLayoutConversationTitle(selectedLayout.name);
|
|
|
|
setIsSending(true);
|
|
|
|
try {
|
|
const conversations = await fetchChatConversations();
|
|
const matchedConversation = conversations.find((item) =>
|
|
isReusableLayoutConversation(item, conversationTitle, selectedChatType.id),
|
|
);
|
|
const shouldCreateConversation = !matchedConversation;
|
|
const targetSessionId =
|
|
matchedConversation?.sessionId ||
|
|
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
? crypto.randomUUID()
|
|
: `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`);
|
|
|
|
if (shouldCreateConversation) {
|
|
await createChatConversationRoom({
|
|
sessionId: targetSessionId,
|
|
title: conversationTitle,
|
|
chatTypeId: selectedChatType.id,
|
|
lastChatTypeId: selectedChatType.id,
|
|
contextLabel: selectedChatType.name,
|
|
contextDescription: selectedChatType.description,
|
|
notifyOffline: true,
|
|
});
|
|
}
|
|
|
|
const requestId = `client-${Date.now().toString(36)}`;
|
|
const socketUrl = resolveLayoutCodexRequestSocketUrl(targetSessionId);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const socket = new WebSocket(socketUrl);
|
|
const timeoutId = window.setTimeout(() => {
|
|
socket.close();
|
|
reject(new Error('Codex 요청 연결 시간이 초과되었습니다.'));
|
|
}, 8000);
|
|
|
|
socket.addEventListener('open', () => {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: 'message:send',
|
|
payload: {
|
|
text: prompt,
|
|
chatTypeId: selectedChatType.id,
|
|
chatTypeLabel: selectedChatType.name,
|
|
chatTypeDescription: selectedChatType.description,
|
|
requestId,
|
|
mode: 'queue',
|
|
},
|
|
}),
|
|
);
|
|
|
|
window.setTimeout(() => {
|
|
window.clearTimeout(timeoutId);
|
|
socket.close();
|
|
resolve();
|
|
}, 120);
|
|
});
|
|
|
|
socket.addEventListener('error', () => {
|
|
window.clearTimeout(timeoutId);
|
|
reject(new Error('Codex 요청 연결에 실패했습니다.'));
|
|
});
|
|
|
|
socket.addEventListener('close', (event) => {
|
|
if (!event.wasClean && event.code !== 1000) {
|
|
window.clearTimeout(timeoutId);
|
|
reject(new Error('Codex 요청 연결이 비정상 종료되었습니다.'));
|
|
}
|
|
});
|
|
});
|
|
|
|
void messageApi.success('Codex 실행 요청을 전송했습니다.');
|
|
navigate(`${buildChatPath('live')}?sessionId=${encodeURIComponent(targetSessionId)}`);
|
|
} catch (error) {
|
|
void messageApi.error(error instanceof Error ? error.message : 'Codex 실행 요청에 실패했습니다.');
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
const editorTabItems = [
|
|
{
|
|
key: 'description',
|
|
label: '기능설명 입력',
|
|
children: (
|
|
<div className="feature-menu-layout-page__editor-fields">
|
|
<Input.TextArea
|
|
value={draftBody}
|
|
placeholder="기능설명 본문을 입력하세요."
|
|
autoSize={false}
|
|
className="feature-menu-layout-page__textarea"
|
|
onChange={(event) => {
|
|
setDraftBody(event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'notes',
|
|
label: 'Codex 설명',
|
|
children: selectedFeature?.implementationNotes.trim() ? (
|
|
<div className="feature-menu-layout-page__notes">
|
|
<Paragraph className="feature-menu-layout-page__notes-body">{selectedFeature.implementationNotes}</Paragraph>
|
|
</div>
|
|
) : (
|
|
<div className="feature-menu-layout-page__notes feature-menu-layout-page__notes--empty">
|
|
<Empty
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
description={null}
|
|
className="feature-menu-layout-page__notes-empty"
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
] satisfies Array<{ key: FeatureMenuTabKey; label: string; children: ReactNode }>;
|
|
|
|
return (
|
|
<div
|
|
className={`feature-menu-layout-page${isEditorMaximized ? ' feature-menu-layout-page--editor-maximized' : ''}`}
|
|
data-layout-id={layoutId}
|
|
>
|
|
{contextHolder}
|
|
{modalContextHolder}
|
|
<section className="feature-menu-layout-page__filters">
|
|
<div className="feature-menu-layout-page__field">
|
|
<Text className="feature-menu-layout-page__field-label">레이아웃명</Text>
|
|
<SelectUI
|
|
data={layoutOptions}
|
|
value={selectedLayoutId ?? undefined}
|
|
allowClear={false}
|
|
className="feature-menu-layout-page__select"
|
|
onChange={(nextCode) => {
|
|
setSelectedLayoutId(nextCode ?? null);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="feature-menu-layout-page__field">
|
|
<Text className="feature-menu-layout-page__field-label">기능설명 선택</Text>
|
|
<SelectUI
|
|
data={featureOptions}
|
|
value={selectedFeatureId ?? undefined}
|
|
allowClear={false}
|
|
disabled={featureOptions.length === 0}
|
|
className="feature-menu-layout-page__select"
|
|
onChange={(nextCode) => {
|
|
setSelectedFeatureId(nextCode ?? null);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="feature-menu-layout-page__field">
|
|
<Text className="feature-menu-layout-page__field-label">Codex 채팅유형</Text>
|
|
<SelectUI
|
|
data={availableChatTypes.map((item) => ({
|
|
code: item.id,
|
|
value: item.name,
|
|
}))}
|
|
value={selectedChatType?.id ?? undefined}
|
|
allowClear={false}
|
|
disabled={availableChatTypes.length === 0}
|
|
className="feature-menu-layout-page__select"
|
|
onChange={(nextCode) => {
|
|
setSelectedChatTypeId(nextCode ?? null);
|
|
}}
|
|
/>
|
|
</div>
|
|
<Tooltip title="Codex 실행">
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
icon={<PlayCircleOutlined />}
|
|
className="feature-menu-layout-page__run-button"
|
|
disabled={!selectedLayout || !selectedChatType}
|
|
loading={isSending}
|
|
aria-label="Codex 실행"
|
|
onClick={() => {
|
|
void handleCodexExecute();
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</section>
|
|
|
|
<section className="feature-menu-layout-page__editor-shell">
|
|
{selectedFeature ? (
|
|
<>
|
|
<div className="feature-menu-layout-page__editor-toolbar">
|
|
<Space size={8} wrap className="feature-menu-layout-page__tab-actions">
|
|
<Tooltip title="추가">
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
disabled={!selectedLayout}
|
|
loading={isSaving && !selectedFeature}
|
|
aria-label="추가"
|
|
onClick={() => {
|
|
void handleAdd();
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={isEditorMaximized ? '입력 축소' : '입력 최대화'}>
|
|
<Button
|
|
type={isEditorMaximized ? 'primary' : 'default'}
|
|
icon={isEditorMaximized ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
|
aria-label={isEditorMaximized ? '입력 축소' : '입력 최대화'}
|
|
onClick={() => {
|
|
setIsEditorMaximized((current) => !current);
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title="저장">
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
disabled={!selectedFeature || !isDirty}
|
|
loading={isSaving}
|
|
aria-label="저장"
|
|
onClick={() => {
|
|
void handleSave();
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title="삭제">
|
|
<Button
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
disabled={!selectedFeature}
|
|
aria-label="삭제"
|
|
onClick={handleDelete}
|
|
/>
|
|
</Tooltip>
|
|
</Space>
|
|
</div>
|
|
<Tabs
|
|
activeKey={activeTab}
|
|
className="feature-menu-layout-page__tabs"
|
|
tabPosition="top"
|
|
items={editorTabItems}
|
|
onChange={(nextKey) => {
|
|
setActiveTab(nextKey as FeatureMenuTabKey);
|
|
}}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className="feature-menu-layout-page__empty">
|
|
<Empty description="선택 가능한 기능설명 항목이 없습니다." />
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|