Files
ai-code-app/src/app/main/SharedChatManagementPage.tsx

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>
);
}