feat: expand live chat and work server tools
This commit is contained in:
284
src/app/main/AutomationContextManagementPage.tsx
Normal file
284
src/app/main/AutomationContextManagementPage.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
deleteAutomationContext,
|
||||
type AutomationContextRecord,
|
||||
upsertAutomationContext,
|
||||
useAutomationContextRegistry,
|
||||
} from './automationContextAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.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 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') {
|
||||
return;
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
|
||||
}, [detailMode, form, isCreating, selectedAutomationContext]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) {
|
||||
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 (
|
||||
<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' : ''}`}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,24 @@ export function ChatTypeManagementPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || !isMobileViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { body, documentElement } = document;
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousHtmlOverflow = documentElement.style.overflow;
|
||||
|
||||
body.style.overflow = 'hidden';
|
||||
documentElement.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
documentElement.style.overflow = previousHtmlOverflow;
|
||||
};
|
||||
}, [isMobileViewport]);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedChatTypeId(null);
|
||||
@@ -151,7 +169,7 @@ export function ChatTypeManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
|
||||
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,13 +215,13 @@ export function ChatTypeManagementPage() {
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Tooltip title="비활성화">
|
||||
<Tooltip title="삭제">
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label="비활성화"
|
||||
aria-label="삭제"
|
||||
onClick={() => void handleDelete()}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -28,6 +28,30 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-chat-panel--tablet-app.ant-card,
|
||||
.app-chat-panel--tablet-app .ant-card-body,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__stack,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__stack--chat,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-shell,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-main,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-view,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-view-inner,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-chat-panel--tablet-app .app-chat-panel__stack--chat,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-shell,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-main,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-view,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-view-inner {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal.ant-modal {
|
||||
z-index: 1400;
|
||||
max-width: 100vw;
|
||||
@@ -663,6 +687,12 @@
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty .ant-empty,
|
||||
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list .ant-empty {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--activity {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
@@ -1141,12 +1171,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1180px) {
|
||||
@media (min-width: 768px) and (max-width: 1366px) {
|
||||
.app-chat-panel .app-chat-panel__title-copy .ant-typography,
|
||||
.app-chat-panel .app-chat-panel__conversation-header .ant-typography {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-message__header .ant-typography {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-panel__conversation-section-title,
|
||||
.app-chat-panel .app-chat-panel__conversation-section-count,
|
||||
.app-chat-panel .app-chat-panel__conversation-item-time,
|
||||
@@ -1167,7 +1201,7 @@
|
||||
.app-chat-panel .app-chat-panel__resource-strip-filter,
|
||||
.app-chat-panel .app-chat-panel__resource-strip-empty.ant-typography,
|
||||
.app-chat-panel .app-chat-panel__busy-overlay span {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-panel__conversation-item-title,
|
||||
@@ -1180,6 +1214,10 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-preview-card__ranked-link-anchor {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-panel__conversation-item-preview,
|
||||
.app-chat-panel .app-chat-message__header-meta,
|
||||
.app-chat-panel .app-chat-message__header-meta strong,
|
||||
@@ -1187,11 +1225,12 @@
|
||||
.app-chat-panel .app-chat-panel__composer-queue-text,
|
||||
.app-chat-panel .app-chat-panel__composer-queue-more,
|
||||
.app-chat-panel .app-chat-panel__preview-modal-close-label {
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-message__body {
|
||||
font-size: 18px;
|
||||
.app-chat-panel .app-chat-message__body,
|
||||
.app-chat-panel .app-chat-message__body.ant-typography {
|
||||
font-size: 20px !important;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1722,6 +1761,37 @@
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--ranked-link {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__glyph--ranked-link {
|
||||
color: #1d4ed8;
|
||||
background: rgba(191, 219, 254, 0.72);
|
||||
}
|
||||
|
||||
.app-chat-preview-card__body--ranked-link {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__ranked-link-anchor {
|
||||
display: block;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__ranked-link-anchor:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__open-link.ant-btn {
|
||||
height: 26px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-chat-panel {
|
||||
height: 100%;
|
||||
@@ -2041,7 +2111,7 @@
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
min-height: clamp(112px, 18dvh, 160px);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-queue {
|
||||
@@ -2149,15 +2219,15 @@
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
height: clamp(64px, 10dvh, 92px);
|
||||
min-height: clamp(64px, 10dvh, 92px);
|
||||
height: clamp(112px, 18dvh, 160px);
|
||||
min-height: clamp(112px, 18dvh, 160px);
|
||||
padding: 10px 52px 8px 14px;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
|
||||
padding-top: 76px;
|
||||
padding-top: 96px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-topline,
|
||||
@@ -2188,6 +2258,24 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-contextless-toggle.ant-btn {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-contextless-toggle--active.ant-btn {
|
||||
border-color: #0f766e;
|
||||
background: linear-gradient(135deg, #0f766e, #0f766e);
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.24);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-contextless-toggle--active.ant-btn:hover,
|
||||
.app-chat-panel__composer-contextless-toggle--active.ant-btn:focus-visible {
|
||||
border-color: #0f766e;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-utility-buttons {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
@@ -2901,14 +2989,18 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__composer textarea.ant-input {
|
||||
height: clamp(56px, 8.5dvh, 72px);
|
||||
min-height: clamp(56px, 8.5dvh, 72px);
|
||||
height: clamp(104px, 16dvh, 136px);
|
||||
min-height: clamp(104px, 16dvh, 136px);
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-input-shell {
|
||||
min-height: clamp(104px, 16dvh, 136px);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
|
||||
padding-top: 64px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip-list {
|
||||
|
||||
@@ -20,7 +20,15 @@ import {
|
||||
import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useAppConfig } from './appConfig';
|
||||
@@ -34,6 +42,7 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie
|
||||
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
|
||||
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
||||
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
||||
import { shouldSkipForegroundResyncAfterExternalLink } from './mainChatPanel/linkNavigation';
|
||||
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
|
||||
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
||||
@@ -66,11 +75,13 @@ import type {
|
||||
ChatViewContext,
|
||||
MainChatPanelProps,
|
||||
} from './mainChatPanel/types';
|
||||
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
|
||||
import { buildChatPath } from './routes';
|
||||
import './MainChatPanel.css';
|
||||
import './MainChatPanel.hotfix.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
const ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
type ChatTypeOption = {
|
||||
value: string;
|
||||
@@ -107,6 +118,21 @@ type PendingContextConfirm = {
|
||||
omittedContextCount: number;
|
||||
};
|
||||
|
||||
type ImportedCodexDraftRequest = {
|
||||
text: string;
|
||||
autoSend: boolean;
|
||||
sendMode: 'queue' | 'direct';
|
||||
};
|
||||
|
||||
type PendingFreshConversationSendRequest = {
|
||||
targetSessionId: string;
|
||||
text: string;
|
||||
sendMode: 'queue' | 'direct';
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
};
|
||||
|
||||
const CHAT_MAX_RETRY_ATTEMPTS = 5;
|
||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
const CHAT_RESTART_REQUIRED_PATTERNS = [
|
||||
@@ -950,6 +976,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [previewFindQuery, setPreviewFindQuery] = useState('');
|
||||
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
|
||||
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
|
||||
const [queuedImportedDraft, setQueuedImportedDraft] = useState('');
|
||||
const [pendingImportedDraftRequest, setPendingImportedDraftRequest] = useState<ImportedCodexDraftRequest | null>(null);
|
||||
const [pendingFreshConversationSendRequest, setPendingFreshConversationSendRequest] =
|
||||
useState<PendingFreshConversationSendRequest | null>(null);
|
||||
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -959,6 +990,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
||||
const previewSearchMatchIndexRef = useRef(-1);
|
||||
const previewSearchKeyRef = useRef('');
|
||||
const activeConversationResyncPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const previousPreviewModalOpenRef = useRef(false);
|
||||
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
|
||||
const titleClusterRef = useRef<HTMLDivElement | null>(null);
|
||||
const copyFeedbackTimerRef = useRef<number | null>(null);
|
||||
@@ -977,6 +1010,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
|
||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||
const isCreatingImportedDraftConversationRef = useRef(false);
|
||||
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
|
||||
setRequestItemsState((previous) => {
|
||||
const safePrevious = Array.isArray(previous) ? previous : [];
|
||||
@@ -989,6 +1023,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== buildChatPath('live')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingDraft = consumeCodexLiveDraft();
|
||||
if (!pendingDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveView('chat');
|
||||
setQueuedImportedDraft(pendingDraft.text);
|
||||
setPendingImportedDraftRequest(
|
||||
pendingDraft.autoSend
|
||||
? {
|
||||
text: pendingDraft.text,
|
||||
autoSend: true,
|
||||
sendMode: pendingDraft.sendMode === 'direct' ? 'direct' : 'queue',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
messageApi.success(pendingDraft.autoSend ? '레이아웃 명세를 Codex Live로 전송합니다.' : '레이아웃 명세를 Codex Live 입력창에 채웠습니다.');
|
||||
}, [location.pathname, messageApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedImportedDraft.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
setQueuedImportedDraft('');
|
||||
}, [activeSessionId, queuedImportedDraft]);
|
||||
|
||||
const {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
@@ -1100,7 +1168,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
};
|
||||
const openCreateConversationModal = () => {
|
||||
if (availableChatTypes.length === 0) {
|
||||
@@ -1239,6 +1310,24 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
},
|
||||
[activeSessionId, syncConversationDetailIntoState],
|
||||
);
|
||||
const resyncActiveConversationDetail = useCallback(async () => {
|
||||
const normalizedSessionId = activeSessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeConversationResyncPromiseRef.current) {
|
||||
return activeConversationResyncPromiseRef.current;
|
||||
}
|
||||
|
||||
const requestPromise = syncConversationFromServer(normalizedSessionId).finally(() => {
|
||||
activeConversationResyncPromiseRef.current = null;
|
||||
});
|
||||
|
||||
activeConversationResyncPromiseRef.current = requestPromise;
|
||||
return requestPromise;
|
||||
}, [activeSessionId, syncConversationFromServer]);
|
||||
const resyncConversationEntryState = useCallback(() => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1250,9 +1339,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
void reloadConversationItems();
|
||||
|
||||
if (activeSessionId.trim()) {
|
||||
void syncConversationFromServer(activeSessionId);
|
||||
void resyncActiveConversationDetail();
|
||||
}
|
||||
}, [activeSessionId, reloadConversationItems, syncConversationFromServer]);
|
||||
}, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]);
|
||||
|
||||
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
|
||||
const sessionId = eventSessionId.trim() || activeSessionId;
|
||||
@@ -1662,6 +1751,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
mapSystemStatusMessage,
|
||||
isActivityLogMessage,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat' || !activeSessionId.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const runSilentResync = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
void resyncActiveConversationDetail();
|
||||
};
|
||||
|
||||
runSilentResync();
|
||||
|
||||
const intervalId = window.setInterval(runSilentResync, ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [activeSessionId, activeView, resyncActiveConversationDetail]);
|
||||
const { loadOlderMessages } = useConversationRoomController({
|
||||
activeSessionId,
|
||||
oldestLoadedMessageId,
|
||||
@@ -1782,6 +1896,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
previewError,
|
||||
previewText,
|
||||
setActivePreviewId,
|
||||
setActivePreviewOverride,
|
||||
setIsPreviewModalOpen,
|
||||
} = useConversationViewController({
|
||||
activeSessionId,
|
||||
@@ -1799,11 +1914,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setMessages,
|
||||
});
|
||||
const openPreviewModal = useCallback(
|
||||
(previewId: string) => {
|
||||
setActivePreviewId(previewId);
|
||||
(
|
||||
preview:
|
||||
| string
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
||||
source?: 'message' | 'context';
|
||||
},
|
||||
) => {
|
||||
if (typeof preview === 'string') {
|
||||
setActivePreviewOverride(null);
|
||||
setActivePreviewId(preview);
|
||||
} else {
|
||||
setActivePreviewOverride({
|
||||
...preview,
|
||||
source: preview.source ?? 'message',
|
||||
});
|
||||
setActivePreviewId(null);
|
||||
}
|
||||
|
||||
setIsPreviewModalOpen(true);
|
||||
},
|
||||
[setActivePreviewId, setIsPreviewModalOpen],
|
||||
[setActivePreviewId, setActivePreviewOverride, setIsPreviewModalOpen],
|
||||
);
|
||||
const handleCopyActivePreview = useCallback(async () => {
|
||||
if (!activePreview) {
|
||||
@@ -1884,6 +2019,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
}, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousPreviewModalOpenRef.current && isPreviewModalOpen) {
|
||||
previousPreviewModalOpenRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousPreviewModalOpenRef.current && !isPreviewModalOpen) {
|
||||
previousPreviewModalOpenRef.current = false;
|
||||
void reloadConversationItems();
|
||||
void resyncActiveConversationDetail();
|
||||
}
|
||||
}, [isPreviewModalOpen, reloadConversationItems, resyncActiveConversationDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
resetActivePreviewSearchState();
|
||||
clearActivePreviewSearchSelection();
|
||||
@@ -2755,9 +2903,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
|
||||
return;
|
||||
}
|
||||
resyncConversationEntryState();
|
||||
};
|
||||
const handlePageShow = () => {
|
||||
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
|
||||
return;
|
||||
}
|
||||
resyncConversationEntryState();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -2765,6 +2919,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
resyncConversationEntryState();
|
||||
};
|
||||
|
||||
@@ -2777,7 +2935,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [resyncConversationEntryState]);
|
||||
}, [connectionState, resyncConversationEntryState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'disconnected') {
|
||||
@@ -2946,6 +3104,172 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
scrollViewportToBottom,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFreshConversationSendRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingContextConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSessionId !== pendingFreshConversationSendRequest.targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
mode: pendingFreshConversationSendRequest.sendMode,
|
||||
text: pendingFreshConversationSendRequest.text,
|
||||
chatTypeId: pendingFreshConversationSendRequest.chatTypeId,
|
||||
chatTypeLabel: pendingFreshConversationSendRequest.chatTypeLabel,
|
||||
chatTypeDescription: pendingFreshConversationSendRequest.chatTypeDescription,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
setPendingFreshConversationSendRequest(null);
|
||||
}, [activeSessionId, executeSendMessage, pendingContextConfirm, pendingFreshConversationSendRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingImportedDraftRequest?.autoSend) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingContextConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedSessionIdValue = requestedSessionId?.trim() ?? '';
|
||||
|
||||
if (requestedSessionIdValue && activeSessionId !== requestedSessionIdValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSessionId.trim()) {
|
||||
if (requestedSessionIdValue || isCreatingImportedDraftConversationRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const importChatType = selectedChatType ?? availableChatTypes[0] ?? null;
|
||||
|
||||
if (!importChatType) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCreatingImportedDraftConversationRef.current = true;
|
||||
setSelectedChatTypeId(importChatType.id);
|
||||
void handleCreateConversation(importChatType).finally(() => {
|
||||
isCreatingImportedDraftConversationRef.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectiveChatType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingImportedDraftRequest(null);
|
||||
setDraft('');
|
||||
executeSendMessage({
|
||||
mode: pendingImportedDraftRequest.sendMode,
|
||||
text: pendingImportedDraftRequest.text,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
chatTypeDescription: effectiveChatType.description,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
}, [
|
||||
activeSessionId,
|
||||
availableChatTypes,
|
||||
effectiveChatType,
|
||||
executeSendMessage,
|
||||
handleCreateConversation,
|
||||
pendingContextConfirm,
|
||||
pendingImportedDraftRequest,
|
||||
requestedSessionId,
|
||||
selectedChatType,
|
||||
setDraft,
|
||||
setSelectedChatTypeId,
|
||||
]);
|
||||
|
||||
const handleSendWithoutPreviousContext = useCallback(
|
||||
async (mode: 'queue' | 'direct') => {
|
||||
if (isComposerAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConversationChatType =
|
||||
effectiveChatType ??
|
||||
(selectedChatType && isSelectedChatTypeAllowed
|
||||
? {
|
||||
id: selectedChatType.id,
|
||||
name: selectedChatType.name,
|
||||
description: selectedChatType.description,
|
||||
}
|
||||
: (availableChatTypes[0] ?? null));
|
||||
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextConversationChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const createdSessionId = await handleCreateConversation(nextConversationChatType);
|
||||
if (!createdSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingFreshConversationSendRequest({
|
||||
targetSessionId: createdSessionId,
|
||||
text: trimmed,
|
||||
sendMode: mode,
|
||||
chatTypeId: nextConversationChatType.id,
|
||||
chatTypeLabel: nextConversationChatType.name,
|
||||
chatTypeDescription: nextConversationChatType.description,
|
||||
});
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
},
|
||||
[
|
||||
availableChatTypes,
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
effectiveChatType,
|
||||
handleCreateConversation,
|
||||
isComposerAttachmentUploading,
|
||||
isSelectedChatTypeAllowed,
|
||||
selectedChatType,
|
||||
setMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const handleComposerSend = useCallback(() => {
|
||||
if (isSendWithoutContextEnabled) {
|
||||
void handleSendWithoutPreviousContext('queue');
|
||||
return;
|
||||
}
|
||||
|
||||
handleSend();
|
||||
}, [handleSend, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]);
|
||||
|
||||
const handleComposerSendImmediate = useCallback(() => {
|
||||
if (isSendWithoutContextEnabled) {
|
||||
void handleSendWithoutPreviousContext('direct');
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendImmediate();
|
||||
}, [handleSendImmediate, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]);
|
||||
|
||||
const handleCopyMessage = async (message: ChatMessage) => {
|
||||
await copyText(message.text);
|
||||
setCopiedMessageId(message.id);
|
||||
@@ -3285,8 +3609,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
setSelectedChatTypeId(nextChatTypeId);
|
||||
}}
|
||||
onSend={handleSend}
|
||||
onSendImmediate={handleSendImmediate}
|
||||
onSend={handleComposerSend}
|
||||
onSendImmediate={handleComposerSendImmediate}
|
||||
isSendWithoutContextEnabled={isSendWithoutContextEnabled}
|
||||
onToggleSendWithoutContext={() => {
|
||||
setIsSendWithoutContextEnabled((current) => !current);
|
||||
}}
|
||||
onClearDraft={() => {
|
||||
setDraft('');
|
||||
}}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSearchLayer } from '../../layer';
|
||||
import { useAppStore } from '../../store';
|
||||
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
|
||||
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
|
||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||
import { MainChatPanel } from './MainChatPanel';
|
||||
@@ -174,6 +175,10 @@ export function MainContent({
|
||||
return <AutomationTypeManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:automation-context') {
|
||||
return <AutomationContextManagementPage />;
|
||||
}
|
||||
|
||||
const planStatus = getPlanStatusFromWindowSelection(selectionId);
|
||||
|
||||
if (planStatus) {
|
||||
|
||||
@@ -578,6 +578,12 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
html:has(.chat-type-management-page),
|
||||
body:has(.chat-type-management-page),
|
||||
#root:has(.chat-type-management-page) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.app-main-content.ant-layout-content,
|
||||
.app-main-panel,
|
||||
@@ -602,6 +608,16 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.chat-type-management-page),
|
||||
.app-shell:has(.chat-type-management-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.chat-type-management-page),
|
||||
.app-main-panel:has(.chat-type-management-page),
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
height: calc(100dvh - 52px);
|
||||
min-height: calc(100dvh - 52px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-main-panel--play-saved),
|
||||
.app-shell:has(.app-main-panel--play-saved) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
|
||||
|
||||
361
src/app/main/automationContextAccess.ts
Normal file
361
src/app/main/automationContextAccess.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
export type AutomationContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
defaultSelected: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AutomationContextInput = {
|
||||
id?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
enabled?: boolean;
|
||||
defaultSelected?: boolean;
|
||||
};
|
||||
|
||||
const AUTOMATION_CONTEXTS_API_PATH = '/automation-contexts';
|
||||
const AUTOMATION_CONTEXT_SYNC_EVENT = 'work-app:automation-contexts-changed';
|
||||
const AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content:
|
||||
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
|
||||
const title = normalizeText(record.title);
|
||||
const content = normalizeText(record.content);
|
||||
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
normalizeText(record.id) ||
|
||||
`automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
title: title || 'Context',
|
||||
content,
|
||||
enabled: record.enabled !== false,
|
||||
defaultSelected: record.defaultSelected !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, AutomationContextRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationContextRecord>();
|
||||
|
||||
(items ?? [])
|
||||
.map((item) => normalizeAutomationContext(item))
|
||||
.filter((item): item is AutomationContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
|
||||
}
|
||||
|
||||
function emitAutomationContextsChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(AUTOMATION_CONTEXT_SYNC_EVENT));
|
||||
}
|
||||
|
||||
function resolveApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const API_BASE_URL = resolveApiBaseUrl();
|
||||
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init?.body !== null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${AUTOMATION_CONTEXTS_API_PATH}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.text()) || '자동화 Context 요청에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAutomationContexts<T>(init?: RequestInit) {
|
||||
try {
|
||||
return await requestOnce<T>(API_BASE_URL, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
FALLBACK_BASE_URL &&
|
||||
FALLBACK_BASE_URL !== API_BASE_URL &&
|
||||
error instanceof Error &&
|
||||
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(FALLBACK_BASE_URL, init);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAutomationContextsFromServer() {
|
||||
const response = await requestAutomationContexts<{ ok: boolean; automationContexts: Partial<AutomationContextRecord>[] | null }>({
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.automationContexts == null) {
|
||||
return DEFAULT_AUTOMATION_CONTEXTS;
|
||||
}
|
||||
|
||||
return sanitizeAutomationContexts(response.automationContexts);
|
||||
}
|
||||
|
||||
async function saveAutomationContextsToServer(items: AutomationContextRecord[]) {
|
||||
const resolved = sanitizeAutomationContexts(items);
|
||||
const response = await requestAutomationContexts<{ ok: boolean; automationContexts: Partial<AutomationContextRecord>[] }>({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ automationContexts: resolved }),
|
||||
});
|
||||
|
||||
return sanitizeAutomationContexts(response.automationContexts);
|
||||
}
|
||||
|
||||
export function upsertAutomationContext(items: AutomationContextRecord[], input: AutomationContextInput) {
|
||||
const nextItem = normalizeAutomationContext(input);
|
||||
|
||||
if (!nextItem) {
|
||||
return sanitizeAutomationContexts(items);
|
||||
}
|
||||
|
||||
const nextItems = items.filter((item) => item.id !== nextItem.id);
|
||||
nextItems.push(nextItem);
|
||||
return sanitizeAutomationContexts(nextItems);
|
||||
}
|
||||
|
||||
export function deleteAutomationContext(items: AutomationContextRecord[], automationContextId: string) {
|
||||
const normalizedId = normalizeText(automationContextId);
|
||||
|
||||
if (!normalizedId) {
|
||||
return sanitizeAutomationContexts(items);
|
||||
}
|
||||
|
||||
return sanitizeAutomationContexts(items.filter((item) => item.id !== normalizedId));
|
||||
}
|
||||
|
||||
export function buildAutomationContextOptions(
|
||||
items: AutomationContextRecord[],
|
||||
selectedContextIds: string[] = [],
|
||||
) {
|
||||
const contexts = sanitizeAutomationContexts(items);
|
||||
const selectedSet = new Set(selectedContextIds);
|
||||
const enabledIds = new Set(contexts.filter((item) => item.enabled).map((item) => item.id));
|
||||
|
||||
return contexts
|
||||
.filter((item) => enabledIds.has(item.id) || selectedSet.has(item.id))
|
||||
.map((item) => ({
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveDefaultAutomationContextIds(items: AutomationContextRecord[]) {
|
||||
return sanitizeAutomationContexts(items)
|
||||
.filter((item) => item.enabled && item.defaultSelected)
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
export function useAutomationContextRegistry() {
|
||||
const [automationContexts, setAutomationContextsState] = useState<AutomationContextRecord[]>(DEFAULT_AUTOMATION_CONTEXTS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const nextAutomationContexts = await loadAutomationContextsFromServer();
|
||||
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setAutomationContextsState(nextAutomationContexts);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setAutomationContextsState(DEFAULT_AUTOMATION_CONTEXTS);
|
||||
setErrorMessage(error instanceof Error ? error.message : '자동화 Context를 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
const handleSync = () => {
|
||||
void load();
|
||||
};
|
||||
|
||||
window.addEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setAutomationContexts = async (nextItems: AutomationContextRecord[]) => {
|
||||
const saved = await saveAutomationContextsToServer(nextItems);
|
||||
|
||||
if (mountedRef.current) {
|
||||
setAutomationContextsState(saved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
emitAutomationContextsChange();
|
||||
return saved;
|
||||
};
|
||||
|
||||
return {
|
||||
automationContexts,
|
||||
setAutomationContexts,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
@@ -13,10 +13,20 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
|
||||
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
|
||||
|
||||
export type AutomationTypeContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
defaultSelected: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AutomationTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
contexts: AutomationTypeContextRecord[];
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
@@ -26,6 +36,7 @@ export type AutomationTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
contexts?: Partial<AutomationTypeContextRecord>[];
|
||||
behaviorType?: AutomationBehaviorType;
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -43,11 +54,39 @@ export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string>
|
||||
};
|
||||
|
||||
const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description: '일반 문의/확인 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
description: '기본 자동화 처리용 유형입니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -56,6 +95,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -64,6 +113,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -72,6 +131,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -79,7 +148,18 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
description: '자동화 작업메모로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content:
|
||||
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'auto_worker',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -95,6 +175,10 @@ export function normalizeAutomationTypeId(
|
||||
): PlanAutomationType | BoardAutomationType {
|
||||
const normalized = normalizeText(typeof value === 'string' ? value : '');
|
||||
|
||||
if (normalized === 'stock-alert') {
|
||||
return 'general-inquiry';
|
||||
}
|
||||
|
||||
if (normalized === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
@@ -128,7 +212,67 @@ function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecor
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
|
||||
function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
|
||||
const title = normalizeText(record.title);
|
||||
const content = normalizeText(record.content);
|
||||
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
normalizeText(record.id) ||
|
||||
`automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
title: title || 'Context',
|
||||
content,
|
||||
enabled: record.enabled !== false,
|
||||
defaultSelected: record.defaultSelected !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, AutomationTypeContextRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
|
||||
|
||||
(items ?? [])
|
||||
.map((item) => normalizeAutomationContext(item))
|
||||
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
}
|
||||
|
||||
function normalizeAutomationType(
|
||||
record: Partial<Omit<AutomationTypeRecord, 'contexts'>> & { contexts?: Partial<AutomationTypeContextRecord>[] },
|
||||
): AutomationTypeRecord | null {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!name) {
|
||||
@@ -143,6 +287,7 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
contexts: sanitizeAutomationContexts(record.contexts),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
@@ -217,7 +362,7 @@ const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const hasBody = init?.body !== undefined && init?.body !== null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_TYPE_REQUEST_TIMEOUT_MS);
|
||||
|
||||
@@ -329,6 +474,36 @@ export function buildAutomationTypeOptions(
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildAutomationContextOptions(
|
||||
items: AutomationTypeRecord[],
|
||||
automationTypeId: string | null | undefined,
|
||||
selectedContextIds: string[] = [],
|
||||
) {
|
||||
const normalizedId = normalizeAutomationTypeId(automationTypeId);
|
||||
const automationType = items.find((item) => item.id === normalizedId) ?? null;
|
||||
const contexts = sanitizeAutomationContexts(automationType?.contexts);
|
||||
const selectedSet = new Set(selectedContextIds);
|
||||
const enabledIds = new Set(contexts.filter((item) => item.enabled).map((item) => item.id));
|
||||
|
||||
return contexts
|
||||
.filter((item) => enabledIds.has(item.id) || selectedSet.has(item.id))
|
||||
.map((item) => ({
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveDefaultAutomationContextIds(
|
||||
items: AutomationTypeRecord[],
|
||||
automationTypeId: string | null | undefined,
|
||||
) {
|
||||
const normalizedId = normalizeAutomationTypeId(automationTypeId);
|
||||
const automationType = items.find((item) => item.id === normalizedId) ?? null;
|
||||
return sanitizeAutomationContexts(automationType?.contexts)
|
||||
.filter((item) => item.enabled && item.defaultSelected)
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
export function useAutomationTypeRegistry() {
|
||||
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
import {
|
||||
LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
|
||||
LAYOUT_EDITOR_CHAT_TYPE_ID,
|
||||
LAYOUT_EDITOR_CHAT_TYPE_NAME,
|
||||
} from './chatTypeDefaults';
|
||||
|
||||
export type ChatPermissionRole = 'guest' | 'token-user';
|
||||
|
||||
@@ -38,6 +43,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
|
||||
name: LAYOUT_EDITOR_CHAT_TYPE_NAME,
|
||||
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-27T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'api-request-template',
|
||||
name: 'API요청',
|
||||
@@ -275,17 +288,7 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
|
||||
return sanitizeChatTypes(chatTypes);
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(
|
||||
chatTypes.map((item) =>
|
||||
item.id === normalizedId
|
||||
? {
|
||||
...item,
|
||||
enabled: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
|
||||
}
|
||||
|
||||
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
||||
|
||||
6
src/app/main/chatTypeDefaults.ts
Normal file
6
src/app/main/chatTypeDefaults.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
|
||||
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
|
||||
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION =
|
||||
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
|
||||
@@ -64,6 +64,7 @@ export function ConversationRoomPane({
|
||||
isMobileViewport={false}
|
||||
isChatTypeSelectionLocked={true}
|
||||
isComposerAttachmentUploading={false}
|
||||
isSendWithoutContextEnabled={false}
|
||||
onViewportScroll={() => {}}
|
||||
onViewportTouchEnd={() => {}}
|
||||
onViewportTouchMove={() => {}}
|
||||
@@ -74,6 +75,7 @@ export function ConversationRoomPane({
|
||||
onSelectChatType={() => {}}
|
||||
onSend={() => {}}
|
||||
onSendImmediate={() => {}}
|
||||
onToggleSendWithoutContext={() => {}}
|
||||
onClearDraft={() => {}}
|
||||
onScrollToBottom={() => {}}
|
||||
onToggleResourceStrip={() => {}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
@@ -119,67 +119,88 @@ export function useConversationComposerController({
|
||||
sendChatRequest,
|
||||
scrollViewportToBottom,
|
||||
}: UseConversationComposerControllerOptions) {
|
||||
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
|
||||
const activeComposerUploadCountRef = useRef(0);
|
||||
|
||||
const handleComposerFilesPicked = useCallback(
|
||||
async (files: File[]): Promise<ComposerFilePickResult> => {
|
||||
if (files.length === 0 || isComposerAttachmentUploading) {
|
||||
if (files.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(true);
|
||||
const uploadResults = await Promise.allSettled(
|
||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||
);
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedItems: Array<{ fileName: string; reason: string }> = [];
|
||||
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
|
||||
activeComposerUploadCountRef.current += 1;
|
||||
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
if (activeComposerUploadCountRef.current === 1) {
|
||||
setIsComposerAttachmentUploading(true);
|
||||
}
|
||||
|
||||
const fileName = files[index]?.name || `파일 ${index + 1}`;
|
||||
const reason =
|
||||
result.reason instanceof Error && result.reason.message.trim()
|
||||
? result.reason.message.trim()
|
||||
: '업로드 실패';
|
||||
failedItems.push({ fileName, reason });
|
||||
});
|
||||
try {
|
||||
const uploadResults = await Promise.allSettled(
|
||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||
);
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedItems: Array<{ fileName: string; reason: string }> = [];
|
||||
|
||||
if (uploadedItems.length > 0) {
|
||||
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
}
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(
|
||||
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(false);
|
||||
return {
|
||||
items: uploadResults.map((result, index) => ({
|
||||
key: buildComposerFilePickKey(files[index] as File),
|
||||
fileName: files[index]?.name || `파일 ${index + 1}`,
|
||||
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
|
||||
reason:
|
||||
result.status === 'fulfilled'
|
||||
? undefined
|
||||
: result.reason instanceof Error && result.reason.message.trim()
|
||||
const fileName = files[index]?.name || `파일 ${index + 1}`;
|
||||
const reason =
|
||||
result.reason instanceof Error && result.reason.message.trim()
|
||||
? result.reason.message.trim()
|
||||
: '업로드 실패',
|
||||
})),
|
||||
: '업로드 실패';
|
||||
failedItems.push({ fileName, reason });
|
||||
});
|
||||
|
||||
if (uploadedItems.length > 0) {
|
||||
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
}
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(
|
||||
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
items: uploadResults.map((result, index) => ({
|
||||
key: buildComposerFilePickKey(files[index] as File),
|
||||
fileName: files[index]?.name || `파일 ${index + 1}`,
|
||||
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
|
||||
reason:
|
||||
result.status === 'fulfilled'
|
||||
? undefined
|
||||
: result.reason instanceof Error && result.reason.message.trim()
|
||||
? result.reason.message.trim()
|
||||
: '업로드 실패',
|
||||
})),
|
||||
};
|
||||
} finally {
|
||||
activeComposerUploadCountRef.current = Math.max(0, activeComposerUploadCountRef.current - 1);
|
||||
|
||||
if (activeComposerUploadCountRef.current === 0) {
|
||||
setIsComposerAttachmentUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const queuedUpload = composerUploadQueueRef.current.then(uploadBatch, uploadBatch);
|
||||
composerUploadQueueRef.current = queuedUpload.catch(() => ({ items: [] }));
|
||||
return queuedUpload;
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
composerUploadQueueRef,
|
||||
createLocalMessage,
|
||||
isComposerAttachmentUploading,
|
||||
mergeComposerAttachments,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { sortChatConversationSummaries } from '../../mainChatPanel';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseConversationListDataOptions = {
|
||||
@@ -16,6 +17,8 @@ type UseConversationListDataResult = {
|
||||
setConversationSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
function mergeConversationItemsPreservingRequestedSession(
|
||||
nextItems: ChatConversationSummary[],
|
||||
previousItems: ChatConversationSummary[],
|
||||
@@ -49,51 +52,117 @@ export function useConversationListData({
|
||||
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
|
||||
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
|
||||
const [conversationSearch, setConversationSearch] = useState('');
|
||||
const isMountedRef = useRef(true);
|
||||
const listRequestIdRef = useRef(0);
|
||||
const pendingRequestRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const loadConversationItems = async () => {
|
||||
setIsConversationListLoading(true);
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
setConversationItems((previous) =>
|
||||
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
|
||||
);
|
||||
} catch {
|
||||
setConversationItems((previous) => previous);
|
||||
} finally {
|
||||
setIsConversationListLoading(false);
|
||||
const loadConversationItems = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (pendingRequestRef.current) {
|
||||
return pendingRequestRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
const requestId = listRequestIdRef.current + 1;
|
||||
listRequestIdRef.current = requestId;
|
||||
const isSilent = options?.silent === true;
|
||||
|
||||
if (!isSilent) {
|
||||
setIsConversationListLoading(true);
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
|
||||
emitChatConversationsUpdated(nextItems);
|
||||
return nextItems;
|
||||
});
|
||||
} catch {
|
||||
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems((previous) => previous);
|
||||
} finally {
|
||||
pendingRequestRef.current = null;
|
||||
|
||||
if (!isMountedRef.current || listRequestIdRef.current !== requestId || isSilent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingRequestRef.current = requestPromise;
|
||||
return requestPromise;
|
||||
}, [requestedSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void chatGateway
|
||||
.listConversations()
|
||||
.then((items) => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems((previous) =>
|
||||
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems((previous) => previous);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
isMountedRef.current = true;
|
||||
setIsConversationListLoading(true);
|
||||
void loadConversationItems();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
}, [loadConversationItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let intervalId: number | null = null;
|
||||
|
||||
const startPolling = () => {
|
||||
if (intervalId != null || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
intervalId = window.setInterval(() => {
|
||||
void loadConversationItems({ silent: true });
|
||||
}, CONVERSATION_LIST_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (intervalId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadConversationItems({ silent: true });
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
void loadConversationItems({ silent: true });
|
||||
startPolling();
|
||||
};
|
||||
|
||||
startPolling();
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [loadConversationItems]);
|
||||
|
||||
return {
|
||||
conversationItems,
|
||||
|
||||
@@ -42,13 +42,14 @@ export function useConversationViewController({
|
||||
}: UseConversationViewControllerOptions) {
|
||||
const previousSessionIdRef = useRef(activeSessionId);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const [activePreviewOverride, setActivePreviewOverride] = useState<PreviewItem | null>(null);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const [previewText, setPreviewText] = useState('');
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState('');
|
||||
const [previewContentType, setPreviewContentType] = useState('');
|
||||
|
||||
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
|
||||
const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
|
||||
@@ -64,6 +65,7 @@ export function useConversationViewController({
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setActivePreviewOverride(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
@@ -80,7 +82,7 @@ export function useConversationViewController({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreviewId) {
|
||||
if (!activePreviewId || activePreviewOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export function useConversationViewController({
|
||||
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
}, [activePreviewId, previewItems]);
|
||||
}, [activePreviewId, activePreviewOverride, previewItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen || !activePreview) {
|
||||
@@ -205,6 +207,7 @@ export function useConversationViewController({
|
||||
previewError,
|
||||
previewText,
|
||||
setActivePreviewId,
|
||||
setActivePreviewOverride,
|
||||
setIsPreviewModalOpen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,14 +261,22 @@ export function useConversationViewportController({
|
||||
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
|
||||
if (!viewport || isLoadingOlderMessages) {
|
||||
touchStartYRef.current = null;
|
||||
touchPullActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isAtTop = viewport.scrollTop <= 0;
|
||||
|
||||
if (isAtTop && hasOlderMessages) {
|
||||
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
touchPullActiveRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
touchPullActiveRef.current = true;
|
||||
touchPullActiveRef.current = false;
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
|
||||
|
||||
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
@@ -279,7 +287,15 @@ export function useConversationViewportController({
|
||||
const viewport = viewportRef.current;
|
||||
const currentY = event.touches[0]?.clientY ?? null;
|
||||
|
||||
if (!viewport || currentY == null || viewport.scrollTop > 0) {
|
||||
if (!viewport || currentY == null) {
|
||||
touchPullActiveRef.current = false;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
setIsPullToLoadArmed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewport.scrollTop > 0) {
|
||||
touchPullActiveRef.current = false;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
@@ -313,7 +329,13 @@ export function useConversationViewportController({
|
||||
if (shouldLoadOlder) {
|
||||
void onLoadOlderMessages();
|
||||
}
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
|
||||
}, [
|
||||
hasOlderMessages,
|
||||
isLoadingOlderMessages,
|
||||
isPullToLoadArmed,
|
||||
onLoadOlderMessages,
|
||||
resetPullToLoad,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'disconnected') {
|
||||
|
||||
67
src/app/main/codexLiveDraftBridge.ts
Normal file
67
src/app/main/codexLiveDraftBridge.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
const CODEX_LIVE_DRAFT_STORAGE_KEY = 'codex-live:draft-bridge';
|
||||
|
||||
export type CodexLiveDraftPayload = {
|
||||
text: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
autoSend?: boolean;
|
||||
sendMode?: 'queue' | 'direct';
|
||||
};
|
||||
|
||||
export function stashCodexLiveDraft(payload: CodexLiveDraftPayload) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = payload.text.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
CODEX_LIVE_DRAFT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
text,
|
||||
source: payload.source.trim() || 'unknown',
|
||||
createdAt: payload.createdAt.trim() || new Date().toISOString(),
|
||||
autoSend: payload.autoSend === true,
|
||||
sendMode: payload.sendMode === 'direct' ? 'direct' : 'queue',
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumeCodexLiveDraft() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.sessionStorage.getItem(CODEX_LIVE_DRAFT_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(CODEX_LIVE_DRAFT_STORAGE_KEY);
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw) as Partial<CodexLiveDraftPayload>;
|
||||
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
source: typeof payload.source === 'string' ? payload.source.trim() || 'unknown' : 'unknown',
|
||||
createdAt:
|
||||
typeof payload.createdAt === 'string' && payload.createdAt.trim()
|
||||
? payload.createdAt.trim()
|
||||
: new Date().toISOString(),
|
||||
autoSend: payload.autoSend === true,
|
||||
sendMode: payload.sendMode === 'direct' ? 'direct' : 'queue',
|
||||
} satisfies CodexLiveDraftPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ function parseRoute(pathname: string): {
|
||||
first === 'schedule' ||
|
||||
first === 'history' ||
|
||||
first === 'automation-type' ||
|
||||
first === 'automation-context' ||
|
||||
first === 'server-command')
|
||||
) {
|
||||
return {
|
||||
@@ -155,8 +156,16 @@ function getIsMobileViewport() {
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) {
|
||||
if (!isMobileViewport) {
|
||||
function getIsSidebarOverlayViewport(topMenu: TopMenuKey) {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean, topMenu: TopMenuKey) {
|
||||
if (!isSidebarOverlayViewport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -208,7 +217,10 @@ export function MainLayout() {
|
||||
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu),
|
||||
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu), routeState.topMenu),
|
||||
);
|
||||
const [isSidebarOverlayViewport, setIsSidebarOverlayViewport] = useState(() =>
|
||||
getIsSidebarOverlayViewport(routeState.topMenu),
|
||||
);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
|
||||
@@ -218,7 +230,7 @@ export function MainLayout() {
|
||||
'working' | 'release-pending-main' | 'automation-failed' | null
|
||||
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
|
||||
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
|
||||
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData;
|
||||
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
|
||||
const { chatUnreadCount } = useUnreadCounts();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -240,8 +252,22 @@ export function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu));
|
||||
}, [isMobileViewport, routeState.topMenu]);
|
||||
const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)');
|
||||
const updateViewport = () => {
|
||||
setIsSidebarOverlayViewport(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateViewport();
|
||||
mediaQuery.addEventListener('change', updateViewport);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateViewport);
|
||||
};
|
||||
}, [routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport, routeState.topMenu));
|
||||
}, [isSidebarOverlayViewport, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
|
||||
@@ -256,10 +282,10 @@ export function MainLayout() {
|
||||
useEffect(() => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
|
||||
|
||||
if (savedLayoutId && !savedLayouts.some((record) => record.id === savedLayoutId)) {
|
||||
if (savedLayoutId && savedLayoutsReady && !savedLayouts.some((record) => record.id === savedLayoutId)) {
|
||||
navigate(buildPlayPath('layout'), { replace: true });
|
||||
}
|
||||
}, [navigate, routeState.playMenu, savedLayouts]);
|
||||
}, [navigate, routeState.playMenu, savedLayouts, savedLayoutsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
|
||||
@@ -407,6 +433,7 @@ export function MainLayout() {
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
savedLayouts,
|
||||
savedLayoutsReady,
|
||||
setSavedLayouts,
|
||||
searchOptions,
|
||||
}}
|
||||
@@ -427,21 +454,21 @@ export function MainLayout() {
|
||||
}}
|
||||
onChangeTopMenu={(menu) => {
|
||||
navigate(resolveTopMenuPath(menu, currentDocsFolder));
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu));
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu), menu));
|
||||
}}
|
||||
onOpenPlanQuickFilter={(filter) => {
|
||||
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
||||
setActivePlanQuickFilter(filter);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(targetPlanMenu));
|
||||
setSidebarCollapsed(isMobileViewport);
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans'), 'plans'));
|
||||
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Layout>
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
|
||||
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
@@ -461,13 +488,13 @@ export function MainLayout() {
|
||||
onOpenKeysChange={setSidebarOpenKeys}
|
||||
onSelectApiMenu={(key) => {
|
||||
navigate(buildApisPath(key as ApiSectionKey));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectDocsMenu={(key) => {
|
||||
navigate(buildDocsPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
@@ -475,20 +502,20 @@ export function MainLayout() {
|
||||
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectChatMenu={(key) => {
|
||||
navigate(buildChatPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlayMenu={(key) => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
|
||||
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
@@ -498,7 +525,7 @@ export function MainLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobileViewport && !sidebarCollapsed ? null : (
|
||||
{isSidebarOverlayViewport && !sidebarCollapsed ? null : (
|
||||
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ export type MainLayoutContextValue = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
widgetSamples: LoadedSampleEntry[];
|
||||
savedLayouts: SavedLayoutRecord[];
|
||||
savedLayoutsReady: boolean;
|
||||
setSavedLayouts: (layouts: SavedLayoutRecord[]) => void;
|
||||
searchOptions: SearchKeywordOption[];
|
||||
};
|
||||
|
||||
@@ -157,6 +157,18 @@ export function buildSearchOptions({
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
{
|
||||
id: 'page:plans:automation-context',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-context']}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', 'context', 'context type', '컨텍스트', 'Context 유형', '부모 context'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('automation-context'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
{
|
||||
id: 'page:plans:history',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useMainLayoutData() {
|
||||
const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]);
|
||||
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
|
||||
const [savedLayoutsReady, setSavedLayoutsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -47,11 +48,13 @@ export function useMainLayoutData() {
|
||||
.then((layouts) => {
|
||||
if (mounted) {
|
||||
setSavedLayouts(layouts);
|
||||
setSavedLayoutsReady(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setSavedLayouts([]);
|
||||
setSavedLayoutsReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -98,6 +101,7 @@ export function useMainLayoutData() {
|
||||
widgetSamples,
|
||||
docsDocuments,
|
||||
savedLayouts,
|
||||
savedLayoutsReady,
|
||||
setSavedLayouts,
|
||||
docFolders,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DisconnectOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
@@ -33,11 +34,16 @@ import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
||||
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||
import { triggerResourceDownload } from './downloadUtils';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { copyPreviewContent, copyText } from './chatUtils';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
|
||||
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types';
|
||||
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
@@ -80,6 +86,16 @@ type InlinePreviewTarget = {
|
||||
kind: InlinePreviewKind;
|
||||
};
|
||||
|
||||
type OpenPreviewTarget =
|
||||
| string
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: InlinePreviewKind;
|
||||
source?: 'message' | 'context';
|
||||
};
|
||||
|
||||
type PendingComposerUpload = {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -102,8 +118,14 @@ type MessageRenderPayload = {
|
||||
previewSourceText: string;
|
||||
visibleText: string;
|
||||
diffBlocks: string[];
|
||||
rankedLinkTargets: RankedLinkPreviewTarget[];
|
||||
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||||
};
|
||||
|
||||
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
||||
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
|
||||
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;
|
||||
|
||||
function normalizeInlinePreviewUrl(value: string) {
|
||||
return normalizeChatResourceUrl(value);
|
||||
}
|
||||
@@ -167,7 +189,7 @@ function buildInlinePreviewLabel(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewFileName(item: PreviewOption) {
|
||||
function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
|
||||
try {
|
||||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
||||
@@ -177,10 +199,203 @@ function buildPreviewFileName(item: PreviewOption) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRankedLinkTitle(value: string) {
|
||||
return value
|
||||
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractRankedLinkTargets(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushRankedLink = (title: string, url: string) => {
|
||||
const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
|
||||
const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
|
||||
const key = `${normalizedTitle}::${normalizedUrl}`;
|
||||
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
rankedLinkTargets.push({
|
||||
title: normalizedTitle,
|
||||
url: normalizedUrl,
|
||||
});
|
||||
};
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? '';
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
|
||||
if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
|
||||
markdownMatches.forEach((match) => {
|
||||
const [, label, href] = match;
|
||||
if (href?.trim()) {
|
||||
pushRankedLink(label?.trim() || href.trim(), href);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
|
||||
if (!titleMatch) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectedLines = [line];
|
||||
const title = titleMatch[1]?.trim() ?? '';
|
||||
let url = '';
|
||||
let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
|
||||
let cursor = index + 1;
|
||||
|
||||
while (cursor < lines.length) {
|
||||
const candidate = lines[cursor] ?? '';
|
||||
const trimmedCandidate = candidate.trim();
|
||||
|
||||
if (!trimmedCandidate) {
|
||||
collectedLines.push(candidate);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
|
||||
if (linkMatch) {
|
||||
url = linkMatch[1]?.trim() ?? url;
|
||||
collectedLines.push(candidate);
|
||||
hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
|
||||
hasRank = true;
|
||||
collectedLines.push(candidate);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (title && url && hasRank) {
|
||||
pushRankedLink(title, url);
|
||||
index = cursor - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
keptLines.push(...collectedLines);
|
||||
index = cursor - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||
rankedLinkTargets,
|
||||
};
|
||||
}
|
||||
|
||||
function buildComposerFilePickKey(file: File) {
|
||||
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
function isClipboardImageFile(file: File) {
|
||||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||||
|
||||
if (normalizedType.startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
|
||||
}
|
||||
|
||||
function isGeneratedClipboardImageName(file: File) {
|
||||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||||
|
||||
if (!normalizedName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName);
|
||||
}
|
||||
|
||||
function getClipboardImageMimeRank(file: File) {
|
||||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'image/png':
|
||||
return 0;
|
||||
case 'image/jpeg':
|
||||
return 1;
|
||||
case 'image/webp':
|
||||
return 2;
|
||||
case 'image/gif':
|
||||
return 3;
|
||||
case 'image/bmp':
|
||||
return 4;
|
||||
case 'image/heic':
|
||||
case 'image/heif':
|
||||
return 5;
|
||||
case 'image/tiff':
|
||||
case 'image/tif':
|
||||
return 6;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePreferredClipboardImageFiles(files: File[]) {
|
||||
if (files.length <= 1) {
|
||||
return files;
|
||||
}
|
||||
const sortedFiles = [...files]
|
||||
.sort((left, right) => {
|
||||
const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);
|
||||
|
||||
if (rankDifference !== 0) {
|
||||
return rankDifference;
|
||||
}
|
||||
|
||||
return right.size - left.size;
|
||||
})
|
||||
.slice(0, 1);
|
||||
|
||||
if (files.every(isGeneratedClipboardImageName)) {
|
||||
return sortedFiles;
|
||||
}
|
||||
|
||||
return sortedFiles;
|
||||
}
|
||||
|
||||
function resolveComposerPasteFiles(clipboardData: DataTransfer) {
|
||||
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file instanceof File)
|
||||
.filter((file) => file.size > 0);
|
||||
const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
|
||||
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
|
||||
const imageFiles = candidateFiles.filter(isClipboardImageFile);
|
||||
const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;
|
||||
|
||||
return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
|
||||
}
|
||||
|
||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
let responseMessage = '';
|
||||
@@ -252,7 +467,15 @@ function renderMessageInlineParts(line: string): ReactNode[] {
|
||||
|
||||
const href = normalizeInlinePreviewUrl(rawHref.trim());
|
||||
renderedParts.push(
|
||||
<a key={`${href}-${start}`} href={href} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
key={`${href}-${start}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
openChatExternalLink(href, event);
|
||||
}}
|
||||
>
|
||||
{label.trim() || href}
|
||||
</a>,
|
||||
);
|
||||
@@ -300,18 +523,28 @@ function renderMessageBody(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function extractMessageRenderPayload(text: string): MessageRenderPayload {
|
||||
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
|
||||
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
|
||||
const extractedMessageParts = extractChatMessageParts(message.text);
|
||||
const text = extractedMessageParts.strippedText;
|
||||
const linkCardTargets = [
|
||||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
||||
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||
const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||
const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
|
||||
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
||||
|
||||
return {
|
||||
previewSourceText,
|
||||
visibleText,
|
||||
diffBlocks,
|
||||
rankedLinkTargets,
|
||||
linkCardTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,6 +553,10 @@ function summarizeQueuedText(text: string) {
|
||||
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function normalizeAttachmentName(value: string) {
|
||||
return String(value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isActivityLogMessage(message: ChatMessage) {
|
||||
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
||||
}
|
||||
@@ -552,8 +789,11 @@ function InlineMessagePreview({
|
||||
className="app-chat-preview-card__action"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label="preview 다운로드"
|
||||
href={target.url}
|
||||
download
|
||||
onClick={() => {
|
||||
void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -699,6 +939,7 @@ type ChatConversationViewProps = {
|
||||
isMobileViewport: boolean;
|
||||
isChatTypeSelectionLocked: boolean;
|
||||
isComposerAttachmentUploading: boolean;
|
||||
isSendWithoutContextEnabled: boolean;
|
||||
onViewportScroll: () => void;
|
||||
onViewportTouchEnd: () => void;
|
||||
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
||||
@@ -709,10 +950,11 @@ type ChatConversationViewProps = {
|
||||
onSelectChatType: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onSendImmediate: () => void;
|
||||
onToggleSendWithoutContext: () => void;
|
||||
onClearDraft: () => void;
|
||||
onScrollToBottom: () => void;
|
||||
onToggleResourceStrip: () => void;
|
||||
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
|
||||
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
|
||||
onCopyMessage: (message: ChatMessage) => void;
|
||||
onRetryMessage: (message: ChatMessage) => void;
|
||||
onCancelMessage: (message: ChatMessage) => void;
|
||||
@@ -746,6 +988,7 @@ export function ChatConversationView({
|
||||
isMobileViewport,
|
||||
isChatTypeSelectionLocked,
|
||||
isComposerAttachmentUploading,
|
||||
isSendWithoutContextEnabled,
|
||||
onViewportScroll,
|
||||
onViewportTouchEnd,
|
||||
onViewportTouchMove,
|
||||
@@ -756,6 +999,7 @@ export function ChatConversationView({
|
||||
onSelectChatType,
|
||||
onSend,
|
||||
onSendImmediate,
|
||||
onToggleSendWithoutContext,
|
||||
onClearDraft,
|
||||
onScrollToBottom,
|
||||
onToggleResourceStrip,
|
||||
@@ -1056,11 +1300,17 @@ export function ChatConversationView({
|
||||
}
|
||||
|
||||
const uploadedAttachmentNames = new Set(
|
||||
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
|
||||
);
|
||||
const resolvedUploads = pendingComposerUploads.filter(
|
||||
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
|
||||
composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
|
||||
);
|
||||
const resolvedUploads = pendingComposerUploads.filter((item) => {
|
||||
const normalizedName = normalizeAttachmentName(item.name);
|
||||
|
||||
if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.status === 'uploaded' || item.status === 'failed';
|
||||
});
|
||||
|
||||
if (resolvedUploads.length > 0) {
|
||||
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
|
||||
@@ -1071,6 +1321,7 @@ export function ChatConversationView({
|
||||
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
||||
|
||||
const syncPendingComposerUploads = async (files: File[]) => {
|
||||
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
|
||||
const nextPendingUploads = files.map((file) => ({
|
||||
key: buildComposerFilePickKey(file),
|
||||
name: file.name,
|
||||
@@ -1079,7 +1330,7 @@ export function ChatConversationView({
|
||||
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
||||
|
||||
setPendingComposerUploads((current) => [
|
||||
...current.filter((item) => !pendingKeys.has(item.key)),
|
||||
...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
|
||||
...nextPendingUploads,
|
||||
]);
|
||||
|
||||
@@ -1135,24 +1386,14 @@ export function ChatConversationView({
|
||||
if (!clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemFiles = Array.from(clipboardData.items ?? [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []);
|
||||
const files = resolveComposerPasteFiles(clipboardData);
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const uniqueFiles = Array.from(
|
||||
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
|
||||
);
|
||||
|
||||
void syncPendingComposerUploads(uniqueFiles);
|
||||
void syncPendingComposerUploads(files);
|
||||
};
|
||||
|
||||
const dismissPendingComposerUpload = (key: string) => {
|
||||
@@ -1398,14 +1639,15 @@ export function ChatConversationView({
|
||||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||||
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
||||
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
|
||||
|
||||
if (isActivityLogMessage(message)) {
|
||||
return renderActivityCard(message);
|
||||
}
|
||||
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
||||
const hasPreviewCards =
|
||||
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
|
||||
const shouldRenderStandalonePreview =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
const stackClassName = [
|
||||
@@ -1534,6 +1776,12 @@ export function ChatConversationView({
|
||||
)}
|
||||
{hasPreviewCards ? (
|
||||
<div className="app-chat-message-stack__previews">
|
||||
{linkCardTargets.map((target) => (
|
||||
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
{rankedLinkTargets.map((target) => (
|
||||
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
{diffBlocks.map((diffText, index) => {
|
||||
const previewKey = `${message.id}-diff-${index}`;
|
||||
|
||||
@@ -1577,12 +1825,20 @@ export function ChatConversationView({
|
||||
key={previewKey}
|
||||
target={target}
|
||||
isExpanded={expandedPreviewKey === previewKey}
|
||||
hasModalPreview={Boolean(matchedPreview)}
|
||||
hasModalPreview
|
||||
onOpenModalPreview={() => {
|
||||
if (matchedPreview) {
|
||||
onOpenPreview(matchedPreview.id, { fullscreen: true });
|
||||
return;
|
||||
}
|
||||
onOpenPreview(
|
||||
matchedPreview
|
||||
? matchedPreview.id
|
||||
: {
|
||||
id: previewKey,
|
||||
label: target.label,
|
||||
url: target.url,
|
||||
kind: target.kind,
|
||||
source: 'message',
|
||||
},
|
||||
{ fullscreen: true },
|
||||
);
|
||||
}}
|
||||
onToggle={() => {
|
||||
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
|
||||
@@ -1595,7 +1851,6 @@ export function ChatConversationView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
{activeSystemStatus ? (
|
||||
@@ -1656,6 +1911,17 @@ export function ChatConversationView({
|
||||
</div>
|
||||
<div className="app-chat-panel__composer-actions">
|
||||
<div className="app-chat-panel__composer-action-buttons">
|
||||
<Button
|
||||
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
|
||||
className={`app-chat-panel__composer-contextless-toggle${
|
||||
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
|
||||
}`}
|
||||
icon={<DisconnectOutlined />}
|
||||
aria-label={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
|
||||
title={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
|
||||
onClick={onToggleSendWithoutContext}
|
||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||
/>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label="즉시 요청"
|
||||
|
||||
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal file
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
import type { ChatMessagePart } from './types';
|
||||
|
||||
export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePart, { type: 'link_card' }> }) {
|
||||
return (
|
||||
<section className="app-chat-preview-card app-chat-preview-card--link-card">
|
||||
<div className="app-chat-preview-card__header">
|
||||
<div className="app-chat-preview-card__meta">
|
||||
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
|
||||
<LinkOutlined />
|
||||
</span>
|
||||
<div className="app-chat-preview-card__titles">
|
||||
<span className="app-chat-preview-card__label">{target.title}</span>
|
||||
<span className="app-chat-preview-card__kind">link card</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="app-chat-preview-card__open-link"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={(event) => {
|
||||
void openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
{target.actionLabel?.trim() || '열기'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
|
||||
<a
|
||||
className="app-chat-preview-card__ranked-link-anchor"
|
||||
href={target.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
{target.url}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
|
||||
import { Alert, Button, Empty, Space, Spin, Typography, message } from 'antd';
|
||||
import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
@@ -326,6 +326,13 @@ export function ChatPreviewBody({
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
const handleDownloadResource = () => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
void triggerResourceDownload(target.url, fileName).catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
||||
});
|
||||
};
|
||||
|
||||
if (target.kind === 'file') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-file">
|
||||
@@ -334,15 +341,7 @@ export function ChatPreviewBody({
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
@@ -414,15 +413,7 @@ export function ChatPreviewBody({
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal file
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
|
||||
export type RankedLinkPreviewTarget = {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTarget }) {
|
||||
return (
|
||||
<section className="app-chat-preview-card app-chat-preview-card--ranked-link">
|
||||
<div className="app-chat-preview-card__header">
|
||||
<div className="app-chat-preview-card__meta">
|
||||
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
|
||||
<LinkOutlined />
|
||||
</span>
|
||||
<div className="app-chat-preview-card__titles">
|
||||
<span className="app-chat-preview-card__label">{target.title}</span>
|
||||
<span className="app-chat-preview-card__kind">link preview</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="app-chat-preview-card__open-link"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={(event) => {
|
||||
void openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
|
||||
<a
|
||||
className="app-chat-preview-card__ranked-link-anchor"
|
||||
href={target.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
{target.url}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,74 @@ export function shouldOpenDownloadInNewWindow() {
|
||||
return isStandaloneDisplayMode() && isMobileLikeViewport();
|
||||
}
|
||||
|
||||
export function triggerResourceDownload(url: string, fileName?: string) {
|
||||
function decodeDownloadFileName(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileNameFromUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : 'https://local.invalid');
|
||||
return decodeDownloadFileName(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDispositionFileName(headerValue: string | null) {
|
||||
const normalized = String(headerValue ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const utf8Match = normalized.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeDownloadFileName(utf8Match[1]);
|
||||
}
|
||||
|
||||
const quotedMatch = normalized.match(/filename="([^"]+)"/i);
|
||||
|
||||
if (quotedMatch?.[1]) {
|
||||
return decodeDownloadFileName(quotedMatch[1]);
|
||||
}
|
||||
|
||||
const plainMatch = normalized.match(/filename=([^;]+)/i);
|
||||
return plainMatch?.[1] ? decodeDownloadFileName(plainMatch[1].replace(/^["']|["']$/g, '')) : '';
|
||||
}
|
||||
|
||||
function isHtmlFileName(fileName: string) {
|
||||
return /\.html?$/i.test(fileName.trim());
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function triggerAnchorDownload(url: string, fileName?: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
@@ -45,3 +112,57 @@ export function triggerResourceDownload(url: string, fileName?: string) {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function buildDownloadErrorMessage(response: Response) {
|
||||
if (response.status === 401) {
|
||||
return '인증이 없어 파일을 내려받지 못했습니다.';
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
return '권한이 없어 파일을 내려받지 못했습니다.';
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return '파일을 찾지 못했습니다.';
|
||||
}
|
||||
|
||||
return `다운로드에 실패했습니다. (${response.status})`;
|
||||
}
|
||||
|
||||
export async function triggerResourceDownload(url: string, fileName?: string) {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url, window.location.href);
|
||||
const preferredFileName = fileName?.trim() || resolveFileNameFromUrl(parsedUrl.toString()) || 'resource';
|
||||
|
||||
if (parsedUrl.origin !== window.location.origin) {
|
||||
triggerAnchorDownload(parsedUrl.toString(), preferredFileName);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(parsedUrl.toString(), {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(buildDownloadErrorMessage(response));
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const responseFileName = parseContentDispositionFileName(contentDisposition);
|
||||
const resolvedFileName = responseFileName || preferredFileName;
|
||||
const blob = await response.blob();
|
||||
|
||||
if (contentType.includes('text/html') && !isHtmlFileName(resolvedFileName)) {
|
||||
const htmlPreview = (await blob.text()).trimStart().toLowerCase();
|
||||
|
||||
if (htmlPreview.startsWith('<!doctype html') || htmlPreview.startsWith('<html') || htmlPreview.includes('<head')) {
|
||||
throw new Error('실제 파일 대신 앱 HTML이 반환되어 다운로드를 중단했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
downloadBlob(blob, resolvedFileName);
|
||||
}
|
||||
|
||||
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal file
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at';
|
||||
const CHAT_EXTERNAL_LINK_TTL_MS = 15_000;
|
||||
|
||||
type LinkNavigationEvent = {
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
};
|
||||
|
||||
function canUseSessionStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function persistExternalLinkOpenTimestamp(openedAt: number) {
|
||||
if (!canUseSessionStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY, String(openedAt));
|
||||
}
|
||||
|
||||
function clearExternalLinkOpenTimestamp() {
|
||||
if (!canUseSessionStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
|
||||
}
|
||||
|
||||
export function shouldSkipForegroundResyncAfterExternalLink() {
|
||||
if (!canUseSessionStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawOpenedAt = window.sessionStorage.getItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
|
||||
clearExternalLinkOpenTimestamp();
|
||||
|
||||
if (!rawOpenedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const openedAt = Number(rawOpenedAt);
|
||||
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
|
||||
}
|
||||
|
||||
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
persistExternalLinkOpenTimestamp(Date.now());
|
||||
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (openedWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.click();
|
||||
}
|
||||
164
src/app/main/mainChatPanel/messageParts.ts
Normal file
164
src/app/main/mainChatPanel/messageParts.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { ChatMessagePart } from './types';
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasKnownFileExtension(url: string) {
|
||||
const pathname = url.split('?')[0] ?? '';
|
||||
return /\.[a-z0-9]{1,8}$/i.test(pathname);
|
||||
}
|
||||
|
||||
function isStructuredLinkCardCandidate(url: string) {
|
||||
const normalized = normalizeUrl(url);
|
||||
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return !hasKnownFileExtension(normalized);
|
||||
}
|
||||
|
||||
return !hasKnownFileExtension(normalized);
|
||||
}
|
||||
|
||||
function buildFallbackLinkTitle(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
||||
return lastSegment || parsed.hostname || normalizeText(url);
|
||||
} catch {
|
||||
return normalizeText(url);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStandaloneTitle(value: string) {
|
||||
return value
|
||||
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
|
||||
.replace(/[`'"]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
|
||||
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
|
||||
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return buildFallbackLinkTitle(url);
|
||||
}
|
||||
|
||||
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
||||
const segments = rawBody
|
||||
.split('|')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawTitle, rawUrl, rawActionLabel] = segments;
|
||||
const title = normalizeText(rawTitle);
|
||||
const url = normalizeUrl(rawUrl);
|
||||
const actionLabel = normalizeText(rawActionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card',
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatMessageParts(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
const parts: ChatMessagePart[] = [];
|
||||
const seenLinkKeys = new Set<string>();
|
||||
const pushPart = (nextPart: ChatMessagePart | null) => {
|
||||
if (!nextPart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
||||
|
||||
if (seenLinkKeys.has(dedupeKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
seenLinkKeys.add(dedupeKey);
|
||||
parts.push(nextPart);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
|
||||
if (markdownLinkMatch) {
|
||||
const [, rawTitle, rawUrl] = markdownLinkMatch;
|
||||
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
|
||||
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
|
||||
if (standaloneUrlMatch) {
|
||||
const rawUrl = standaloneUrlMatch[1] ?? '';
|
||||
if (isStructuredLinkCardCandidate(rawUrl)) {
|
||||
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
|
||||
keptLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||
parts,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
import { extractHiddenPreviewUrls } from './previewMarkers';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
@@ -106,7 +107,21 @@ export function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
||||
const extractedMessageParts = extractChatMessageParts(message.text);
|
||||
const structuredLinkUrls = [
|
||||
...(Array.isArray(message.parts) ? message.parts : []),
|
||||
...extractedMessageParts.parts,
|
||||
]
|
||||
.filter(
|
||||
(part): part is Extract<(typeof extractedMessageParts.parts)[number], { type: 'link_card' }> =>
|
||||
part.type === 'link_card' && Boolean(part.url),
|
||||
)
|
||||
.map((part) => part.url);
|
||||
const matches = [
|
||||
...extractAutoDetectedPreviewUrls(message.text),
|
||||
...extractHiddenPreviewUrls(message.text),
|
||||
...structuredLinkUrls,
|
||||
];
|
||||
|
||||
matches.forEach((matchedUrl) => {
|
||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatMessagePart =
|
||||
| {
|
||||
type: 'link_card';
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
author: 'codex' | 'system' | 'user';
|
||||
@@ -8,6 +16,7 @@ export type ChatMessage = {
|
||||
clientRequestId?: string | null;
|
||||
deliveryStatus?: 'retrying' | 'failed' | null;
|
||||
retryCount?: number;
|
||||
parts?: ChatMessagePart[];
|
||||
};
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'automation-context': 'Context 유형',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
@@ -52,6 +53,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'automation-context': 'plan-menu-automation-context',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
|
||||
@@ -113,6 +113,18 @@ export function buildMainViewSearchOptions({
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:automation-context',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-context']}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', 'context', 'context type', '컨텍스트', 'Context 유형', '부모 context'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('plans');
|
||||
setSelectedPlanMenu('automation-context');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -77,6 +77,9 @@ export type ClientNotificationPayload = {
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
threadId?: string;
|
||||
targetClientIds?: string[];
|
||||
targetAppOrigins?: string[];
|
||||
targetAppDomains?: string[];
|
||||
};
|
||||
|
||||
export type ClientNotificationSendResult = {
|
||||
@@ -100,8 +103,26 @@ export type ClientNotificationSendResult = {
|
||||
export type PwaNotificationTokenPayload = {
|
||||
token: string;
|
||||
deviceId?: string;
|
||||
appOrigin?: string;
|
||||
appDomain?: string;
|
||||
};
|
||||
|
||||
function getCurrentAppOrigin() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function getCurrentAppDomain() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.location.hostname;
|
||||
}
|
||||
|
||||
export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type NotificationMessageListStatus = 'all' | 'unread';
|
||||
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
|
||||
@@ -724,6 +745,8 @@ export async function registerWebPushSubscription(
|
||||
subscription,
|
||||
deviceId,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
appOrigin: getCurrentAppOrigin(),
|
||||
appDomain: getCurrentAppDomain(),
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
@@ -744,6 +767,8 @@ export async function registerPwaNotificationToken(payload: PwaNotificationToken
|
||||
body: JSON.stringify({
|
||||
token: payload.token,
|
||||
deviceId: payload.deviceId,
|
||||
appOrigin: payload.appOrigin || getCurrentAppOrigin(),
|
||||
appDomain: payload.appDomain || getCurrentAppDomain(),
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from '../AutomationContextManagementPage';
|
||||
import { BoardPage } from '../../../features/board';
|
||||
import { HistoryPage } from '../../../features/history';
|
||||
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
|
||||
@@ -62,6 +63,14 @@ export function PlansPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'automation-context') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<AutomationContextManagementPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'server-command') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
|
||||
import { MemoLayoutPage } from '../../../features/layout/memo';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
|
||||
|
||||
export function PlayPage() {
|
||||
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
|
||||
const { selectedPlayMenu, savedLayouts, setSavedLayouts } = useMainLayoutContext();
|
||||
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
||||
const selectedSavedLayout = selectedSavedLayoutId
|
||||
? savedLayouts.find((layout) => layout.id === selectedSavedLayoutId) ?? null
|
||||
: null;
|
||||
const isMemoLayout = selectedSavedLayout?.name === '메모';
|
||||
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
|
||||
|
||||
return (
|
||||
<div className={panelClassName}>
|
||||
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
|
||||
{selectedSavedLayoutId ? (
|
||||
{selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
|
||||
{selectedSavedLayoutId && !isMemoLayout ? (
|
||||
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PlanSectionKey =
|
||||
| 'schedule'
|
||||
| 'history'
|
||||
| 'automation-type'
|
||||
| 'automation-context'
|
||||
| 'server-command';
|
||||
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
|
||||
export type PlaySectionKey = 'layout';
|
||||
@@ -49,6 +50,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'automation-context': 'Context 유형',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
@@ -68,6 +70,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'automation-context': 'plan-menu-automation-context',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
@@ -203,6 +206,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
|
||||
key: 'automation-type',
|
||||
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
|
||||
},
|
||||
{
|
||||
key: 'automation-context',
|
||||
label: renderPlanMenuLabel('automation-context', PLAN_SIDEBAR_LABELS['automation-context']),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user