307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
|
import { Alert, Button, Card, Empty, Form, Input, List, Modal, Space, Switch, Typography } from 'antd';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
|
import {
|
|
deleteAutomationContext,
|
|
type AutomationContextRecord,
|
|
upsertAutomationContext,
|
|
useAutomationContextRegistry,
|
|
} from './automationContextAccess';
|
|
import { confirmWithKeyboard } from './modalKeyboard';
|
|
import { useTokenAccess } from './tokenAccess';
|
|
import './AutomationContextManagementPage.css';
|
|
|
|
const { Text, Title } = Typography;
|
|
|
|
type AutomationContextFormValue = {
|
|
id?: string;
|
|
title: string;
|
|
content: string;
|
|
enabled: boolean;
|
|
defaultSelected: boolean;
|
|
};
|
|
|
|
const EMPTY_FORM_VALUE: AutomationContextFormValue = {
|
|
title: '',
|
|
content: '',
|
|
enabled: true,
|
|
defaultSelected: true,
|
|
};
|
|
|
|
function toFormValue(context: AutomationContextRecord | null): AutomationContextFormValue {
|
|
if (!context) {
|
|
return EMPTY_FORM_VALUE;
|
|
}
|
|
|
|
return {
|
|
id: context.id,
|
|
title: context.title,
|
|
content: context.content,
|
|
enabled: context.enabled,
|
|
defaultSelected: context.defaultSelected,
|
|
};
|
|
}
|
|
|
|
export function AutomationContextManagementPage() {
|
|
const { hasAccess } = useTokenAccess();
|
|
const { automationContexts, setAutomationContexts, isLoading, errorMessage } = useAutomationContextRegistry();
|
|
const [selectedAutomationContextId, setSelectedAutomationContextId] = useState<string | null>(automationContexts[0]?.id ?? null);
|
|
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
|
const [form] = Form.useForm<AutomationContextFormValue>();
|
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
|
const lastHydratedFormKeyRef = useRef('');
|
|
|
|
const selectedAutomationContext = useMemo(
|
|
() => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null,
|
|
[automationContexts, selectedAutomationContextId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (selectedAutomationContextId && automationContexts.some((item) => item.id === selectedAutomationContextId)) {
|
|
return;
|
|
}
|
|
|
|
setSelectedAutomationContextId(automationContexts[0]?.id ?? null);
|
|
}, [automationContexts, selectedAutomationContextId]);
|
|
|
|
useEffect(() => {
|
|
if (detailMode !== 'detail') {
|
|
lastHydratedFormKeyRef.current = '';
|
|
return;
|
|
}
|
|
|
|
const nextFormKey = isCreating ? '__create__' : selectedAutomationContext?.id ?? '__empty__';
|
|
|
|
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
|
return;
|
|
}
|
|
|
|
lastHydratedFormKeyRef.current = nextFormKey;
|
|
form.resetFields();
|
|
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
|
|
}, [detailMode, form, isCreating, selectedAutomationContext?.id]);
|
|
|
|
const openCreateForm = () => {
|
|
setIsCreating(true);
|
|
setSelectedAutomationContextId(null);
|
|
setDetailMode('detail');
|
|
form.resetFields();
|
|
form.setFieldsValue(EMPTY_FORM_VALUE);
|
|
};
|
|
|
|
const openDetail = (automationContextId: string) => {
|
|
setIsCreating(false);
|
|
setSelectedAutomationContextId(automationContextId);
|
|
setDetailMode('detail');
|
|
};
|
|
|
|
const closeDetail = () => {
|
|
setIsCreating(false);
|
|
setDetailMode('list');
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!selectedAutomationContext) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = await confirmWithKeyboard(modalApi, {
|
|
title: `"${selectedAutomationContext.title}" Context를 삭제할까요?`,
|
|
okText: '삭제',
|
|
cancelText: '취소',
|
|
okButtonProps: { danger: true },
|
|
});
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
const nextAutomationContexts = deleteAutomationContext(automationContexts, selectedAutomationContext.id);
|
|
setIsSaving(true);
|
|
setSaveErrorMessage('');
|
|
|
|
try {
|
|
const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts);
|
|
setSelectedAutomationContextId(savedAutomationContexts[0]?.id ?? null);
|
|
setIsCreating(false);
|
|
setDetailMode('list');
|
|
form.resetFields();
|
|
form.setFieldsValue(EMPTY_FORM_VALUE);
|
|
} catch (error) {
|
|
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 삭제에 실패했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!hasAccess) {
|
|
return (
|
|
<>
|
|
{modalContextHolder}
|
|
<Card title="Context 관리" className="chat-type-management-page">
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
|
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
|
|
/>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
|
|
{modalContextHolder}
|
|
{detailMode === 'list' ? (
|
|
<Card
|
|
title="Context 관리"
|
|
className="chat-type-management-page__card"
|
|
extra={
|
|
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
|
신규 Context
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="chat-type-management-page__list">
|
|
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
|
<div className="chat-type-management-page__list-header">
|
|
<Title level={5}>등록 Context</Title>
|
|
<Text type="secondary">{isLoading ? '불러오는 중' : `${automationContexts.length}건`}</Text>
|
|
</div>
|
|
{automationContexts.length > 0 ? (
|
|
<List
|
|
dataSource={automationContexts}
|
|
renderItem={(item) => (
|
|
<List.Item
|
|
className={
|
|
item.id === selectedAutomationContextId
|
|
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
|
: 'chat-type-management-page__item'
|
|
}
|
|
onClick={() => openDetail(item.id)}
|
|
actions={[
|
|
<Button
|
|
key="edit"
|
|
type="text"
|
|
icon={<EditOutlined />}
|
|
disabled={isSaving}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openDetail(item.id);
|
|
}}
|
|
/>,
|
|
]}
|
|
>
|
|
<div className="chat-type-management-page__item-main">
|
|
<Space size={[8, 8]} wrap>
|
|
<Text strong>{item.title}</Text>
|
|
<Text type="secondary">{item.id}</Text>
|
|
</Space>
|
|
<Space size={[8, 8]} wrap style={{ marginTop: 6 }}>
|
|
<Text type={item.enabled ? undefined : 'secondary'}>{item.enabled ? '사용' : '중지'}</Text>
|
|
<Text type={item.defaultSelected ? undefined : 'secondary'}>
|
|
{item.defaultSelected ? '기본 선택' : '기본 해제'}
|
|
</Text>
|
|
</Space>
|
|
<div className="chat-type-management-page__item-description">
|
|
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
|
</div>
|
|
</div>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Empty description="등록된 Context가 없습니다." />
|
|
)}
|
|
</div>
|
|
</Card>
|
|
) : (
|
|
<Card
|
|
title={isCreating ? 'Context 등록' : 'Context 상세'}
|
|
className="chat-type-management-page__card"
|
|
extra={
|
|
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
|
<Button
|
|
type="primary"
|
|
shape="circle"
|
|
icon={<SaveOutlined />}
|
|
loading={isSaving}
|
|
aria-label={isCreating ? '등록' : '수정 저장'}
|
|
onClick={() => {
|
|
void form.submit();
|
|
}}
|
|
/>
|
|
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
|
|
{!isCreating && selectedAutomationContext ? (
|
|
<Button
|
|
danger
|
|
shape="circle"
|
|
icon={<DeleteOutlined />}
|
|
loading={isSaving}
|
|
aria-label="삭제"
|
|
onClick={() => void handleDelete()}
|
|
/>
|
|
) : null}
|
|
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
|
</Space>
|
|
}
|
|
>
|
|
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
|
<Form
|
|
layout="vertical"
|
|
form={form}
|
|
initialValues={EMPTY_FORM_VALUE}
|
|
onFinish={async (values) => {
|
|
const nextAutomationContexts = upsertAutomationContext(automationContexts, values);
|
|
setIsSaving(true);
|
|
setSaveErrorMessage('');
|
|
|
|
try {
|
|
const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts);
|
|
const savedAutomationContext = savedAutomationContexts.find(
|
|
(item) => item.id === values.id || item.title === values.title,
|
|
);
|
|
setIsCreating(false);
|
|
setSelectedAutomationContextId(savedAutomationContext?.id ?? null);
|
|
setDetailMode('detail');
|
|
} catch (error) {
|
|
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 저장에 실패했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}}
|
|
>
|
|
<Form.Item name="id" hidden>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item label="제목" name="title" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
|
<Input placeholder="예: 기본 처리" />
|
|
</Form.Item>
|
|
<Space wrap>
|
|
<Form.Item label="사용" name="enabled" valuePropName="checked">
|
|
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
|
</Form.Item>
|
|
<Form.Item label="기본 선택" name="defaultSelected" valuePropName="checked">
|
|
<Switch checkedChildren="기본" unCheckedChildren="해제" />
|
|
</Form.Item>
|
|
</Space>
|
|
<Form.Item label="Context 본문" name="content">
|
|
<Input.TextArea
|
|
autoSize={{ minRows: 10, maxRows: 18 }}
|
|
placeholder={'## 처리 기준\n- 이 Context에서 적용할 규칙을 Markdown으로 정리하세요.'}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|