495 lines
23 KiB
TypeScript
495 lines
23 KiB
TypeScript
import { CheckCircleOutlined, CopyOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons';
|
|
import { Alert, App, Button, Card, Checkbox, Empty, Input, Result, Space, Steps, Tag, Typography } from 'antd';
|
|
import { useMemo, useState } from 'react';
|
|
import { useChatTypeRegistry } from './chatTypeAccess';
|
|
import { useTokenSettingRegistry, type TokenSettingRecord } from './tokenSettingAccess';
|
|
import { useTokenAccess } from './tokenAccess';
|
|
import { createManagedChatShareRoom, type ManagedChatShareRoom } from './mainChatPanel';
|
|
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
|
|
import { resolveChatPathForSession } from './chatSessionRouting';
|
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
|
import './SharedChatManagementPage.css';
|
|
|
|
const { Paragraph, Text, Title } = Typography;
|
|
|
|
type SharedChatDraft = {
|
|
tokenSettingId: string;
|
|
chatTypeId: string;
|
|
name: string;
|
|
requestBadgeLabel: string;
|
|
seedMessage: string;
|
|
allowManageAccess: boolean;
|
|
};
|
|
|
|
const DEFAULT_DRAFT: SharedChatDraft = {
|
|
tokenSettingId: '',
|
|
chatTypeId: '',
|
|
name: '',
|
|
requestBadgeLabel: '',
|
|
seedMessage: '이 공유채팅방에서 원하는 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.',
|
|
allowManageAccess: false,
|
|
};
|
|
|
|
function hasAllowedApp(setting: Pick<TokenSettingRecord, 'allowedAppIds'>, appId: string) {
|
|
return setting.allowedAppIds.some((item) => item.trim().toLowerCase() === appId);
|
|
}
|
|
|
|
function resolveAbsoluteUrl(pathname: string) {
|
|
if (typeof window === 'undefined') {
|
|
return pathname;
|
|
}
|
|
|
|
try {
|
|
return new URL(pathname, window.location.origin).toString();
|
|
} catch {
|
|
return pathname;
|
|
}
|
|
}
|
|
|
|
export function SharedChatManagementPage() {
|
|
const { message } = App.useApp();
|
|
const { hasAccess } = useTokenAccess();
|
|
const { tokenSettings, isLoading: isTokenSettingsLoading, errorMessage: tokenSettingsErrorMessage } = useTokenSettingRegistry();
|
|
const { chatTypes, isLoading: isChatTypesLoading, errorMessage: chatTypesErrorMessage } = useChatTypeRegistry();
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [draft, setDraft] = useState<SharedChatDraft>(DEFAULT_DRAFT);
|
|
const [createErrorMessage, setCreateErrorMessage] = useState('');
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [createdRoom, setCreatedRoom] = useState<(ManagedChatShareRoom & { shareUrl: string; conversationUrl: string }) | null>(null);
|
|
|
|
const availableTokenSettings = useMemo(
|
|
() => tokenSettings.filter((item) => item.enabled && hasAllowedApp(item, 'chat-live')),
|
|
[tokenSettings],
|
|
);
|
|
const availableChatTypes = useMemo(() => chatTypes.filter((item) => item.enabled), [chatTypes]);
|
|
const selectedTokenSetting = useMemo(
|
|
() => availableTokenSettings.find((item) => item.id === draft.tokenSettingId) ?? null,
|
|
[availableTokenSettings, draft.tokenSettingId],
|
|
);
|
|
const selectedChatType = useMemo(
|
|
() => availableChatTypes.find((item) => item.id === draft.chatTypeId) ?? null,
|
|
[availableChatTypes, draft.chatTypeId],
|
|
);
|
|
const canGrantManageAccess = useMemo(
|
|
() =>
|
|
Boolean(
|
|
selectedTokenSetting &&
|
|
(hasAllowedApp(selectedTokenSetting, 'chat-room-settings') ||
|
|
hasAllowedApp(selectedTokenSetting, 'token-setting') ||
|
|
hasAllowedApp(selectedTokenSetting, 'shared-resource') ||
|
|
hasAllowedApp(selectedTokenSetting, 'server-command')),
|
|
),
|
|
[selectedTokenSetting],
|
|
);
|
|
const suggestedShareName = useMemo(
|
|
() => (selectedChatType ? `${selectedChatType.name} 공유채팅` : ''),
|
|
[selectedChatType],
|
|
);
|
|
|
|
const stepItems = [
|
|
{ title: '공유 토큰' },
|
|
{ title: '채팅 유형' },
|
|
{ title: '채팅방 정보' },
|
|
{ title: 'URL 공유' },
|
|
];
|
|
const openManagedShareWindow = (url: string) => {
|
|
openExternalLinkInNewWindow(url, {
|
|
onUnsupportedStandalone: (fallbackUrl) => {
|
|
void copyTextToClipboard(fallbackUrl)
|
|
.then(() => {
|
|
message.info('현재 모바일 PWA에서는 preview 앱 열기도 막혀 URL을 복사했습니다. 브라우저에서 붙여 열어 주세요.');
|
|
})
|
|
.catch(() => {
|
|
message.info('현재 모바일 PWA에서는 새 창과 preview 앱 열기가 막힐 수 있습니다. URL 복사 후 브라우저에서 열어 주세요.');
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleMoveNext = () => {
|
|
if (currentStep === 0 && !selectedTokenSetting) {
|
|
message.warning('공유 토큰 설정을 먼저 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
if (currentStep === 1 && !selectedChatType) {
|
|
message.warning('채팅 유형을 먼저 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
if (currentStep === 2) {
|
|
if (!draft.name.trim()) {
|
|
message.warning('공유 이름을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
if (!draft.seedMessage.trim()) {
|
|
message.warning('공유 시작 문구를 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
setCurrentStep((previous) => Math.min(previous + 1, stepItems.length - 1));
|
|
};
|
|
|
|
const handleCreateRoom = async () => {
|
|
if (!selectedTokenSetting || !selectedChatType) {
|
|
message.warning('공유 토큰과 채팅 유형을 먼저 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
const name = draft.name.trim();
|
|
const seedMessage = draft.seedMessage.trim();
|
|
|
|
if (!name || !seedMessage) {
|
|
message.warning('공유 이름과 공유 시작 문구를 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
setIsCreating(true);
|
|
setCreateErrorMessage('');
|
|
|
|
try {
|
|
const created = await createManagedChatShareRoom({
|
|
tokenSettingId: selectedTokenSetting.id,
|
|
chatTypeId: selectedChatType.id,
|
|
chatTypeLabel: selectedChatType.name,
|
|
name,
|
|
requestBadgeLabel: draft.requestBadgeLabel.trim() || null,
|
|
seedMessage,
|
|
allowManageAccess: draft.allowManageAccess && canGrantManageAccess,
|
|
});
|
|
const shareUrl = resolveAbsoluteUrl(created.sharePath);
|
|
const conversationUrl = resolveAbsoluteUrl(`${resolveChatPathForSession(created.sessionId)}?sessionId=${encodeURIComponent(created.sessionId)}`);
|
|
setCreatedRoom({
|
|
...created,
|
|
shareUrl,
|
|
conversationUrl,
|
|
});
|
|
setCurrentStep(3);
|
|
message.success('공유채팅방을 생성했습니다.');
|
|
} catch (error) {
|
|
setCreateErrorMessage(error instanceof Error ? error.message : '공유채팅방 생성에 실패했습니다.');
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setDraft(DEFAULT_DRAFT);
|
|
setCurrentStep(0);
|
|
setCreateErrorMessage('');
|
|
setCreatedRoom(null);
|
|
};
|
|
|
|
if (!hasAccess) {
|
|
return (
|
|
<Card title="공유채팅 생성" className="shared-chat-management-page">
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
|
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공유채팅을 생성하세요."
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="shared-chat-management-page">
|
|
<Card
|
|
title="공유채팅 생성"
|
|
className="shared-chat-management-page__card"
|
|
extra={
|
|
createdRoom ? (
|
|
<Button icon={<PlusOutlined />} onClick={handleReset}>
|
|
새로 만들기
|
|
</Button>
|
|
) : null
|
|
}
|
|
>
|
|
<div className="shared-chat-management-page__layout">
|
|
<div className="shared-chat-management-page__steps">
|
|
<Steps current={currentStep} items={stepItems} responsive={false} />
|
|
</div>
|
|
<div className="shared-chat-management-page__content">
|
|
<div className="shared-chat-management-page__stage">
|
|
{tokenSettingsErrorMessage ? <Alert showIcon type="error" message={tokenSettingsErrorMessage} /> : null}
|
|
{chatTypesErrorMessage ? <Alert showIcon type="error" message={chatTypesErrorMessage} /> : null}
|
|
{createErrorMessage ? <Alert showIcon type="error" message={createErrorMessage} /> : null}
|
|
|
|
<div className="shared-chat-management-page__stage-body">
|
|
{currentStep === 0 ? (
|
|
<Card bordered={false} className="shared-chat-management-page__panel">
|
|
<Title level={5}>1. 공유 토큰 설정 선택</Title>
|
|
<Paragraph type="secondary">
|
|
`chat-live` 권한이 있는 토큰 설정만 표시합니다. 이 설정이 공유 URL의 만료 시간과 사용량 한도를 결정합니다.
|
|
</Paragraph>
|
|
{availableTokenSettings.length > 0 ? (
|
|
<div className="shared-chat-management-page__option-list">
|
|
{availableTokenSettings.map((item) => {
|
|
const active = item.id === draft.tokenSettingId;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={`shared-chat-management-page__option-card${active ? ' shared-chat-management-page__option-card--active' : ''}`}
|
|
onClick={() => {
|
|
setDraft((previous) => ({
|
|
...previous,
|
|
tokenSettingId: item.id,
|
|
allowManageAccess:
|
|
previous.allowManageAccess &&
|
|
(hasAllowedApp(item, 'chat-room-settings') ||
|
|
hasAllowedApp(item, 'token-setting') ||
|
|
hasAllowedApp(item, 'shared-resource') ||
|
|
hasAllowedApp(item, 'server-command')),
|
|
}));
|
|
}}
|
|
>
|
|
<div className="shared-chat-management-page__option-head">
|
|
<Space size={[8, 8]} wrap>
|
|
<Text strong>{item.name}</Text>
|
|
<Tag>{item.id}</Tag>
|
|
<Tag color="blue">{`기본 ${item.defaultExpiresInMinutes > 0 ? `${item.defaultExpiresInMinutes}분` : '무제한'}`}</Tag>
|
|
</Space>
|
|
</div>
|
|
<Text type="secondary">{item.description || '설명 없음'}</Text>
|
|
<div className="shared-chat-management-page__tag-row">
|
|
{item.allowedAppIds.map((appId) => (
|
|
<Tag key={`${item.id}-${appId}`}>{appId}</Tag>
|
|
))}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<Empty
|
|
description={
|
|
isTokenSettingsLoading ? '공유 토큰 설정을 불러오는 중입니다.' : '공유채팅에 사용할 토큰 설정이 없습니다.'
|
|
}
|
|
/>
|
|
)}
|
|
</Card>
|
|
) : null}
|
|
|
|
{currentStep === 1 ? (
|
|
<Card bordered={false} className="shared-chat-management-page__panel">
|
|
<Title level={5}>2. 채팅 유형 선택</Title>
|
|
<Paragraph type="secondary">
|
|
공유 URL에 들어온 사용자는 이 채팅 유형 기준으로 대화를 이어갑니다.
|
|
</Paragraph>
|
|
{availableChatTypes.length > 0 ? (
|
|
<div className="shared-chat-management-page__option-list">
|
|
{availableChatTypes.map((item) => {
|
|
const active = item.id === draft.chatTypeId;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={`shared-chat-management-page__option-card${active ? ' shared-chat-management-page__option-card--active' : ''}`}
|
|
onClick={() => {
|
|
setDraft((previous) => ({
|
|
...previous,
|
|
chatTypeId: item.id,
|
|
}));
|
|
}}
|
|
>
|
|
<div className="shared-chat-management-page__option-head">
|
|
<Space size={[8, 8]} wrap>
|
|
<Text strong>{item.name}</Text>
|
|
<Tag>{item.id}</Tag>
|
|
</Space>
|
|
</div>
|
|
<Text type="secondary">{item.description || '설명 없음'}</Text>
|
|
<div className="shared-chat-management-page__tag-row">
|
|
{item.permissions.map((permission) => (
|
|
<Tag key={`${item.id}-${permission}`}>{permission}</Tag>
|
|
))}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<Empty description={isChatTypesLoading ? '채팅 유형을 불러오는 중입니다.' : '사용 가능한 채팅 유형이 없습니다.'} />
|
|
)}
|
|
</Card>
|
|
) : null}
|
|
|
|
{currentStep === 2 ? (
|
|
<Card bordered={false} className="shared-chat-management-page__panel">
|
|
<Title level={5}>3. 채팅방 정보 입력</Title>
|
|
<div className="shared-chat-management-page__field-grid">
|
|
<label className="shared-chat-management-page__field">
|
|
<span>공유 이름</span>
|
|
<Input
|
|
value={draft.name}
|
|
maxLength={160}
|
|
placeholder={suggestedShareName || '예: 공유 문구 검토방'}
|
|
onChange={(event) => setDraft((previous) => ({ ...previous, name: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="shared-chat-management-page__field">
|
|
<span>채팅 유형</span>
|
|
<Input value={selectedChatType?.name ?? ''} readOnly />
|
|
</label>
|
|
<label className="shared-chat-management-page__field">
|
|
<span>요청 뱃지</span>
|
|
<Input
|
|
value={draft.requestBadgeLabel}
|
|
maxLength={120}
|
|
placeholder="선택 입력"
|
|
onChange={(event) => setDraft((previous) => ({ ...previous, requestBadgeLabel: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
|
|
<span>공유 시작 문구</span>
|
|
<Input.TextArea
|
|
rows={6}
|
|
value={draft.seedMessage}
|
|
placeholder="공유채팅방에 처음 보일 안내나 첫 질문을 입력하세요."
|
|
onChange={(event) => setDraft((previous) => ({ ...previous, seedMessage: event.target.value }))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="shared-chat-management-page__checkbox-row">
|
|
<Checkbox
|
|
checked={draft.allowManageAccess && canGrantManageAccess}
|
|
disabled={!canGrantManageAccess}
|
|
onChange={(event) => setDraft((previous) => ({ ...previous, allowManageAccess: event.target.checked }))}
|
|
>
|
|
공유 참여자에게 관리 권한 허용
|
|
</Checkbox>
|
|
<Text type="secondary">
|
|
{canGrantManageAccess
|
|
? '선택 시 채팅방 설정 또는 연결된 관리 화면 접근이 열립니다.'
|
|
: '선택한 토큰 설정에는 노출 가능한 관리 앱 권한이 없습니다.'}
|
|
</Text>
|
|
</div>
|
|
<div className="shared-chat-management-page__summary">
|
|
<div>
|
|
<Text type="secondary">공유 토큰</Text>
|
|
<div className="shared-chat-management-page__summary-value">{selectedTokenSetting?.name ?? '-'}</div>
|
|
</div>
|
|
<div>
|
|
<Text type="secondary">생성 이름</Text>
|
|
<div className="shared-chat-management-page__summary-value">{draft.name.trim() || '-'}</div>
|
|
</div>
|
|
<div>
|
|
<Text type="secondary">유효 시간</Text>
|
|
<div className="shared-chat-management-page__summary-value">
|
|
{selectedTokenSetting
|
|
? selectedTokenSetting.defaultExpiresInMinutes > 0
|
|
? `${selectedTokenSetting.defaultExpiresInMinutes}분`
|
|
: '무제한'
|
|
: '-'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Text type="secondary">접근 보호</Text>
|
|
<div className="shared-chat-management-page__summary-value">
|
|
공유받은 사용자가 필요할 때 채팅방 설정에서 직접 설정
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
) : null}
|
|
|
|
{currentStep === 3 ? (
|
|
<Card bordered={false} className="shared-chat-management-page__panel">
|
|
{createdRoom ? (
|
|
<Result
|
|
status="success"
|
|
icon={<CheckCircleOutlined />}
|
|
title="공유채팅방 URL이 준비되었습니다."
|
|
subTitle="이 URL로 외부 사용자가 같은 채팅방 흐름에 이어서 질문할 수 있습니다."
|
|
extra={
|
|
<Space wrap>
|
|
<Button
|
|
type="primary"
|
|
icon={<CopyOutlined />}
|
|
onClick={async () => {
|
|
await copyTextToClipboard(createdRoom.shareUrl);
|
|
message.success('공유 URL을 복사했습니다.');
|
|
}}
|
|
>
|
|
URL 복사
|
|
</Button>
|
|
<Button
|
|
icon={<LinkOutlined />}
|
|
onClick={() => {
|
|
openManagedShareWindow(createdRoom.shareUrl);
|
|
}}
|
|
>
|
|
공유 화면 열기
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
openManagedShareWindow(createdRoom.conversationUrl);
|
|
}}
|
|
>
|
|
내부 채팅방 열기
|
|
</Button>
|
|
</Space>
|
|
}
|
|
/>
|
|
) : (
|
|
<Alert showIcon type="info" message="생성된 공유채팅방이 없습니다. 이전 단계에서 생성을 완료하세요." />
|
|
)}
|
|
{createdRoom ? (
|
|
<div className="shared-chat-management-page__result-grid">
|
|
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
|
|
<span>공유 이름</span>
|
|
<Input value={createdRoom.name} readOnly />
|
|
</label>
|
|
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
|
|
<span>공유 URL</span>
|
|
<Input value={createdRoom.shareUrl} readOnly />
|
|
</label>
|
|
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
|
|
<span>내부 채팅방</span>
|
|
<Input value={createdRoom.conversationUrl} readOnly />
|
|
</label>
|
|
<div className="shared-chat-management-page__field shared-chat-management-page__field--full">
|
|
<span>적용 권한</span>
|
|
<div className="shared-chat-management-page__tag-row">
|
|
{createdRoom.permissions.map((permission) => (
|
|
<Tag key={permission}>{permission}</Tag>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<label className="shared-chat-management-page__field">
|
|
<span>접근 보호</span>
|
|
<Input value="공유 후 채팅방 설정에서 변경" readOnly />
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shared-chat-management-page__actions">
|
|
<Button onClick={() => setCurrentStep((previous) => Math.max(previous - 1, 0))} disabled={currentStep === 0 || isCreating}>
|
|
이전
|
|
</Button>
|
|
{currentStep < 2 ? (
|
|
<Button type="primary" onClick={handleMoveNext}>
|
|
다음
|
|
</Button>
|
|
) : currentStep === 2 ? (
|
|
<Button type="primary" loading={isCreating} onClick={() => void handleCreateRoom()}>
|
|
공유채팅 생성
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|