chore: sync local workspace changes
This commit is contained in:
520
src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx
Normal file
520
src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
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 {
|
||||
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
|
||||
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
|
||||
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
|
||||
} from '../../../app/main/chatTypeDefaults';
|
||||
import { buildChatPath } from '../../../app/main/routes';
|
||||
import { useTokenAccess } from '../../../app/main/tokenAccess';
|
||||
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
|
||||
import { resolvePreferredLayoutCodexChatType } from '../../../views/play/layoutCodexChatType';
|
||||
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 chatPermissionRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
const availableChatTypes = useMemo(
|
||||
() => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)),
|
||||
[chatPermissionRoles, chatTypes],
|
||||
);
|
||||
const preferredCodexChatType = useMemo(
|
||||
() => resolvePreferredLayoutCodexChatType(availableChatTypes),
|
||||
[availableChatTypes],
|
||||
);
|
||||
const targetChatType = preferredCodexChatType ?? {
|
||||
id: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
|
||||
name: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
|
||||
description: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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) {
|
||||
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, targetChatType.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: targetChatType.id,
|
||||
lastChatTypeId: targetChatType.id,
|
||||
contextLabel: targetChatType.name,
|
||||
contextDescription: targetChatType.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: targetChatType.id,
|
||||
chatTypeLabel: targetChatType.name,
|
||||
chatTypeDescription: targetChatType.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>
|
||||
<Tooltip title="Codex 실행">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<PlayCircleOutlined />}
|
||||
className="feature-menu-layout-page__run-button"
|
||||
disabled={!selectedLayout}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user