Fix chat type persistence and board flow
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
ArrowsAltOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
@@ -78,8 +79,13 @@ export function AutomationTypeManagementPage() {
|
||||
}, [automationTypes, selectedAutomationTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
return;
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
|
||||
}, [form, isCreating, selectedAutomationType]);
|
||||
}, [detailMode, form, isCreating, selectedAutomationType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
@@ -163,6 +169,41 @@ export function AutomationTypeManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const detailHeaderActions = (
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Tooltip title={isCreating ? '등록' : '수정 저장'}>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="새 입력">
|
||||
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
|
||||
</Tooltip>
|
||||
{!isCreating && selectedAutomationType ? (
|
||||
<Tooltip title="삭제">
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label="삭제"
|
||||
onClick={() => void handleDelete()}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title="목록 가기">
|
||||
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="자동화 유형 관리" className="chat-type-management-page">
|
||||
@@ -250,13 +291,11 @@ export function AutomationTypeManagementPage() {
|
||||
<Card
|
||||
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
|
||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||
extra={detailHeaderActions}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{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}>{isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
@@ -290,172 +329,150 @@ export function AutomationTypeManagementPage() {
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="유형명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 자동화 메모" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
설명
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
<div className="chat-type-management-page__editor-scroll">
|
||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="유형명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
|
||||
>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">입력</Text>
|
||||
{isMobileViewport ? (
|
||||
<Input placeholder="예: 자동화 메모" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
설명
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Form.Item name="description" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={
|
||||
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
<Text type="secondary">입력</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||
{({ getFieldValue }) => {
|
||||
const description = String(getFieldValue('description') ?? '').trim();
|
||||
<Form.Item name="description" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 12, maxRows: 24 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={
|
||||
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||
{({ getFieldValue }) => {
|
||||
const description = String(getFieldValue('description') ?? '').trim();
|
||||
|
||||
return description ? (
|
||||
<MarkdownPreviewContent content={description} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
return description ? (
|
||||
<MarkdownPreviewContent content={description} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__form-actions${
|
||||
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
|
||||
}`}
|
||||
>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
||||
새 입력
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{!isCreating && selectedAutomationType ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -17,21 +19,31 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 52px;
|
||||
padding: 0 14px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding: 10px 0;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px;
|
||||
padding: 4px 14px 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
@@ -40,7 +52,7 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -57,12 +69,23 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form .ant-form-item {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
@@ -73,7 +96,7 @@
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header .ant-typography {
|
||||
margin-bottom: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
@@ -137,15 +160,28 @@
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions .ant-btn {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -173,6 +209,15 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--desktop-hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -191,13 +236,13 @@
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: 180px;
|
||||
min-height: 360px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: 180px;
|
||||
min-height: 360px;
|
||||
overflow: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
@@ -209,10 +254,10 @@
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -226,23 +271,11 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__form-actions--compact {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid--hidden {
|
||||
@@ -251,16 +284,45 @@
|
||||
|
||||
.chat-type-management-page__meta-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--name {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
@@ -293,6 +355,23 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -305,7 +384,18 @@
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra,
|
||||
.chat-type-management-page .ant-card-body {
|
||||
padding: 10px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
gap: 3px;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
@@ -314,28 +404,83 @@
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
|
||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page__markdown-textarea textarea,
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__form-actions {
|
||||
flex-wrap: wrap;
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions .ant-btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
ArrowsAltOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
@@ -82,8 +83,13 @@ export function ChatTypeManagementPage() {
|
||||
}, [chatTypes, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
return;
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
|
||||
}, [form, isCreating, selectedChatType]);
|
||||
}, [detailMode, form, isCreating, selectedChatType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
@@ -145,7 +151,7 @@ export function ChatTypeManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) {
|
||||
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +173,52 @@ export function ChatTypeManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const detailHeaderActions = (
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label={isCreating ? '저장' : '수정 저장'}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="새 입력">
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={isSaving}
|
||||
aria-label="새 입력"
|
||||
onClick={openCreateForm}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Tooltip title="비활성화">
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label="비활성화"
|
||||
onClick={() => void handleDelete()}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title="목록 가기">
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<UnorderedListOutlined />}
|
||||
aria-label="목록 가기"
|
||||
onClick={closeDetail}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
|
||||
@@ -267,13 +319,11 @@ export function ChatTypeManagementPage() {
|
||||
<Card
|
||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||
extra={detailHeaderActions}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{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}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
@@ -301,187 +351,165 @@ export function ChatTypeManagementPage() {
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||
label="권한 대상"
|
||||
name="permissions"
|
||||
>
|
||||
<Checkbox.Group
|
||||
options={[
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
기본 문맥 설명
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
<div className="chat-type-management-page__editor-scroll">
|
||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">입력</Text>
|
||||
{isMobileViewport ? (
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||
label="사용 권한"
|
||||
name="permissions"
|
||||
>
|
||||
<Checkbox.Group
|
||||
options={[
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
기본 문맥 설명
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Form.Item name="description" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={
|
||||
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
<Text type="secondary">입력</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||
{({ getFieldValue }) => {
|
||||
const description = String(getFieldValue('description') ?? '').trim();
|
||||
<Form.Item name="description" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={
|
||||
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
{isMobileViewport ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||
{({ getFieldValue }) => {
|
||||
const description = String(getFieldValue('description') ?? '').trim();
|
||||
|
||||
return description ? (
|
||||
<MarkdownPreviewContent content={description} />
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="미리보기할 문맥 설명이 없습니다."
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
return description ? (
|
||||
<MarkdownPreviewContent content={description} />
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="미리보기할 문맥 설명이 없습니다."
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__form-actions${
|
||||
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
|
||||
}`}
|
||||
>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
||||
새 입력
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2131,12 +2131,12 @@
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: min(240px, calc(100% - 16px));
|
||||
max-height: min(28vh, 180px);
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
width: min(420px, calc(100% - 16px));
|
||||
max-height: min(58vh, 520px);
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(246, 248, 252, 0.96);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
overflow: hidden;
|
||||
@@ -2145,7 +2145,7 @@
|
||||
.app-chat-panel__resource-strip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -2171,22 +2171,18 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-chip {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
.app-chat-panel__resource-strip .app-chat-preview-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip .app-chat-preview-card__body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip .app-chat-panel__preview-rich,
|
||||
.app-chat-panel__resource-strip .previewer-ui__editor,
|
||||
.app-chat-panel__resource-strip .previewer-ui__editor-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-stage {
|
||||
@@ -2596,10 +2592,6 @@
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-chip {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-image,
|
||||
.app-chat-panel__preview-video,
|
||||
.app-chat-panel__preview-frame,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
@@ -34,6 +35,7 @@ import { useConversationViewportController } from './chatV2/hooks/useConversatio
|
||||
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
||||
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
|
||||
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
||||
import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
|
||||
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
||||
@@ -47,9 +49,7 @@ import {
|
||||
createChatMessage,
|
||||
createLocalMessage,
|
||||
ErrorLogViewer,
|
||||
getStoredChatSessionLastTypeId,
|
||||
isPreparingChatReplyText,
|
||||
setStoredChatSessionLastTypeId,
|
||||
sortChatConversationSummaries,
|
||||
upsertChatMessage,
|
||||
useErrorLogs,
|
||||
@@ -666,7 +666,7 @@ function isPreviewRouteUrl(url: string) {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
||||
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -725,14 +725,28 @@ function buildPreviewLabel(url: string, source: PreviewItem['source']) {
|
||||
}
|
||||
}
|
||||
|
||||
function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
|
||||
if (!item || item.kind !== 'code') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||
} catch {
|
||||
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
|
||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||
}
|
||||
}
|
||||
|
||||
function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const urlPattern = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
|
||||
const seen = new Set<string>();
|
||||
const items: PreviewItem[] = [];
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)];
|
||||
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
||||
|
||||
matches.forEach((matchedUrl) => {
|
||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||
@@ -895,7 +909,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[chatTypes, userRoles],
|
||||
);
|
||||
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
|
||||
const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
||||
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
||||
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
|
||||
const requestedSessionId = getSessionIdFromSearch(location.search);
|
||||
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
|
||||
const requestedChatView = getRequestedChatViewFromSearch(location.search);
|
||||
@@ -940,6 +955,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
||||
const previewSearchMatchIndexRef = useRef(-1);
|
||||
const previewSearchKeyRef = useRef('');
|
||||
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
|
||||
const titleClusterRef = useRef<HTMLDivElement | null>(null);
|
||||
const copyFeedbackTimerRef = useRef<number | null>(null);
|
||||
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
|
||||
@@ -950,7 +966,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
||||
const handledRequestedSessionIdRef = useRef('');
|
||||
const isClosingConversationRef = useRef(false);
|
||||
const lastChatTypeSessionIdRef = useRef('');
|
||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||
@@ -966,19 +981,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentContext: ChatViewContext = {
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
chatTypeLabel: selectedChatType?.name ?? '',
|
||||
chatTypeDescription: selectedChatType?.description ?? '',
|
||||
};
|
||||
const {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
@@ -1029,14 +1031,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const handleCreateConversation = async () => {
|
||||
const sessionId = createConversationSessionId();
|
||||
const now = new Date().toISOString();
|
||||
const nextConversationChatType =
|
||||
selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null);
|
||||
const optimisticItem: ChatConversationSummary = {
|
||||
sessionId,
|
||||
clientId: null,
|
||||
title: '새 대화',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
lastChatTypeId: selectedChatType?.id ?? null,
|
||||
contextLabel: selectedChatType?.name ?? null,
|
||||
contextDescription: selectedChatType?.description ?? null,
|
||||
chatTypeId: nextConversationChatType?.id ?? null,
|
||||
lastChatTypeId: nextConversationChatType?.id ?? null,
|
||||
contextLabel: nextConversationChatType?.name ?? null,
|
||||
contextDescription: nextConversationChatType?.description ?? null,
|
||||
notifyOffline: true,
|
||||
hasUnreadResponse: false,
|
||||
currentRequestId: null,
|
||||
@@ -1059,10 +1063,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const item = await chatGateway.createConversation({
|
||||
sessionId,
|
||||
title: '새 대화',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
lastChatTypeId: selectedChatType?.id ?? null,
|
||||
contextLabel: selectedChatType?.name,
|
||||
contextDescription: selectedChatType?.description,
|
||||
chatTypeId: nextConversationChatType?.id ?? null,
|
||||
lastChatTypeId: nextConversationChatType?.id ?? null,
|
||||
contextLabel: nextConversationChatType?.name,
|
||||
contextDescription: nextConversationChatType?.description,
|
||||
notifyOffline: true,
|
||||
});
|
||||
|
||||
@@ -1384,6 +1388,63 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
};
|
||||
const previewItems = useMemo(
|
||||
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
|
||||
[messages],
|
||||
);
|
||||
const isTabletAppLayout = isMobileViewport;
|
||||
const chatMessages = useMemo(
|
||||
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
|
||||
[messages],
|
||||
);
|
||||
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
||||
const activeConversation = useMemo(
|
||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||
[activeSessionId, conversationItems],
|
||||
);
|
||||
const persistedActiveChatTypeId =
|
||||
activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null;
|
||||
const effectiveChatType = useMemo(() => {
|
||||
if (persistedActiveChatTypeId) {
|
||||
const persistedChatType = chatTypes.find((item) => item.id === persistedActiveChatTypeId);
|
||||
if (persistedChatType) {
|
||||
return persistedChatType;
|
||||
}
|
||||
|
||||
return {
|
||||
id: persistedActiveChatTypeId,
|
||||
name: activeConversation?.contextLabel?.trim() || persistedActiveChatTypeId,
|
||||
description: activeConversation?.contextDescription?.trim() || '',
|
||||
};
|
||||
}
|
||||
|
||||
return selectedChatType;
|
||||
}, [
|
||||
activeConversation?.contextDescription,
|
||||
activeConversation?.contextLabel,
|
||||
chatTypes,
|
||||
persistedActiveChatTypeId,
|
||||
selectedChatType,
|
||||
]);
|
||||
const effectiveChatTypeId = effectiveChatType?.id ?? null;
|
||||
const effectiveRegisteredChatType =
|
||||
effectiveChatType ? chatTypes.find((item) => item.id === effectiveChatType.id) ?? null : null;
|
||||
const isEffectiveChatTypeAllowed = effectiveRegisteredChatType
|
||||
? canUseChatType(effectiveRegisteredChatType, userRoles)
|
||||
: false;
|
||||
const currentContext: ChatViewContext = {
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: effectiveChatType?.id ?? null,
|
||||
chatTypeLabel: effectiveChatType?.name ?? '',
|
||||
chatTypeDescription: effectiveChatType?.description ?? '',
|
||||
};
|
||||
const { socketRef, connectionState } = chatConnectionGateway.useConnection({
|
||||
sessionId: activeSessionId,
|
||||
currentContext,
|
||||
@@ -1410,21 +1471,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
activeView,
|
||||
hasAccess,
|
||||
});
|
||||
|
||||
const previewItems = useMemo(
|
||||
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
|
||||
[messages],
|
||||
);
|
||||
const isTabletAppLayout = isMobileViewport;
|
||||
const chatMessages = useMemo(
|
||||
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
|
||||
[messages],
|
||||
);
|
||||
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
||||
const activeConversation = useMemo(
|
||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||
[activeSessionId, conversationItems],
|
||||
);
|
||||
const activeRuntimeStatus = useMemo(
|
||||
() => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId),
|
||||
[runtimeSnapshot, activeSessionId],
|
||||
@@ -1576,7 +1622,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
activeSessionId,
|
||||
activeView,
|
||||
previewItems,
|
||||
selectedChatTypeId: selectedChatType?.id ?? null,
|
||||
selectedChatTypeId,
|
||||
composerRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
@@ -1634,10 +1680,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
}, [activePreview, messageApi]);
|
||||
|
||||
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
|
||||
|
||||
const canSearchActivePreview =
|
||||
Boolean(activePreview) &&
|
||||
!isPreviewLoading &&
|
||||
!previewError.trim() &&
|
||||
!(isActivePreviewHtml && isHtmlPreviewMode) &&
|
||||
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
|
||||
|
||||
const resetActivePreviewSearchState = useCallback(() => {
|
||||
@@ -1673,6 +1722,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
clearActivePreviewSearchSelection();
|
||||
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHtmlPreviewMode(false);
|
||||
}, [activePreview?.id, isPreviewModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
resetActivePreviewSearchState();
|
||||
}, [previewFindQuery, resetActivePreviewSearchState]);
|
||||
@@ -2254,25 +2307,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hasSessionChanged = lastChatTypeSessionIdRef.current !== activeSessionId;
|
||||
lastChatTypeSessionIdRef.current = activeSessionId;
|
||||
|
||||
if (activeSessionId) {
|
||||
if (hasSessionChanged) {
|
||||
const lastUsedChatTypeId =
|
||||
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
|
||||
if (!activeConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
|
||||
if (selectedChatTypeId !== lastUsedChatTypeId) {
|
||||
setSelectedChatTypeId(lastUsedChatTypeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const persistedChatTypeId =
|
||||
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
|
||||
|
||||
const defaultChatTypeId = availableChatTypes[0]?.id ?? null;
|
||||
|
||||
if (selectedChatTypeId !== defaultChatTypeId) {
|
||||
setSelectedChatTypeId(defaultChatTypeId);
|
||||
if (persistedChatTypeId) {
|
||||
if (selectedChatTypeId !== persistedChatTypeId) {
|
||||
setSelectedChatTypeId(persistedChatTypeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2283,34 +2328,38 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
||||
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !selectedChatTypeId) {
|
||||
if (!activeSessionId || !selectedChatTypeId || !selectedChatType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
|
||||
setConversationItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
|
||||
? { ...item, lastChatTypeId: selectedChatTypeId }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
|
||||
|
||||
if (currentLastChatTypeId === selectedChatTypeId) {
|
||||
const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null;
|
||||
if (currentChatTypeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void chatGateway.updateConversation(activeSessionId, {
|
||||
chatTypeId: selectedChatTypeId,
|
||||
lastChatTypeId: selectedChatTypeId,
|
||||
contextLabel: selectedChatType.name,
|
||||
contextDescription: selectedChatType.description,
|
||||
}).then((item) => {
|
||||
setConversationItems((previous) =>
|
||||
previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
|
||||
);
|
||||
}).catch(() => {
|
||||
// Ignore background sync failures and keep local in-memory fallback.
|
||||
});
|
||||
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
|
||||
}, [
|
||||
activeConversation?.chatTypeId,
|
||||
activeConversation?.lastChatTypeId,
|
||||
activeSessionId,
|
||||
selectedChatType,
|
||||
selectedChatTypeId,
|
||||
setConversationItems,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
||||
@@ -2600,7 +2649,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
draft,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType,
|
||||
selectedChatType: effectiveChatType
|
||||
? {
|
||||
id: effectiveChatType.id,
|
||||
name: effectiveChatType.name,
|
||||
description: effectiveChatType.description,
|
||||
}
|
||||
: null,
|
||||
socketRef,
|
||||
composerRef,
|
||||
messagesRef,
|
||||
@@ -2614,7 +2669,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
setPendingContextConfirm,
|
||||
setStoredChatSessionLastTypeId,
|
||||
upsertRequestItem,
|
||||
syncConversationPreviewForRequest,
|
||||
updatePendingMessageStatus,
|
||||
@@ -2924,7 +2978,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
isPullToLoadArmed={isPullToLoadArmed}
|
||||
pullToLoadDistance={pullToLoadDistance}
|
||||
requestStateMap={activeRequestMap}
|
||||
selectedChatTypeId={selectedChatType?.id ?? null}
|
||||
selectedChatTypeId={effectiveChatTypeId}
|
||||
queuedRequests={activeQueuedComposerRequests.map((item, index) => ({
|
||||
requestId: item.requestId,
|
||||
order: index + 1,
|
||||
@@ -2933,7 +2987,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
chatTypeOptions={chatTypeOptions}
|
||||
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
|
||||
isResourceStripOpen={isResourceStripOpen}
|
||||
isComposerDisabled={!selectedChatType}
|
||||
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
|
||||
isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())}
|
||||
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
||||
onViewportScroll={handleViewportScroll}
|
||||
onViewportTouchEnd={handleViewportTouchEnd}
|
||||
@@ -2944,7 +2999,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
onRemoveComposerAttachment={(attachmentId) => {
|
||||
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
|
||||
}}
|
||||
onSelectChatType={setSelectedChatTypeId}
|
||||
onSelectChatType={(nextChatTypeId) => {
|
||||
if (activeConversation?.chatTypeId?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedChatTypeId(nextChatTypeId);
|
||||
}}
|
||||
onSend={handleSend}
|
||||
onSendImmediate={handleSendImmediate}
|
||||
onClearDraft={() => {
|
||||
@@ -3061,6 +3122,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
<div className="app-chat-panel__preview-modal-title">
|
||||
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
||||
<Space size={4} wrap>
|
||||
{isActivePreviewHtml ? (
|
||||
<Button
|
||||
type={isHtmlPreviewMode ? 'default' : 'text'}
|
||||
aria-label={isHtmlPreviewMode ? 'HTML 소스 보기' : 'HTML 미리보기'}
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
setIsHtmlPreviewMode((current) => !current);
|
||||
}}
|
||||
>
|
||||
{isHtmlPreviewMode ? '소스' : '미리보기'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canSearchActivePreview ? (
|
||||
<Button
|
||||
type={isPreviewFindOpen ? 'default' : 'text'}
|
||||
@@ -3141,6 +3214,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
previewError={previewError}
|
||||
previewContentType={previewContentType}
|
||||
maxMarkdownBlocks={undefined}
|
||||
renderHtmlAsFrame={isActivePreviewHtml && isHtmlPreviewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -961,16 +961,28 @@ export function MainHeader({
|
||||
const workServerPendingUpdateCount =
|
||||
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
||||
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||
const hasBuildRequiredUpdate =
|
||||
Boolean(testServerStatus?.buildRequired) ||
|
||||
Boolean(prodServerStatus?.buildRequired) ||
|
||||
Boolean(workServerStatus?.buildRequired);
|
||||
const totalAutomationShortcutCount =
|
||||
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
|
||||
const settingsStatusClassName =
|
||||
totalPendingUpdateCount >= 2
|
||||
hasBuildRequiredUpdate
|
||||
? 'app-header__status-dot--inactive'
|
||||
: totalPendingUpdateCount >= 2
|
||||
? 'app-header__status-dot--inactive'
|
||||
: totalPendingUpdateCount === 1
|
||||
? 'app-header__status-dot--warning'
|
||||
: 'app-header__status-dot--active';
|
||||
const settingsStatusLabel =
|
||||
totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태';
|
||||
hasBuildRequiredUpdate
|
||||
? '커밋 미반영 업데이트 존재'
|
||||
: totalPendingUpdateCount >= 2
|
||||
? '모든 업데이트 존재'
|
||||
: totalPendingUpdateCount === 1
|
||||
? '업데이트 1건 존재'
|
||||
: '최신 상태';
|
||||
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
||||
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
|
||||
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
|
||||
@@ -2832,7 +2844,7 @@ export function MainHeader({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={connectionIndicatorClassName}
|
||||
className={`${connectionIndicatorClassName} app-header__connection-indicator--labelled`}
|
||||
aria-label={chatConnectionLabel}
|
||||
title={chatConnectionLabel}
|
||||
onClick={() => {
|
||||
@@ -2843,6 +2855,12 @@ export function MainHeader({
|
||||
<ApiOutlined />
|
||||
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
|
||||
</span>
|
||||
<span className="app-header__connection-copy">
|
||||
<span className="app-header__connection-title">트랜잭션</span>
|
||||
<span className="app-header__connection-meta">
|
||||
{hasPendingRuntimeWork ? `실행 ${runningRuntimeCount} · 대기 ${queuedRuntimeCount}` : '바로 열기'}
|
||||
</span>
|
||||
</span>
|
||||
{runningRuntimeCount > 0 ? (
|
||||
<span
|
||||
className={connectionCountBadgeClassName}
|
||||
|
||||
@@ -83,6 +83,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--labelled {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
min-width: 124px;
|
||||
padding: 0 12px 0 10px;
|
||||
}
|
||||
|
||||
.app-header__connection-indicator:hover {
|
||||
background: #f3f7ff;
|
||||
}
|
||||
@@ -140,6 +148,28 @@
|
||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22);
|
||||
}
|
||||
|
||||
.app-header__connection-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.app-header__connection-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #182230;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__connection-meta {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes app-header-connection-badge-pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -487,6 +517,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
html,
|
||||
body,
|
||||
@@ -734,6 +770,17 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--labelled {
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-header__connection-copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__runtime-summary {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -764,6 +811,11 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-main-window-layer {
|
||||
inset: 8px;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
|
||||
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
|
||||
@@ -23,10 +23,6 @@ export type ChatTypeInput = {
|
||||
const CHAT_TYPES_API_PATH = '/chat-types';
|
||||
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
|
||||
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
|
||||
const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
|
||||
const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids';
|
||||
const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids';
|
||||
|
||||
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
|
||||
guest: '게스트',
|
||||
'token-user': '토큰 사용자',
|
||||
@@ -50,6 +46,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-16T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description:
|
||||
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
@@ -218,68 +223,6 @@ async function requestChatTypes<T>(init?: RequestInit) {
|
||||
}
|
||||
}
|
||||
|
||||
function readLegacyDeletedChatTypeIds() {
|
||||
if (typeof window === 'undefined') {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDeletedIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
|
||||
const rawLegacyDeletedDefaultIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
|
||||
const deletedIds = [rawDeletedIds, rawLegacyDeletedDefaultIds]
|
||||
.flatMap((raw) => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
})
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(deletedIds);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function readLegacyChatTypes() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deletedIds = readLegacyDeletedChatTypeIds();
|
||||
const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
|
||||
return normalized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegacyChatTypeStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function fetchChatTypesFromServer() {
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
||||
method: 'GET',
|
||||
@@ -300,7 +243,6 @@ async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
||||
});
|
||||
|
||||
emitChatTypesChange();
|
||||
clearLegacyChatTypeStorage();
|
||||
return sanitizeChatTypes(response.chatTypes);
|
||||
}
|
||||
|
||||
@@ -333,7 +275,17 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
|
||||
return sanitizeChatTypes(chatTypes);
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
|
||||
return sanitizeChatTypes(
|
||||
chatTypes.map((item) =>
|
||||
item.id === normalizedId
|
||||
? {
|
||||
...item,
|
||||
enabled: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
||||
@@ -359,13 +311,7 @@ export function useChatTypeRegistry() {
|
||||
|
||||
try {
|
||||
const serverChatTypes = await fetchChatTypesFromServer();
|
||||
let resolvedChatTypes = serverChatTypes;
|
||||
|
||||
if (resolvedChatTypes == null) {
|
||||
const legacyChatTypes = readLegacyChatTypes();
|
||||
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
|
||||
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
|
||||
}
|
||||
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolvedChatTypes);
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type ChatPreviewKind,
|
||||
type ChatPreviewTarget,
|
||||
} from '../../mainChatPanel/ChatPreviewBody';
|
||||
import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers';
|
||||
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
|
||||
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
|
||||
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
@@ -35,12 +37,12 @@ type ConversationRoomPaneProps = {
|
||||
|
||||
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
||||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
|
||||
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
||||
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
|
||||
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
||||
|
||||
type MessageRenderPayload = {
|
||||
previewSourceText: string;
|
||||
visibleText: string;
|
||||
diffBlocks: string[];
|
||||
};
|
||||
@@ -132,7 +134,7 @@ function downloadTextFile(content: string, fileName: string, mimeType = 'text/pl
|
||||
}
|
||||
|
||||
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
|
||||
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
|
||||
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
||||
const seen = new Set<string>();
|
||||
const targets: ChatPreviewTarget[] = [];
|
||||
|
||||
@@ -220,12 +222,10 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const visibleText = text
|
||||
.replace(DIFF_CODE_BLOCK_PATTERN, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
||||
|
||||
return { visibleText, diffBlocks };
|
||||
return { previewSourceText, visibleText, diffBlocks };
|
||||
}
|
||||
|
||||
function isLikelyCollapsibleMessage(text: string) {
|
||||
@@ -574,8 +574,8 @@ export function ConversationRoomPane({
|
||||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||||
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
||||
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
|
||||
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
||||
const shouldRenderStandalonePreview =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
|
||||
@@ -58,7 +58,6 @@ type UseConversationComposerControllerOptions = {
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setShowScrollToBottom: (value: boolean) => void;
|
||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
|
||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
@@ -95,7 +94,6 @@ export function useConversationComposerController({
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
setPendingContextConfirm,
|
||||
setStoredChatSessionLastTypeId,
|
||||
upsertRequestItem,
|
||||
syncConversationPreviewForRequest,
|
||||
updatePendingMessageStatus,
|
||||
@@ -181,8 +179,6 @@ export function useConversationComposerController({
|
||||
failed: false,
|
||||
};
|
||||
|
||||
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
|
||||
|
||||
if (mode === 'queue') {
|
||||
const queuedAt = new Date().toISOString();
|
||||
const optimisticUserMessage: ChatMessage = {
|
||||
@@ -302,7 +298,6 @@ export function useConversationComposerController({
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setShowScrollToBottom,
|
||||
setStoredChatSessionLastTypeId,
|
||||
shouldStickToBottomRef,
|
||||
socketRef,
|
||||
syncConversationPreviewForRequest,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { copyPreviewContent, copyText } from './chatUtils';
|
||||
@@ -83,7 +84,6 @@ type PreviewFetchError = Error & {
|
||||
status?: number;
|
||||
};
|
||||
|
||||
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
|
||||
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
||||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
@@ -92,6 +92,7 @@ const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
||||
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
||||
|
||||
type MessageRenderPayload = {
|
||||
previewSourceText: string;
|
||||
visibleText: string;
|
||||
diffBlocks: string[];
|
||||
};
|
||||
@@ -169,6 +170,21 @@ function buildPreviewFileName(item: PreviewOption) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'markdown':
|
||||
case 'code':
|
||||
case 'diff':
|
||||
case 'document':
|
||||
case 'pdf':
|
||||
return kind;
|
||||
default:
|
||||
return 'file';
|
||||
}
|
||||
}
|
||||
|
||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
let responseMessage = '';
|
||||
@@ -199,7 +215,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
|
||||
}
|
||||
|
||||
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
|
||||
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
||||
const seen = new Set<string>();
|
||||
const targets: InlinePreviewTarget[] = [];
|
||||
|
||||
@@ -293,9 +309,11 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
|
||||
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
||||
|
||||
return {
|
||||
previewSourceText,
|
||||
visibleText,
|
||||
diffBlocks,
|
||||
};
|
||||
@@ -688,6 +706,7 @@ type ChatConversationViewProps = {
|
||||
previewItems: PreviewOption[];
|
||||
isResourceStripOpen: boolean;
|
||||
isComposerDisabled: boolean;
|
||||
isChatTypeSelectionLocked: boolean;
|
||||
isComposerAttachmentUploading: boolean;
|
||||
onViewportScroll: () => void;
|
||||
onViewportTouchEnd: () => void;
|
||||
@@ -733,6 +752,7 @@ export function ChatConversationView({
|
||||
previewItems,
|
||||
isResourceStripOpen,
|
||||
isComposerDisabled,
|
||||
isChatTypeSelectionLocked,
|
||||
isComposerAttachmentUploading,
|
||||
onViewportScroll,
|
||||
onViewportTouchEnd,
|
||||
@@ -756,6 +776,7 @@ export function ChatConversationView({
|
||||
}: ChatConversationViewProps) {
|
||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
|
||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
||||
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
||||
@@ -1191,17 +1212,22 @@ export function ChatConversationView({
|
||||
</label>
|
||||
<div className="app-chat-panel__resource-strip-list">
|
||||
{visiblePreviewItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="app-chat-panel__resource-chip"
|
||||
onClick={() => {
|
||||
onOpenPreview(item.id);
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{item.kind}</span>
|
||||
</button>
|
||||
<InlineMessagePreview
|
||||
key={item.id}
|
||||
target={{
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
kind: normalizePreviewOptionKind(item.kind),
|
||||
}}
|
||||
isExpanded={expandedResourcePreviewKey === item.id}
|
||||
hasModalPreview
|
||||
onOpenModalPreview={() => {
|
||||
onOpenPreview(item.id, { fullscreen: true });
|
||||
}}
|
||||
onToggle={() => {
|
||||
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -1248,13 +1274,13 @@ 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 { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||
|
||||
if (isActivityLogMessage(message)) {
|
||||
return renderActivityCard(message);
|
||||
}
|
||||
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
||||
const shouldRenderStandalonePreview =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
@@ -1498,7 +1524,7 @@ export function ChatConversationView({
|
||||
),
|
||||
}))}
|
||||
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
||||
disabled={chatTypeOptions.length === 0}
|
||||
disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked}
|
||||
onChange={onSelectChatType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -203,6 +203,33 @@ function canRenderFramePreview(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildHtmlFrameDocument(html: string, sourceUrl: string) {
|
||||
const trimmed = html.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '<!doctype html><html><body></body></html>';
|
||||
}
|
||||
|
||||
const baseHref = (() => {
|
||||
try {
|
||||
return new URL('.', sourceUrl).toString();
|
||||
} catch {
|
||||
return sourceUrl;
|
||||
}
|
||||
})();
|
||||
const baseTag = `<base href="${baseHref}">`;
|
||||
|
||||
if (/<head(\s|>)/i.test(trimmed)) {
|
||||
return trimmed.replace(/<head(\s*[^>]*)>/i, (match) => `${match}${baseTag}`);
|
||||
}
|
||||
|
||||
if (/<html(\s|>)/i.test(trimmed)) {
|
||||
return trimmed.replace(/<html(\s*[^>]*)>/i, (match) => `${match}<head>${baseTag}</head>`);
|
||||
}
|
||||
|
||||
return `<!doctype html><html><head>${baseTag}</head><body>${trimmed}</body></html>`;
|
||||
}
|
||||
|
||||
type ChatPreviewBodyProps = {
|
||||
target: ChatPreviewTarget | null;
|
||||
previewText: string;
|
||||
@@ -210,6 +237,7 @@ type ChatPreviewBodyProps = {
|
||||
previewError: string;
|
||||
previewContentType?: string;
|
||||
maxMarkdownBlocks?: number;
|
||||
renderHtmlAsFrame?: boolean;
|
||||
};
|
||||
|
||||
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
||||
@@ -238,6 +266,7 @@ export function ChatPreviewBody({
|
||||
previewError,
|
||||
previewContentType,
|
||||
maxMarkdownBlocks,
|
||||
renderHtmlAsFrame = false,
|
||||
}: ChatPreviewBodyProps) {
|
||||
if (!target) {
|
||||
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
||||
@@ -307,6 +336,16 @@ export function ChatPreviewBody({
|
||||
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
|
||||
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
||||
|
||||
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {
|
||||
return (
|
||||
<iframe
|
||||
title={target.label}
|
||||
srcDoc={buildHtmlFrameDocument(previewText, target.url)}
|
||||
className="app-chat-panel__preview-frame"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||
|
||||
26
src/app/main/mainChatPanel/inlinePreviewUrls.ts
Normal file
26
src/app/main/mainChatPanel/inlinePreviewUrls.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const AUTO_DETECTED_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s<>)\]]+|\/[A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]+)/g;
|
||||
|
||||
export function extractAutoDetectedPreviewUrls(text: string) {
|
||||
const normalized = String(text ?? '');
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
|
||||
const value = match[0]?.trim();
|
||||
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIndex = match.index ?? -1;
|
||||
const previousChar = startIndex > 0 ? normalized[startIndex - 1] : '';
|
||||
|
||||
// Ignore HTML closing tags like </div> that were being misread as same-origin routes.
|
||||
if (previousChar === '<') {
|
||||
continue;
|
||||
}
|
||||
|
||||
urls.push(value);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
Reference in New Issue
Block a user