feat: refine codex live chat context flows
This commit is contained in:
1
src/app/main/AutomationContextManagementPage.css
Normal file
1
src/app/main/AutomationContextManagementPage.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './ManagementPage.shared.css';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useAutomationContextRegistry,
|
||||
} from './automationContextAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
import './AutomationContextManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
||||
1
src/app/main/AutomationTypeManagementPage.css
Normal file
1
src/app/main/AutomationTypeManagementPage.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './ManagementPage.shared.css';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type AutomationTypeRecord,
|
||||
} from './automationTypeAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
import './AutomationTypeManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
||||
1
src/app/main/ChatDefaultContextManagementPage.css
Normal file
1
src/app/main/ChatDefaultContextManagementPage.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './ManagementPage.shared.css';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ChatDefaultContextRecord,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
import './ChatDefaultContextManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -55,7 +55,13 @@ export function ChatDefaultContextManagementPage() {
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts,
|
||||
isLoading,
|
||||
hasLoadedFromServer,
|
||||
storeSource,
|
||||
lastLoadedAt,
|
||||
lastFailedAt,
|
||||
errorMessage: contextSettingsErrorMessage,
|
||||
reload,
|
||||
setStore,
|
||||
} = useChatContextSettingsRegistry();
|
||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[0]?.id ?? null);
|
||||
@@ -65,12 +71,15 @@ export function ChatDefaultContextManagementPage() {
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [form] = Form.useForm<ChatDefaultContextFormValue>();
|
||||
|
||||
const selectedContext = useMemo(
|
||||
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
|
||||
[defaultContexts, selectedContextId],
|
||||
);
|
||||
const shouldRenderServerList = hasLoadedFromServer && !contextSettingsErrorMessage;
|
||||
const isServerDataReadyForEditing = hasLoadedFromServer && !contextSettingsErrorMessage && storeSource === 'server';
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
|
||||
@@ -181,58 +190,113 @@ export function ChatDefaultContextManagementPage() {
|
||||
title="기본 유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm} disabled={!isServerDataReadyForEditing}>
|
||||
신규 기본 유형
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 기본 유형</Title>
|
||||
<Text type="secondary">{`${defaultContexts.length}건`}</Text>
|
||||
</div>
|
||||
{defaultContexts.length > 0 ? (
|
||||
<List
|
||||
dataSource={defaultContexts}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={
|
||||
item.id === selectedContextId
|
||||
? '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 />}
|
||||
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>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<div className="chat-type-management-page__list-scroll">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="기본 유형 목록을 서버에서 불러오지 못했습니다."
|
||||
description={
|
||||
<Space direction="vertical" size={8}>
|
||||
<Text>{contextSettingsErrorMessage}</Text>
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsReloading(true);
|
||||
void reload()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
setIsReloading(false);
|
||||
});
|
||||
}}
|
||||
loading={isReloading}
|
||||
>
|
||||
다시 불러오기
|
||||
</Button>
|
||||
{lastLoadedAt ? (
|
||||
<Text type="secondary">{`마지막 정상 동기화: ${new Date(lastLoadedAt).toLocaleString()}`}</Text>
|
||||
) : (
|
||||
<Text type="secondary">정상 동기화 이력이 없어 부분 목록을 대신 보여주지 않습니다.</Text>
|
||||
)}
|
||||
{lastFailedAt ? (
|
||||
<Text type="secondary">{`마지막 실패: ${new Date(lastFailedAt).toLocaleString()}`}</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 기본 유형</Title>
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">{shouldRenderServerList ? `${defaultContexts.length}건` : '서버 확인 전'}</Text>
|
||||
{isLoading ? <Text type="secondary">서버 동기화 중</Text> : null}
|
||||
{shouldRenderServerList && lastLoadedAt ? (
|
||||
<Text type="secondary">{`서버 기준 ${new Date(lastLoadedAt).toLocaleTimeString()}`}</Text>
|
||||
) : null}
|
||||
{!shouldRenderServerList && !contextSettingsErrorMessage ? (
|
||||
<Text type="secondary">표시는 `/api/chat-context-settings` 최신 응답 기준입니다.</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
{shouldRenderServerList && defaultContexts.length > 0 ? (
|
||||
<List
|
||||
dataSource={defaultContexts}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={
|
||||
item.id === selectedContextId
|
||||
? '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 />}
|
||||
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>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 기본 유형이 없습니다." />
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : isLoading && !hasLoadedFromServer ? (
|
||||
<Alert showIcon type="info" message="기본 유형 목록을 서버에서 불러오는 중입니다." />
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
contextSettingsErrorMessage
|
||||
? '서버 동기화 실패 상태입니다. 재조회 후 다시 확인해 주세요.'
|
||||
: hasLoadedFromServer
|
||||
? '등록된 기본 유형이 없습니다.'
|
||||
: '서버 기준 기본 유형을 아직 확인하지 못했습니다.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -248,17 +312,25 @@ export function ChatDefaultContextManagementPage() {
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||
disabled={!isServerDataReadyForEditing}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label="새 입력"
|
||||
onClick={openCreateForm}
|
||||
disabled={!isServerDataReadyForEditing}
|
||||
/>
|
||||
{!isCreating && selectedContext ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="삭제"
|
||||
disabled={!isServerDataReadyForEditing}
|
||||
onClick={() => {
|
||||
void handleDelete();
|
||||
}}
|
||||
@@ -271,6 +343,14 @@ export function ChatDefaultContextManagementPage() {
|
||||
<div className="chat-type-management-page__editor">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
{!isServerDataReadyForEditing ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="서버 최신 기본 유형을 확인한 뒤에만 수정할 수 있습니다."
|
||||
description="부분 목록이나 오래된 상태로 저장해 서버 값이 덮어써지는 경로를 막기 위해, 서버 동기화가 완료되기 전에는 저장을 제한합니다."
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
layout="vertical"
|
||||
|
||||
653
src/app/main/ChatTypeManagementPage.css
Executable file → Normal file
653
src/app/main/ChatTypeManagementPage.css
Executable file → Normal file
@@ -1,652 +1 @@
|
||||
.chat-type-management-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px 14px 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list .ant-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form .ant-form-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header .ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item--active {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-options {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option-copy {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-field {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__field-label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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, 1.55fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid--maximized {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: min(720px, calc(100dvh - 236px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-input-textarea,
|
||||
.chat-type-management-page__markdown-pane .ant-input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
overflow: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||
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__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card--pane-maximized .ant-card-body {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.chat-type-management-page,
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card,
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor,
|
||||
.chat-type-management-page__editor-form,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
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;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra,
|
||||
.chat-type-management-page .ant-card-body {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.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) auto;
|
||||
gap: 6px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
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__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:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: clamp(220px, calc(100dvh - 560px), 320px);
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
flex: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: auto !important;
|
||||
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized {
|
||||
height: calc(100dvh - 52px);
|
||||
max-height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head-title,
|
||||
.chat-type-management-page--pane-maximized .ant-card-extra {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-body {
|
||||
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
|
||||
.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__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
gap: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@import './ManagementPage.shared.css';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -61,7 +62,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
||||
|
||||
export function ChatTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
|
||||
const { chatTypes, builtInChatTypes, customChatTypes, setChatTypes, isLoading, errorMessage, reload } = useChatTypeRegistry();
|
||||
const {
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
@@ -76,6 +77,7 @@ export function ChatTypeManagementPage() {
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
@@ -83,17 +85,17 @@ export function ChatTypeManagementPage() {
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
|
||||
const selectedChatType = useMemo(
|
||||
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||
[chatTypes, selectedChatTypeId],
|
||||
() => customChatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||
[customChatTypes, selectedChatTypeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChatTypeId && chatTypes.some((item) => item.id === selectedChatTypeId)) {
|
||||
if (selectedChatTypeId && customChatTypes.some((item) => item.id === selectedChatTypeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedChatTypeId(chatTypes[0]?.id ?? null);
|
||||
}, [chatTypes, selectedChatTypeId]);
|
||||
setSelectedChatTypeId(customChatTypes[0]?.id ?? null);
|
||||
}, [customChatTypes, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
@@ -198,8 +200,8 @@ export function ChatTypeManagementPage() {
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(savedChatTypes[0]?.id ?? null);
|
||||
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(savedSnapshot.customChatTypes[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
@@ -211,6 +213,19 @@ export function ChatTypeManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = async () => {
|
||||
setIsReloading(true);
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 재조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detailHeaderActions = (
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
|
||||
@@ -259,12 +274,12 @@ export function ChatTypeManagementPage() {
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
|
||||
<Card title="채팅유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 컨텍스트와 권한을 관리하세요."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
@@ -277,94 +292,150 @@ export function ChatTypeManagementPage() {
|
||||
}`}
|
||||
>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="컨텍스트 권한 관리"
|
||||
<Card
|
||||
title="채팅유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 컨텍스트
|
||||
</Button>
|
||||
}
|
||||
extra={!isMobileViewport ? (
|
||||
<Space size={8} wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
|
||||
재조회
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 채팅유형
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 컨텍스트</Title>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${chatTypes.length}건`}</Text>
|
||||
<Title level={5}>사용자 채팅유형</Title>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${customChatTypes.length}건`}</Text>
|
||||
</div>
|
||||
{isMobileViewport ? (
|
||||
<Space size={8} wrap className="chat-type-management-page__list-actions">
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
|
||||
재조회
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 채팅유형
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{chatTypes.length > 0 ? (
|
||||
<List
|
||||
dataSource={chatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
|
||||
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
|
||||
.filter((context): context is NonNullable<typeof context> => Boolean(context));
|
||||
const itemClassName =
|
||||
item.id === selectedChatTypeId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item';
|
||||
<div className="chat-type-management-page__list-scroll">
|
||||
{builtInChatTypes.length > 0 ? (
|
||||
<>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="내장 기본 채팅유형은 코드 기준 고정값입니다."
|
||||
description="이 목록은 참조용이며 여기서 수정·삭제되지 않습니다. 추가/삭제 가능한 대상은 아래 사용자 채팅유형입니다."
|
||||
/>
|
||||
<List
|
||||
dataSource={builtInChatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={itemClassName}
|
||||
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.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
{linkedDefaultContexts.map((context) => (
|
||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
return (
|
||||
<List.Item className="chat-type-management-page__item">
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color="gold">내장 기본유형</Tag>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{customChatTypes.length > 0 ? (
|
||||
<List
|
||||
dataSource={customChatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
|
||||
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
|
||||
.filter((context): context is NonNullable<typeof context> => Boolean(context));
|
||||
const itemClassName =
|
||||
item.id === selectedChatTypeId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item';
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={itemClassName}
|
||||
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.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 컨텍스트가 없습니다." />
|
||||
)}
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
{linkedDefaultContexts.map((context) => (
|
||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 사용자 채팅유형이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
||||
title={isCreating ? '채팅유형 등록' : '채팅유형 상세'}
|
||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||
extra={detailHeaderActions}
|
||||
>
|
||||
@@ -384,8 +455,10 @@ export function ChatTypeManagementPage() {
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||
const savedChatType =
|
||||
savedSnapshot.customChatTypes.find((item) => item.id === values.id || item.name === values.name) ??
|
||||
savedSnapshot.chatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||
const nextChatTypeDefaults = savedChatType
|
||||
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
|
||||
: chatTypeDefaults;
|
||||
@@ -413,9 +486,9 @@ export function ChatTypeManagementPage() {
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
rules={[{ required: true, message: '채팅유형명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
<Input placeholder="예: 운영 문의 전용" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ import {
|
||||
DeleteOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Select, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import {
|
||||
@@ -66,7 +66,12 @@ import {
|
||||
type ChatDefaultContextRecord,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
||||
import { createNotificationMessage } from './notificationApi';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
sendClientNotification,
|
||||
shouldFallbackToLocalNotification,
|
||||
showLocalClientNotification,
|
||||
} from './notificationApi';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import {
|
||||
ChatConversationView,
|
||||
@@ -174,6 +179,14 @@ const CHAT_RESTART_EXCLUSION_PATTERNS = [
|
||||
] as const;
|
||||
const CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS = [0, 350, 900, 1800] as const;
|
||||
|
||||
function areStringListsEqual(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -299,6 +312,56 @@ function buildChatSessionLink(sessionId: string) {
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function buildChatSessionTargetUrl(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const url = new URL('/chat/live', window.location.origin);
|
||||
url.searchParams.set('topMenu', 'chat');
|
||||
url.searchParams.set('sessionId', normalizedSessionId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function createChatQuestionAnswerNotificationBody(args: {
|
||||
questionText?: string | null;
|
||||
answerText?: string | null;
|
||||
fallback: string;
|
||||
}) {
|
||||
const questionPreview = createConversationPreviewText(args.questionText ?? '');
|
||||
const answerPreview = createConversationPreviewText(args.answerText ?? '');
|
||||
|
||||
if (questionPreview && answerPreview) {
|
||||
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (answerPreview) {
|
||||
return `답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (questionPreview) {
|
||||
return `질문: ${questionPreview}`;
|
||||
}
|
||||
|
||||
return args.fallback;
|
||||
}
|
||||
|
||||
async function showLocalChatNotification(args: {
|
||||
title: string;
|
||||
body: string;
|
||||
threadId: string;
|
||||
data: Record<string, string>;
|
||||
}) {
|
||||
await showLocalClientNotification({
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
threadId: args.threadId,
|
||||
data: args.data,
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
@@ -389,6 +452,8 @@ function buildOptimisticConversationSummary(args: {
|
||||
currentJobMessage: null,
|
||||
currentQueueSize: 0,
|
||||
currentStatusUpdatedAt: null,
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
lastRequestPreview: '',
|
||||
lastMessagePreview: '',
|
||||
lastResponsePreview: '',
|
||||
@@ -711,6 +776,106 @@ function resolveConversationListPreviewText(preview: string) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const LOCAL_PENDING_WORK_ANALYSIS_PATTERNS = [
|
||||
/분석/u,
|
||||
/검토/u,
|
||||
/조사/u,
|
||||
/원인/u,
|
||||
/파악/u,
|
||||
/\banalysis\b/i,
|
||||
/\binvestigat(?:e|ion)\b/i,
|
||||
] as const;
|
||||
|
||||
const LOCAL_PENDING_WORK_DESIGN_PATTERNS = [
|
||||
/설계/u,
|
||||
/프롬프트/u,
|
||||
/시안/u,
|
||||
/구조/u,
|
||||
/방향/u,
|
||||
/기획/u,
|
||||
/플로우/u,
|
||||
/아키텍처/u,
|
||||
/\bdesign\b/i,
|
||||
/\barchitecture\b/i,
|
||||
] as const;
|
||||
|
||||
const LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS = [
|
||||
/구현했/u,
|
||||
/수정했/u,
|
||||
/반영했/u,
|
||||
/적용했/u,
|
||||
/완료했/u,
|
||||
/마무리했/u,
|
||||
/배포했/u,
|
||||
/검증했/u,
|
||||
/빌드.*통과/u,
|
||||
/테스트.*통과/u,
|
||||
/캡처/u,
|
||||
/preview/iu,
|
||||
/변경 파일/u,
|
||||
/diff/u,
|
||||
/\bimplement(?:ed|ation)?\b/i,
|
||||
/\bfix(?:ed)?\b/i,
|
||||
/\bverified?\b/i,
|
||||
/\btested?\b/i,
|
||||
] as const;
|
||||
|
||||
const LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
|
||||
/원하시면/u,
|
||||
/진행해드릴/u,
|
||||
/이어(?:서|가)/u,
|
||||
/다음 단계/u,
|
||||
/선택/u,
|
||||
/옵션/u,
|
||||
/후속/u,
|
||||
/\bif you want\b/i,
|
||||
/\bnext step\b/i,
|
||||
] as const;
|
||||
|
||||
function normalizeConversationPendingWorkText(text: string | null | undefined) {
|
||||
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function hasConversationPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function inferConversationPendingWorkReason(item: ChatConversationSummary) {
|
||||
if (item.pendingWorkReason) {
|
||||
return item.pendingWorkReason;
|
||||
}
|
||||
|
||||
const requestText = normalizeConversationPendingWorkText(item.lastRequestPreview);
|
||||
const responseText = normalizeConversationPendingWorkText(item.lastResponsePreview);
|
||||
|
||||
if (
|
||||
hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) &&
|
||||
!hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
|
||||
) {
|
||||
return 'design' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS) &&
|
||||
!hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
|
||||
) {
|
||||
return 'analysis' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) ||
|
||||
hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS)
|
||||
) {
|
||||
if (hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
|
||||
return hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS)
|
||||
? 'design'
|
||||
: 'analysis';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
|
||||
const normalized = label.replace(/\s+/g, ' ').trim();
|
||||
|
||||
@@ -1244,6 +1409,32 @@ function buildMessageSyncKey(messages: ChatMessage[]) {
|
||||
return `${messages.length}:${latestMessage.id}:${latestMessage.text.length}:${latestMessage.timestamp}`;
|
||||
}
|
||||
|
||||
const CHAT_CONVERSATION_DETAIL_PAGE_SIZE = 8;
|
||||
|
||||
function collectVisibleConversationRequestIds(messages: ChatMessage[]) {
|
||||
return new Set(
|
||||
messages
|
||||
.map((message) => message.clientRequestId?.trim() ?? '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function countVisibleConversationRequests(
|
||||
messages: ChatMessage[],
|
||||
requestItems: ChatConversationRequest[],
|
||||
sessionId: string,
|
||||
) {
|
||||
const visibleRequestIds = collectVisibleConversationRequestIds(messages);
|
||||
|
||||
if (visibleRequestIds.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return requestItems.filter(
|
||||
(item) => item.sessionId === sessionId && item.status !== 'removed' && visibleRequestIds.has(item.requestId),
|
||||
).length;
|
||||
}
|
||||
|
||||
function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) {
|
||||
if (attachments.length === 0) {
|
||||
return '';
|
||||
@@ -1689,6 +1880,8 @@ function mergeConversationSummaryPreservingChatType(
|
||||
generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName),
|
||||
contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
|
||||
contextDescription: nextItem.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
||||
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
|
||||
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
|
||||
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
||||
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
||||
@@ -1824,6 +2017,14 @@ function applyRuntimeSnapshotToConversationItems(
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
function isConversationPendingWork(item: ChatConversationSummary) {
|
||||
if (isConversationProcessing(item) || isConversationFailed(item) || item.hasUnreadResponse) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.isPendingWork === true || inferConversationPendingWorkReason(item) != null;
|
||||
}
|
||||
|
||||
function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
@@ -1859,7 +2060,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const { chatTypes, setChatTypes } = useChatTypeRegistry();
|
||||
const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } =
|
||||
useChatContextSettingsRegistry();
|
||||
const [draft, setDraft] = useState('');
|
||||
const draftRef = useRef('');
|
||||
const [draftSeed, setDraftSeed] = useState({ value: '', version: 0 });
|
||||
const [composerAttachments, setComposerAttachments] = useState<ChatComposerAttachment[]>([]);
|
||||
const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false);
|
||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
@@ -1889,6 +2091,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState<string | null>(null);
|
||||
const [editingChatTypeDescription, setEditingChatTypeDescription] = useState('');
|
||||
const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
|
||||
const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false);
|
||||
const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState('');
|
||||
const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState('');
|
||||
const [mobileConversationSectionOpen, setMobileConversationSectionOpen] =
|
||||
@@ -1945,6 +2148,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||
const [pendingClearConversationSessionId, setPendingClearConversationSessionId] = useState<string | null>(null);
|
||||
const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now());
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<TextAreaRef | null>(null);
|
||||
@@ -1975,9 +2179,27 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const isClosingConversationRef = useRef(false);
|
||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
|
||||
const notifiedChatPushKeysRef = useRef<string[]>([]);
|
||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||
const isCreatingImportedDraftConversationRef = useRef(false);
|
||||
const setDraft = useCallback((value: string) => {
|
||||
draftRef.current = value;
|
||||
}, []);
|
||||
const setDraftValue = useCallback((value: string) => {
|
||||
const shouldRefreshComposer = draftRef.current !== value;
|
||||
draftRef.current = value;
|
||||
setDraftSeed((previous) => {
|
||||
if (previous.value === value && !shouldRefreshComposer) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
version: previous.version + 1,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
|
||||
setRequestItemsState((previous) => {
|
||||
const safePrevious = Array.isArray(previous) ? previous : [];
|
||||
@@ -2019,10 +2241,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
|
||||
setDraftValue(draftRef.current.trim() ? draftRef.current : queuedImportedDraft);
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
setQueuedImportedDraft('');
|
||||
}, [activeSessionId, queuedImportedDraft]);
|
||||
}, [activeSessionId, queuedImportedDraft, setDraftValue]);
|
||||
|
||||
const {
|
||||
conversationItems,
|
||||
@@ -2036,6 +2258,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
enabled: activeView === 'chat',
|
||||
});
|
||||
const conversationItemsRef = useRef<ChatConversationSummary[]>(conversationItems);
|
||||
const activeConversation = useMemo(
|
||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||
[activeSessionId, conversationItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
setConversationItems((previous) => {
|
||||
const storedSectionNameMap = readStoredGeneralSectionNameMap();
|
||||
@@ -2293,7 +2519,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
null;
|
||||
setEditingRoomChatTypeId(nextChatTypeId);
|
||||
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
||||
setEditingRoomDefaultContextIds(effectiveDefaultContextIds);
|
||||
setEditingRoomDefaultContextIds(activeRoomContextSettings?.defaultContextIds ?? resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
|
||||
setIsEditingRoomDefaultContextsDirty(false);
|
||||
setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle);
|
||||
setEditingRoomCustomContextContent(effectiveRoomCustomContextContent);
|
||||
setContextDrawerTabKey('chat-type');
|
||||
@@ -2327,32 +2554,47 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
permissions: nextChatType.permissions,
|
||||
enabled: nextChatType.enabled,
|
||||
});
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
nextChatType = savedChatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
|
||||
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||
nextChatType = savedSnapshot.chatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
|
||||
}
|
||||
|
||||
const resolvedChatType = nextChatType;
|
||||
const normalizedDefaultContextIds = Array.from(
|
||||
new Set(
|
||||
editingRoomDefaultContextIds
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => enabledDefaultContexts.some((context) => context.id === value)),
|
||||
),
|
||||
);
|
||||
const nextCustomContextTitle = editingRoomCustomContextTitle.trim();
|
||||
const nextCustomContextContent = editingRoomCustomContextContent.trim();
|
||||
const inheritedDefaultContextIds = resolveChatTypeDefaultContextIds(chatTypeDefaults, resolvedChatType.id);
|
||||
const shouldPersistRoomDefaultContextIds = !areStringListsEqual(normalizedDefaultContextIds, inheritedDefaultContextIds);
|
||||
const shouldPersistRoomCustomContext = Boolean(nextCustomContextTitle || nextCustomContextContent);
|
||||
|
||||
const nextRoomContexts = upsertChatRoomContextSettings(roomContexts, {
|
||||
sessionId: activeConversation.sessionId,
|
||||
defaultContextIds: editingRoomDefaultContextIds,
|
||||
customContextTitle: editingRoomCustomContextTitle,
|
||||
customContextContent: editingRoomCustomContextContent,
|
||||
});
|
||||
const nextRoomContexts =
|
||||
shouldPersistRoomDefaultContextIds || shouldPersistRoomCustomContext
|
||||
? upsertChatRoomContextSettings(roomContexts, {
|
||||
sessionId: activeConversation.sessionId,
|
||||
defaultContextIds: normalizedDefaultContextIds,
|
||||
customContextTitle: nextCustomContextTitle,
|
||||
customContextContent: nextCustomContextContent,
|
||||
})
|
||||
: roomContexts.filter((item) => item.sessionId !== activeConversation.sessionId);
|
||||
const nextDescription = normalizeConversationContextDescription(
|
||||
resolveComposedChatTypeDescription(resolvedChatType, {
|
||||
sessionId: activeConversation.sessionId,
|
||||
defaultContextIds: editingRoomDefaultContextIds,
|
||||
customContextTitle: editingRoomCustomContextTitle,
|
||||
customContextContent: editingRoomCustomContextContent,
|
||||
defaultContextIds: normalizedDefaultContextIds,
|
||||
customContextTitle: nextCustomContextTitle,
|
||||
customContextContent: nextCustomContextContent,
|
||||
}),
|
||||
);
|
||||
|
||||
void setChatContextSettingsStore({
|
||||
await setChatContextSettingsStore({
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts: nextRoomContexts,
|
||||
}).catch(() => {});
|
||||
});
|
||||
setConversationItems((previous) =>
|
||||
previous.map((entry) =>
|
||||
entry.sessionId === activeConversation.sessionId
|
||||
@@ -2376,6 +2618,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
});
|
||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
|
||||
setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id);
|
||||
setIsEditingRoomDefaultContextsDirty(false);
|
||||
setIsContextDrawerOpen(false);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.');
|
||||
@@ -2396,7 +2639,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return nextItems;
|
||||
});
|
||||
};
|
||||
const syncConversationPreviewForRequest = (sessionId: string, text: string, requestedAt = new Date().toISOString()) => {
|
||||
const syncConversationPreviewForRequest = (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
requestedAt = new Date().toISOString(),
|
||||
options?: {
|
||||
requestId?: string;
|
||||
mode?: 'queue' | 'direct';
|
||||
queueSize?: number;
|
||||
jobMessage?: string | null;
|
||||
},
|
||||
) => {
|
||||
const nextPreview = createConversationPreviewText(text);
|
||||
|
||||
if (!nextPreview) {
|
||||
@@ -2413,6 +2666,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
lastMessagePreview: nextPreview,
|
||||
lastMessageAt: requestedAt,
|
||||
updatedAt: requestedAt,
|
||||
currentRequestId: options?.requestId?.trim() || item.currentRequestId,
|
||||
currentJobStatus:
|
||||
options?.mode === 'queue'
|
||||
? 'queued'
|
||||
: options?.mode === 'direct'
|
||||
? 'started'
|
||||
: item.currentJobStatus,
|
||||
currentJobMessage:
|
||||
options?.jobMessage?.trim() ||
|
||||
(options?.mode === 'queue'
|
||||
? '대기열 등록 중'
|
||||
: options?.mode === 'direct'
|
||||
? '즉시 요청 실행 대기 중'
|
||||
: item.currentJobMessage),
|
||||
currentQueueSize:
|
||||
options?.mode === 'queue' ? Math.max(1, Number(options?.queueSize ?? 1)) : item.currentQueueSize,
|
||||
currentStatusUpdatedAt:
|
||||
options?.mode === 'queue' || options?.mode === 'direct'
|
||||
? requestedAt
|
||||
: item.currentStatusUpdatedAt,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -2486,18 +2759,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
const syncToken = ++conversationDetailSyncTokenRef.current;
|
||||
const activeSessionRequestCount = requestItemsRef.current.filter(
|
||||
(item) => item.sessionId === normalizedSessionId,
|
||||
).length;
|
||||
const activeSessionVisibleRequestCount =
|
||||
const visibleMessages =
|
||||
normalizedSessionId === activeSessionId
|
||||
? requestItemsRef.current.filter(
|
||||
(item) => item.sessionId === normalizedSessionId && item.status !== 'removed',
|
||||
).length
|
||||
: 0;
|
||||
? messagesRef.current
|
||||
: getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId);
|
||||
const visibleRequestCount = countVisibleConversationRequests(
|
||||
visibleMessages,
|
||||
requestItemsRef.current,
|
||||
normalizedSessionId,
|
||||
);
|
||||
const detailLimit = Math.min(
|
||||
60,
|
||||
Math.max(20, activeSessionRequestCount || 0, activeSessionVisibleRequestCount || 0),
|
||||
Math.max(CHAT_CONVERSATION_DETAIL_PAGE_SIZE, visibleRequestCount),
|
||||
);
|
||||
|
||||
for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) {
|
||||
@@ -2824,6 +3097,78 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:codex-response`;
|
||||
|
||||
if (notifiedChatPushKeysRef.current.includes(chatNotificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifiedChatPushKeysRef.current = [...notifiedChatPushKeysRef.current, chatNotificationKey].slice(-80);
|
||||
|
||||
const conversationTitle = eventConversation?.title?.trim() || '현재 채팅방';
|
||||
const targetUrl = buildChatSessionTargetUrl(sessionId);
|
||||
const notificationTitle = `${conversationTitle} 새 답변`;
|
||||
const notificationBody = createChatQuestionAnswerNotificationBody({
|
||||
questionText: relatedQuestionText,
|
||||
answerText: incomingMessage.text,
|
||||
fallback: `${conversationTitle}에 새 답변이 도착했습니다.`,
|
||||
});
|
||||
const notificationData = {
|
||||
category: 'chat',
|
||||
priority: 'normal',
|
||||
sessionId,
|
||||
conversationTitle,
|
||||
requestId: incomingMessage.clientRequestId ?? '',
|
||||
questionText: relatedQuestionText,
|
||||
answerText: incomingMessage.text,
|
||||
targetUrl,
|
||||
linkUrl: targetUrl,
|
||||
linkLabel: '채팅 바로 열기',
|
||||
};
|
||||
const serializedNotificationData = Object.fromEntries(
|
||||
Object.entries(notificationData).flatMap(([key, value]) => (value ? [[key, String(value)]] : [])),
|
||||
);
|
||||
|
||||
void Promise.allSettled([
|
||||
createNotificationMessage({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
category: 'chat',
|
||||
source: 'codex-live',
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
...notificationData,
|
||||
previewText: `새 답변 · ${conversationTitle}`,
|
||||
},
|
||||
}),
|
||||
sendClientNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
}),
|
||||
]).then(async ([storedResult, pushResult]) => {
|
||||
if (pushResult.status === 'rejected') {
|
||||
await showLocalChatNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
});
|
||||
} else if (shouldFallbackToLocalNotification(pushResult.value)) {
|
||||
await showLocalChatNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
});
|
||||
}
|
||||
|
||||
if (storedResult.status === 'rejected' && pushResult.status === 'rejected') {
|
||||
notifiedChatPushKeysRef.current = notifiedChatPushKeysRef.current.filter((key) => key !== chatNotificationKey);
|
||||
}
|
||||
});
|
||||
};
|
||||
const previewItems = useMemo(
|
||||
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))),
|
||||
@@ -2836,10 +3181,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[messages],
|
||||
);
|
||||
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
||||
const activeConversation = useMemo(
|
||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||
[activeSessionId, conversationItems],
|
||||
);
|
||||
const activeConversationHasLocalActivity =
|
||||
chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId);
|
||||
const persistedActiveChatTypeId =
|
||||
@@ -3266,6 +3607,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]);
|
||||
const pendingDeleteConversation =
|
||||
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
|
||||
const pendingClearConversation =
|
||||
conversationItems.find((item) => item.sessionId === pendingClearConversationSessionId) ?? null;
|
||||
const editingGeneralSectionConversation =
|
||||
conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null;
|
||||
const availableGeneralSectionNames = useMemo(
|
||||
@@ -3280,7 +3623,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[conversationItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pendingContextConfirm && !pendingDeleteConversation) {
|
||||
if (!pendingContextConfirm && !pendingDeleteConversation && !pendingClearConversation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3313,7 +3656,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleEnterConfirm, true);
|
||||
};
|
||||
}, [pendingContextConfirm, pendingDeleteConversation]);
|
||||
}, [pendingClearConversation, pendingContextConfirm, pendingDeleteConversation]);
|
||||
const {
|
||||
activePreview,
|
||||
isPreviewLoading,
|
||||
@@ -3643,6 +3986,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
{jobStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{isConversationPendingWork(item) ? (
|
||||
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
|
||||
작업중
|
||||
</span>
|
||||
) : null}
|
||||
{isUnread ? (
|
||||
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
|
||||
답변 도착
|
||||
@@ -4005,6 +4353,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
currentJobMessage: null,
|
||||
currentQueueSize: 0,
|
||||
currentStatusUpdatedAt: null,
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
lastRequestPreview: '',
|
||||
lastMessagePreview: '',
|
||||
lastResponsePreview: '',
|
||||
@@ -4026,7 +4376,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setMessages(hasCachedMessages ? cachedMessages : []);
|
||||
setRequestItems((previous) => previous.filter((item) => item.sessionId === sessionId));
|
||||
setRequestItems((previous) => {
|
||||
const visibleRequestIds = collectVisibleConversationRequestIds(cachedMessages);
|
||||
|
||||
return previous.filter(
|
||||
(item) =>
|
||||
item.sessionId === sessionId &&
|
||||
(visibleRequestIds.size === 0 ? !hasCachedMessages : visibleRequestIds.has(item.requestId)),
|
||||
);
|
||||
});
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
@@ -4201,6 +4559,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
const {
|
||||
cancelPendingRequest,
|
||||
handleClearConversation,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
@@ -4239,6 +4598,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
replaceChatSessionInUrl,
|
||||
messageApi,
|
||||
});
|
||||
const openClearConversationDataModal = useCallback(() => {
|
||||
if (!activeConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingClearConversationSessionId(activeConversation.sessionId);
|
||||
}, [activeConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'connected') {
|
||||
@@ -4269,13 +4635,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
updatePendingMessageStatus(request.requestId, null, request.retryCount);
|
||||
return [];
|
||||
} catch {
|
||||
const nextRetryCount = request.retryCount + 1;
|
||||
|
||||
if (nextRetryCount >= CHAT_MAX_RETRY_ATTEMPTS) {
|
||||
updatePendingMessageStatus(request.requestId, 'failed', nextRetryCount);
|
||||
return [{ ...request, retryCount: nextRetryCount, failed: true }];
|
||||
}
|
||||
|
||||
const nextRetryCount = Math.min(request.retryCount + 1, CHAT_MAX_RETRY_ATTEMPTS);
|
||||
updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount);
|
||||
return [{ ...request, retryCount: nextRetryCount }];
|
||||
}
|
||||
@@ -4787,7 +5147,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
} = useConversationComposerController({
|
||||
activeSessionId,
|
||||
appConfigChat: appConfig.chat,
|
||||
draft,
|
||||
getDraft: () => draftRef.current,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType: effectiveChatType
|
||||
@@ -4802,7 +5162,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
messagesRef,
|
||||
pendingRequestsRef,
|
||||
shouldStickToBottomRef,
|
||||
setDraft,
|
||||
setDraft: setDraftValue,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
setMessages,
|
||||
@@ -4865,7 +5225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setPendingImportedDraftRequest(null);
|
||||
setDraft('');
|
||||
setDraftValue('');
|
||||
executeSendMessage({
|
||||
mode: pendingImportedDraftRequest.sendMode,
|
||||
text: pendingImportedDraftRequest.text,
|
||||
@@ -4886,7 +5246,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
pendingImportedDraftRequest,
|
||||
requestedSessionId,
|
||||
selectedChatType,
|
||||
setDraft,
|
||||
setDraftValue,
|
||||
setSelectedChatTypeId,
|
||||
]);
|
||||
|
||||
@@ -4905,7 +5265,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
description: resolveComposedChatTypeDescription(selectedChatType),
|
||||
}
|
||||
: (availableChatTypes[0] ?? null));
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
@@ -4944,7 +5304,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
draftRef,
|
||||
effectiveChatType,
|
||||
executeSendMessage,
|
||||
isComposerAttachmentUploading,
|
||||
@@ -4981,6 +5341,44 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage],
|
||||
);
|
||||
|
||||
const handlePromptSubmit = useCallback(
|
||||
async ({ text, mode }: { text: string; mode: 'queue' | 'direct' }) => {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!effectiveChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 prompt 선택을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!activeSessionId.trim()) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('활성 대화방이 없어서 prompt 선택을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
chatTypeDescription: effectiveChatTypeDescription,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[activeSessionId, createLocalMessage, effectiveChatType, effectiveChatTypeDescription, executeSendMessage, setMessages],
|
||||
);
|
||||
|
||||
const handleCopyMessage = async (message: ChatMessage) => {
|
||||
await copyText(message.text);
|
||||
setCopiedMessageId(message.id);
|
||||
@@ -5187,6 +5585,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="채팅방 데이터 초기화">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="채팅방 데이터 초기화"
|
||||
onClick={() => {
|
||||
setIsMobileActionGroupOpen(false);
|
||||
openClearConversationDataModal();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -5249,6 +5659,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="채팅방 데이터 초기화">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="채팅방 데이터 초기화"
|
||||
onClick={openClearConversationDataModal}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -5347,7 +5766,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
isSystemStatusPending={isSystemStatusPending}
|
||||
showScrollToBottom={showScrollToBottom}
|
||||
copiedMessageId={copiedMessageId}
|
||||
draft={draft}
|
||||
draft={draftSeed.value}
|
||||
draftVersion={draftSeed.version}
|
||||
composerAttachments={composerAttachments}
|
||||
isConversationLoading={isConversationContentLoading}
|
||||
conversationLoadingLabel={conversationLoadingLabel}
|
||||
@@ -5395,7 +5815,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setIsSendWithoutContextEnabled((current) => !current);
|
||||
}}
|
||||
onClearDraft={() => {
|
||||
setDraft('');
|
||||
setDraftValue('');
|
||||
}}
|
||||
onToggleResourceStrip={() => {
|
||||
setIsResourceStripOpen((current) => !current);
|
||||
@@ -5431,6 +5851,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
void deleteStoredRequest(message.clientRequestId);
|
||||
}}
|
||||
onRemoveQueuedRequest={removeQueuedComposerRequest}
|
||||
onSubmitPrompt={handlePromptSubmit}
|
||||
/>
|
||||
) : (
|
||||
<div className="app-chat-panel__conversation-empty">
|
||||
@@ -5508,15 +5929,35 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
children: (
|
||||
<div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
|
||||
<div className="app-chat-panel__context-drawer-section-head">
|
||||
<Text strong>현재 채팅유형</Text>
|
||||
<Text type="secondary">다른 유형을 고르는 대신, 이 방이 쓰는 기본 문맥 내용을 바로 확인하고 수정합니다.</Text>
|
||||
<Text strong>채팅기본유형</Text>
|
||||
<Text type="secondary">이 채팅방이 기본으로 사용할 유형을 선택하고, 그 유형의 기본 문맥도 함께 수정할 수 있습니다.</Text>
|
||||
</div>
|
||||
<Select
|
||||
value={editingRoomChatTypeId ?? undefined}
|
||||
placeholder="채팅유형을 선택하세요."
|
||||
options={availableChatTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
onChange={(nextChatTypeId) => {
|
||||
setEditingRoomChatTypeId(nextChatTypeId);
|
||||
const nextChatType =
|
||||
chatTypes.find((item) => item.id === nextChatTypeId) ??
|
||||
availableChatTypes.find((item) => item.id === nextChatTypeId) ??
|
||||
null;
|
||||
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
||||
|
||||
if (!activeRoomContextSettings && !isEditingRoomDefaultContextsDirty) {
|
||||
setEditingRoomDefaultContextIds(resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{contextDrawerChatType ? (
|
||||
<>
|
||||
<div className="app-chat-panel__context-drawer-card app-chat-panel__context-drawer-card--readonly">
|
||||
<span className="app-chat-panel__context-drawer-card-title">{contextDrawerChatType.name}</span>
|
||||
<Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
|
||||
저장하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다.
|
||||
아래 문맥 설명을 수정하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__context-drawer-textarea-shell">
|
||||
@@ -5579,6 +6020,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
className="app-chat-panel__context-drawer-checkbox"
|
||||
value={editingRoomDefaultContextIds}
|
||||
onChange={(checkedValues) => {
|
||||
setIsEditingRoomDefaultContextsDirty(true);
|
||||
setEditingRoomDefaultContextIds(
|
||||
checkedValues
|
||||
.map((value) => String(value).trim())
|
||||
@@ -5894,6 +6336,34 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(pendingClearConversation)}
|
||||
title="채팅방 데이터를 초기화할까요?"
|
||||
okText="초기화"
|
||||
cancelText="취소"
|
||||
okButtonProps={{ danger: true, autoFocus: true }}
|
||||
modalRender={renderModalWithEnterConfirm}
|
||||
zIndex={1700}
|
||||
onCancel={() => {
|
||||
setPendingClearConversationSessionId(null);
|
||||
}}
|
||||
onOk={async () => {
|
||||
const targetSessionId = pendingClearConversationSessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleClearConversation(targetSessionId);
|
||||
setPendingClearConversationSessionId(null);
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{pendingClearConversation?.title
|
||||
? `"${pendingClearConversation.title}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.`
|
||||
: '채팅방 이름과 설정은 유지하고, 메시지·요청·활동 로그만 초기화됩니다.'}
|
||||
</Text>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(pendingDeleteConversation)}
|
||||
title="대화방을 삭제할까요?"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { fetchPlanItems } from '../../features/planBoard/api';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
|
||||
import {
|
||||
cancelServerRestartReservation,
|
||||
@@ -819,6 +820,32 @@ function getServerRestartReservationOverlayState(
|
||||
}
|
||||
|
||||
if (reservation.status === 'executing') {
|
||||
if (reservation.target === 'work-server') {
|
||||
return {
|
||||
title: 'WORK 서버 재기동 중',
|
||||
statusText: '새 런타임 반영 중',
|
||||
detail: reservation.waitingReason?.trim() || 'WORK 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
|
||||
steps: [
|
||||
{ label: '재기동 요청', status: 'done' },
|
||||
{ label: 'WORK 재기동', status: 'active' },
|
||||
{ label: '정상 기동 확인', status: 'pending' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (reservation.target === 'test') {
|
||||
return {
|
||||
title: 'TEST 서버 재기동 중',
|
||||
statusText: '새 런타임 반영 중',
|
||||
detail: reservation.waitingReason?.trim() || 'TEST 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
|
||||
steps: [
|
||||
{ label: '재기동 요청', status: 'done' },
|
||||
{ label: 'TEST 재기동', status: 'active' },
|
||||
{ label: '정상 기동 확인', status: 'pending' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const startedTimestamp = reservation.startedAt ? Date.parse(reservation.startedAt) : Number.NaN;
|
||||
const workServerRestartAt = Number.isFinite(startedTimestamp)
|
||||
? startedTimestamp + RESERVED_RESTART_WORK_SERVER_DELAY_MS
|
||||
@@ -858,6 +885,33 @@ function getServerRestartReservationOverlayState(
|
||||
};
|
||||
}
|
||||
|
||||
if (reservation.status === 'recovering') {
|
||||
const targetLabel = reservation.autoFix.targetKey ? `${reservation.autoFix.targetKey.toUpperCase()} 자동 개선` : 'Codex 자동 개선';
|
||||
const statusText =
|
||||
reservation.autoFix.status === 'failed'
|
||||
? '자동 개선 실패'
|
||||
: reservation.autoFix.status === 'completed'
|
||||
? '자동 개선 완료'
|
||||
: '빌드 오류 분석 및 수정 중';
|
||||
const detail =
|
||||
reservation.autoFix.detail?.trim()
|
||||
|| reservation.autoFix.summary?.trim()
|
||||
|| reservation.waitingReason?.trim()
|
||||
|| '재기동을 막는 빌드 오류를 Codex가 자동으로 수정한 뒤 재기동을 다시 시도합니다.';
|
||||
|
||||
return {
|
||||
title: targetLabel,
|
||||
statusText,
|
||||
detail,
|
||||
steps: [
|
||||
{ label: reservation.target === 'all' ? '예약 확인' : '재기동 요청', status: 'done' },
|
||||
{ label: '빌드 실패 감지', status: 'done' },
|
||||
{ label: 'Codex 자동 개선', status: reservation.autoFix.status === 'completed' ? 'done' : 'active' },
|
||||
{ label: '재기동 재시도', status: reservation.autoFix.status === 'completed' ? 'active' : 'pending' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (reservation.status === 'completed' && reloadPending) {
|
||||
return {
|
||||
title: '재기동 완료 처리 중',
|
||||
@@ -954,7 +1008,7 @@ function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: Serve
|
||||
|
||||
function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) {
|
||||
if (!previous || !next) {
|
||||
return Boolean(next) && !next.buildRequired && !next.updateAvailable;
|
||||
return next ? !next.buildRequired && !next.updateAvailable : false;
|
||||
}
|
||||
|
||||
const previousNeedsUpdate = previous.buildRequired || previous.updateAvailable;
|
||||
@@ -1410,27 +1464,6 @@ function waitForDuration(durationMs: number) {
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
export function MainHeader({
|
||||
activeTopMenu,
|
||||
sidebarCollapsed,
|
||||
@@ -1571,6 +1604,8 @@ export function MainHeader({
|
||||
? '대기 중'
|
||||
: serverRestartReservation.status === 'ready'
|
||||
? '자동 실행 대기'
|
||||
: serverRestartReservation.status === 'recovering'
|
||||
? 'Codex 자동 개선 중'
|
||||
: serverRestartReservation.status === 'executing'
|
||||
? '실행 중'
|
||||
: serverRestartReservation.status === 'completed'
|
||||
@@ -1599,6 +1634,10 @@ export function MainHeader({
|
||||
? serverRestartReservationPendingSummary
|
||||
: serverRestartReservation?.status === 'ready'
|
||||
? serverRestartReservationPendingSummary
|
||||
: serverRestartReservation?.status === 'recovering'
|
||||
? serverRestartReservation.autoFix.summary
|
||||
?? serverRestartReservation.waitingReason
|
||||
?? '빌드 오류 자동 개선을 진행 중입니다.'
|
||||
: serverRestartReservation?.status === 'executing'
|
||||
? serverRestartReservationTimingLabel
|
||||
: serverRestartReservation?.status === 'completed'
|
||||
@@ -3050,9 +3089,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(feedback.message)
|
||||
void copyTextToClipboard(feedback.message)
|
||||
.then(() => {
|
||||
setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
@@ -3247,9 +3289,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(appConfigFeedback.message)
|
||||
void copyTextToClipboard(appConfigFeedback.message)
|
||||
.then(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setAppConfigCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
@@ -4008,9 +4053,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(appConfigFeedback.message)
|
||||
void copyTextToClipboard(appConfigFeedback.message)
|
||||
.then(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setAppConfigCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
@@ -4090,9 +4138,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(appConfigFeedback.message)
|
||||
void copyTextToClipboard(appConfigFeedback.message)
|
||||
.then(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setAppConfigCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
|
||||
668
src/app/main/ManagementPage.shared.css
Executable file
668
src/app/main/ManagementPage.shared.css
Executable file
@@ -0,0 +1,668 @@
|
||||
.chat-type-management-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px 14px 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__list .ant-list {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-scroll > .ant-list + .ant-list,
|
||||
.chat-type-management-page__list-scroll > .ant-empty {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form .ant-form-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header .ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item--active {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-options {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option-copy {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-field {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__field-label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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, 1.55fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid--maximized {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: min(720px, calc(100dvh - 236px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-input-textarea,
|
||||
.chat-type-management-page__markdown-pane .ant-input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
overflow: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||
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__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card--pane-maximized .ant-card-body {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.chat-type-management-page,
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card,
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor,
|
||||
.chat-type-management-page__editor-form,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
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;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra,
|
||||
.chat-type-management-page .ant-card-body {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.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) auto;
|
||||
gap: 6px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
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__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:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: clamp(220px, calc(100dvh - 560px), 320px);
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
flex: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: auto !important;
|
||||
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized {
|
||||
height: calc(100dvh - 52px);
|
||||
max-height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head-title,
|
||||
.chat-type-management-page--pane-maximized .ant-card-extra {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-body {
|
||||
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
|
||||
.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__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
gap: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.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,8 +1,10 @@
|
||||
.resource-management-page {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(320px, 1fr) minmax(360px, 1.08fr);
|
||||
grid-template-columns: minmax(220px, 0.82fr) minmax(280px, 1fr) minmax(300px, 1.08fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -17,6 +19,9 @@
|
||||
.resource-management-page__sidebar,
|
||||
.resource-management-page__content,
|
||||
.resource-management-page__preview-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 22px;
|
||||
overflow: clip;
|
||||
@@ -39,6 +44,19 @@
|
||||
.resource-management-page__content .ant-card-head,
|
||||
.resource-management-page__preview-card .ant-card-head {
|
||||
min-height: 58px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__sidebar .ant-card-head-wrapper,
|
||||
.resource-management-page__content .ant-card-head-wrapper,
|
||||
.resource-management-page__preview-card .ant-card-head-wrapper,
|
||||
.resource-management-page__sidebar .ant-card-head-title,
|
||||
.resource-management-page__content .ant-card-head-title,
|
||||
.resource-management-page__preview-card .ant-card-head-title,
|
||||
.resource-management-page__sidebar .ant-card-extra,
|
||||
.resource-management-page__content .ant-card-extra,
|
||||
.resource-management-page__preview-card .ant-card-extra {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__sidebar .ant-card-body,
|
||||
@@ -53,6 +71,29 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.resource-management-page__card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__card-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__card-title-subtitle {
|
||||
overflow: hidden;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__scope-copy {
|
||||
display: block;
|
||||
}
|
||||
@@ -62,6 +103,7 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
||||
@@ -77,12 +119,18 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.resource-management-page__tree-title .ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__content {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -105,6 +153,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-main,
|
||||
@@ -115,6 +164,25 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-main {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-actions {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-path {
|
||||
min-width: min(100%, 320px);
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-path .ant-typography {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__guide {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -138,7 +206,7 @@
|
||||
.resource-management-page__list-header,
|
||||
.resource-management-page__list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 160px 88px 76px;
|
||||
grid-template-columns: minmax(0, 1.8fr) minmax(116px, 0.95fr) minmax(72px, 0.5fr) 72px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -155,6 +223,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.resource-management-page__list-row {
|
||||
@@ -184,13 +253,22 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__list-meta {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.resource-management-page__list-meta > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__entry-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -206,6 +284,96 @@
|
||||
padding-bottom: 17px;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-title-file {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta .ant-typography {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta .ant-typography-copy {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-nav {
|
||||
margin-bottom: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-nav-wrap,
|
||||
.resource-management-page__preview-tabs .ant-tabs-nav-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-content-holder,
|
||||
.resource-management-page__preview-tabs .ant-tabs-content,
|
||||
.resource-management-page__preview-tabs .ant-tabs-tabpane {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-tabpane-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resource-management-page__tab-panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__editor-panel {
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.resource-management-page__editor.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100% !important;
|
||||
padding-bottom: max(16px, calc(env(safe-area-inset-bottom, 0px) + 10px));
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.resource-management-page__editor-actions {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-frame {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
@@ -217,12 +385,36 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.resource-management-page__html-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__html-preview > .resource-management-page__rich-preview {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch .ant-btn {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.resource-management-page__text-preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
@@ -236,6 +428,124 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px #d9e1f2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview p,
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview li,
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview a,
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview code {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--code {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--code .previewer-ui__editor,
|
||||
.resource-management-page__rich-preview--code .codex-diff-previewer {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--code .previewer-ui__editor-body,
|
||||
.resource-management-page__rich-preview--code .codex-diff-previewer__diff-body,
|
||||
.resource-management-page__rich-preview--code .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-scroll {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid th,
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid tbody tr:nth-child(even) td {
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__image-preview {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
@@ -447,13 +757,13 @@
|
||||
.resource-management-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.resource-management-page--mobile {
|
||||
min-height: 0;
|
||||
padding-inline: 1px;
|
||||
padding-bottom: max(10px, calc(env(safe-area-inset-bottom, 0px) + 6px));
|
||||
padding-inline: 0;
|
||||
padding-bottom: max(6px, calc(env(safe-area-inset-bottom, 0px) + 2px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -464,29 +774,85 @@
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
padding: 6px;
|
||||
border-radius: 18px;
|
||||
background: rgba(244, 247, 252, 0.96);
|
||||
box-shadow: inset 0 0 0 1px rgba(217, 225, 242, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-card > .ant-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
border-radius: 20px;
|
||||
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 10px 8px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: #52607a;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button:disabled {
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button--active {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(186, 209, 255, 0.92),
|
||||
0 6px 18px rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__sidebar .ant-card-body,
|
||||
.resource-management-page__content .ant-card-body,
|
||||
.resource-management-page__preview-card .ant-card-body {
|
||||
padding: 14px;
|
||||
padding-bottom: 15px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.resource-management-page__list-header {
|
||||
@@ -527,6 +893,20 @@
|
||||
min-width: min(208px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.resource-management-page__tree {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__tree .ant-tree-treenode,
|
||||
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__tree .ant-tree-switcher {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -536,14 +916,40 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-path {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-actions .ant-btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta .ant-typography,
|
||||
.resource-management-page__preview-meta .ant-space-compact {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-frame,
|
||||
.resource-management-page__image-preview,
|
||||
.resource-management-page__text-preview {
|
||||
min-height: 220px;
|
||||
.resource-management-page__text-preview,
|
||||
.resource-management-page__rich-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch .ant-btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.resource-management-page__editor {
|
||||
@@ -561,7 +967,7 @@
|
||||
|
||||
.resource-management-page__preview-card .ant-tabs-content-holder {
|
||||
overflow: hidden;
|
||||
padding-bottom: 1px;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -570,10 +976,26 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resource-management-page__editor-panel {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
|
||||
}
|
||||
|
||||
.resource-management-page__editor-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-management-page--panel-preview .resource-management-page__preview-card .ant-card-body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-management-page--panel-tree .resource-management-page__sidebar,
|
||||
.resource-management-page--panel-list .resource-management-page__content,
|
||||
.resource-management-page--panel-preview .resource-management-page__preview-card {
|
||||
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
|
||||
}
|
||||
|
||||
.resource-management-page__preview-modal-wrap--mobile .ant-modal {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
|
||||
@@ -26,10 +26,16 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { Key } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview/MarkdownPreviewContent';
|
||||
import { CodexDiffBlock } from '../../components/previewer';
|
||||
import { inferCodeLanguage, renderEditorBlock } from '../../components/previewer/renderers';
|
||||
import '../../components/previewer/PreviewerUI.css';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
copyResourceManagerItem,
|
||||
createResourceManagerDirectory,
|
||||
@@ -47,6 +53,7 @@ import {
|
||||
type ResourceManagerTreeNode,
|
||||
type ResourceManagerTreeRoot,
|
||||
} from './resourceManagerApi';
|
||||
import { ChatDataTablePreview, resolveTabularPreviewModel } from './mainChatPanel/ChatDataTablePreview';
|
||||
import './ResourceManagementPage.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
@@ -81,6 +88,8 @@ type PreviewOffset = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
type HtmlPreviewMode = 'browser' | 'source';
|
||||
|
||||
type CreateEntryModalState =
|
||||
| {
|
||||
type: 'file' | 'folder';
|
||||
@@ -150,6 +159,10 @@ function isPdfFile(file: ResourceManagerFileDetail | null) {
|
||||
return file?.mimeType === 'application/pdf';
|
||||
}
|
||||
|
||||
function normalizeFileExtension(file: ResourceManagerFileDetail | null) {
|
||||
return file?.extension?.replace(/^\./, '').trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
||||
if (!file) {
|
||||
return false;
|
||||
@@ -158,12 +171,121 @@ function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
||||
return file.mimeType.includes('html') || file.extension === '.html' || file.extension === '.htm';
|
||||
}
|
||||
|
||||
function isTextPreviewFile(file: ResourceManagerFileDetail | null) {
|
||||
function buildHtmlPreviewDocument(file: ResourceManagerFileDetail) {
|
||||
const content = file.content ?? '';
|
||||
|
||||
if (!content.trim()) {
|
||||
return '<!doctype html><html><body></body></html>';
|
||||
}
|
||||
|
||||
const baseHref = file.previewUrl.replace(/"/g, '"');
|
||||
const baseTag = `<base href="${baseHref}">`;
|
||||
|
||||
if (/<head[\s>]/i.test(content)) {
|
||||
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${baseTag}`);
|
||||
}
|
||||
|
||||
if (/<html[\s>]/i.test(content)) {
|
||||
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${baseTag}</head>`);
|
||||
}
|
||||
|
||||
return `<!doctype html><html><head>${baseTag}</head><body>${content}</body></html>`;
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: ResourceManagerFileDetail | null) {
|
||||
const extension = normalizeFileExtension(file);
|
||||
return extension === 'md' || extension === 'markdown';
|
||||
}
|
||||
|
||||
function isDiffFile(file: ResourceManagerFileDetail | null) {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return file.isTextEditable && !isHtmlFile(file);
|
||||
const extension = normalizeFileExtension(file);
|
||||
if (extension === 'diff' || extension === 'patch') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = file.content ?? '';
|
||||
return /^(diff --git|@@\s|---\s|\+\+\+\s)/m.test(content);
|
||||
}
|
||||
|
||||
function resolveCodeLanguageForResource(file: ResourceManagerFileDetail) {
|
||||
const extension = normalizeFileExtension(file);
|
||||
|
||||
if (extension === 'tsx' || extension === 'ts') {
|
||||
return 'typescript';
|
||||
}
|
||||
|
||||
if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
if (extension === 'json') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (extension === 'css') {
|
||||
return 'css';
|
||||
}
|
||||
|
||||
if (extension === 'scss') {
|
||||
return 'scss';
|
||||
}
|
||||
|
||||
if (extension === 'html' || extension === 'htm') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (extension === 'java') {
|
||||
return 'java';
|
||||
}
|
||||
|
||||
if (extension === 'kt') {
|
||||
return 'kotlin';
|
||||
}
|
||||
|
||||
if (extension === 'py') {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (extension === 'go') {
|
||||
return 'go';
|
||||
}
|
||||
|
||||
if (extension === 'rs') {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
if (extension === 'sql') {
|
||||
return 'sql';
|
||||
}
|
||||
|
||||
if (
|
||||
extension === 'sh' ||
|
||||
extension === 'bash' ||
|
||||
extension === 'zsh' ||
|
||||
extension === 'env' ||
|
||||
extension === 'ini' ||
|
||||
extension === 'conf'
|
||||
) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
if (extension === 'yml' || extension === 'yaml') {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
if (extension === 'xml') {
|
||||
return 'xml';
|
||||
}
|
||||
|
||||
if (extension === 'svg') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
return inferCodeLanguage(extension || 'text');
|
||||
}
|
||||
|
||||
function isNativeContextMenuBypassTarget(target: EventTarget | null) {
|
||||
@@ -317,6 +439,15 @@ function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: stri
|
||||
return visit(treeRoot.tree);
|
||||
}
|
||||
|
||||
function renderCardTitle(title: string, subtitle?: ReactNode) {
|
||||
return (
|
||||
<div className="resource-management-page__card-title">
|
||||
<span className="resource-management-page__card-title-text">{title}</span>
|
||||
{subtitle ? <span className="resource-management-page__card-title-subtitle">{subtitle}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceManagementPage() {
|
||||
const { message } = App.useApp();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -335,6 +466,7 @@ export function ResourceManagementPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<ResourceManagerFileDetail | null>(null);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [activePreviewTab, setActivePreviewTab] = useState('preview');
|
||||
const [htmlPreviewMode, setHtmlPreviewMode] = useState<HtmlPreviewMode>('browser');
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState<MobilePanel>('list');
|
||||
@@ -470,6 +602,7 @@ export function ResourceManagementPage() {
|
||||
};
|
||||
|
||||
const directoryTargets = useMemo(() => collectDirectoryTargets(treeRoot), [treeRoot]);
|
||||
const currentDirectoryLabel = formatPathLabel(selectedDirectoryPath);
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -518,6 +651,7 @@ export function ResourceManagementPage() {
|
||||
setPreviewZoom(1);
|
||||
setPreviewOffset({ x: 0, y: 0 });
|
||||
previewTouchGestureRef.current = null;
|
||||
setHtmlPreviewMode('browser');
|
||||
}, [selectedFile?.path, isPreviewMaximized]);
|
||||
|
||||
const cancelLongPress = () => {
|
||||
@@ -1006,7 +1140,7 @@ export function ResourceManagementPage() {
|
||||
};
|
||||
|
||||
const handleContextMenuAction = async (
|
||||
action: 'open' | 'copy' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
action: 'open' | 'copy' | 'copy-path' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
) => {
|
||||
const target = contextMenu.target;
|
||||
setContextMenu((current) => ({ ...current, open: false, target: null }));
|
||||
@@ -1049,6 +1183,16 @@ export function ResourceManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'copy-path') {
|
||||
try {
|
||||
await copyTextToClipboard(formatPathLabel(target.entry.path));
|
||||
message.success('경로를 복사했습니다.');
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '경로 복사에 실패했습니다.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'move') {
|
||||
openCopyMoveModal('move', target.entry);
|
||||
return;
|
||||
@@ -1065,7 +1209,7 @@ export function ResourceManagementPage() {
|
||||
};
|
||||
|
||||
const bindContextMenuAction = (
|
||||
action: 'open' | 'copy' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
action: 'open' | 'copy' | 'copy-path' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
) => ({
|
||||
onPointerDown: (event: ReactMouseEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -1074,13 +1218,17 @@ export function ResourceManagementPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const canZoomPreview = activePreviewTab === 'preview' && (isImageFile(selectedFile) || isHtmlFile(selectedFile));
|
||||
const canZoomPreview =
|
||||
activePreviewTab === 'preview' &&
|
||||
(isImageFile(selectedFile) || (isHtmlFile(selectedFile) && htmlPreviewMode === 'browser'));
|
||||
|
||||
const renderPreviewContent = (maximized = false) => {
|
||||
if (!selectedFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = selectedFile.content ?? '';
|
||||
|
||||
if (isImageFile(selectedFile)) {
|
||||
if (!maximized) {
|
||||
return <img alt={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__image-preview" />;
|
||||
@@ -1109,12 +1257,17 @@ export function ResourceManagementPage() {
|
||||
return <iframe title={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__preview-frame" />;
|
||||
}
|
||||
|
||||
if (isTextPreviewFile(selectedFile)) {
|
||||
return <pre className="resource-management-page__text-preview">{selectedFile.content ?? ''}</pre>;
|
||||
if (isMarkdownFile(selectedFile)) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--markdown">
|
||||
<MarkdownPreviewContent content={fileContent || '# Preview\n\n표시할 markdown 본문이 없습니다.'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHtmlFile(selectedFile) && maximized) {
|
||||
return (
|
||||
if (isHtmlFile(selectedFile)) {
|
||||
const previewDocument = buildHtmlPreviewDocument(selectedFile);
|
||||
const browserPreview = maximized ? (
|
||||
<div
|
||||
ref={previewShellRef}
|
||||
className="resource-management-page__zoom-shell resource-management-page__zoom-shell--frame resource-management-page__zoom-shell--touch-zoom"
|
||||
@@ -1129,12 +1282,61 @@ export function ResourceManagementPage() {
|
||||
>
|
||||
<iframe
|
||||
title={selectedFile.name}
|
||||
src={selectedFile.previewUrl}
|
||||
srcDoc={previewDocument}
|
||||
className="resource-management-page__preview-frame resource-management-page__preview-frame--zoomable"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<iframe title={selectedFile.name} srcDoc={previewDocument} className="resource-management-page__preview-frame" />
|
||||
);
|
||||
|
||||
const sourcePreview = (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||
{renderEditorBlock(fileContent || '표시할 preview 본문이 없습니다.', resolveCodeLanguageForResource(selectedFile), 'code')}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className="resource-management-page__html-preview">{htmlPreviewMode === 'browser' ? browserPreview : sourcePreview}</div>;
|
||||
}
|
||||
|
||||
if (selectedFile.isTextEditable) {
|
||||
const tabularModel = resolveTabularPreviewModel(
|
||||
{
|
||||
label: selectedFile.name,
|
||||
url: selectedFile.previewUrl,
|
||||
kind: 'document',
|
||||
},
|
||||
fileContent,
|
||||
);
|
||||
|
||||
if (tabularModel) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--table">
|
||||
<ChatDataTablePreview model={tabularModel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDiffFile(selectedFile)) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||
<CodexDiffBlock
|
||||
diffText={fileContent}
|
||||
summary={`${selectedFile.name} 기준 raw diff preview입니다.`}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFile.isTextEditable) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||
{renderEditorBlock(fileContent || '표시할 preview 본문이 없습니다.', resolveCodeLanguageForResource(selectedFile), 'code')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1151,7 +1353,7 @@ export function ResourceManagementPage() {
|
||||
<span>미리보기</span>
|
||||
</Space>
|
||||
),
|
||||
children: renderPreviewContent(),
|
||||
children: <div className="resource-management-page__tab-panel">{renderPreviewContent()}</div>,
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
@@ -1162,22 +1364,21 @@ export function ResourceManagementPage() {
|
||||
</Space>
|
||||
),
|
||||
children: selectedFile.isTextEditable ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div className="resource-management-page__tab-panel resource-management-page__editor-panel">
|
||||
<TextArea
|
||||
value={editorContent}
|
||||
onChange={(event) => {
|
||||
setEditorContent(event.target.value);
|
||||
}}
|
||||
className="resource-management-page__editor"
|
||||
autoSize={{ minRows: 14, maxRows: 22 }}
|
||||
/>
|
||||
<Space>
|
||||
<Space className="resource-management-page__editor-actions">
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSaveFile()}>
|
||||
저장
|
||||
</Button>
|
||||
<Text type="secondary">{selectedFile.mimeType}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Alert showIcon type="warning" message="이 파일은 텍스트 편집을 지원하지 않습니다." />
|
||||
),
|
||||
@@ -1276,7 +1477,7 @@ export function ResourceManagementPage() {
|
||||
}, [treeRoot]);
|
||||
|
||||
const renderTreeCard = () => (
|
||||
<Card title="리소스 트리" className="resource-management-page__sidebar">
|
||||
<Card title={renderCardTitle('리소스 트리', 'resource/ 루트')} className="resource-management-page__sidebar">
|
||||
<Text type="secondary" className="resource-management-page__scope-copy">
|
||||
서버별 분리 없이 같은 `resource/` 루트를 트리와 탐색기에서 함께 봅니다.
|
||||
</Text>
|
||||
@@ -1300,7 +1501,7 @@ export function ResourceManagementPage() {
|
||||
|
||||
const renderListCard = () => (
|
||||
<Card
|
||||
title="폴더 목록"
|
||||
title={renderCardTitle('폴더 목록', `${directoryItems.length}개 항목`)}
|
||||
className="resource-management-page__content"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refreshAll(selectedDirectoryPath, false)}>
|
||||
@@ -1311,7 +1512,11 @@ export function ResourceManagementPage() {
|
||||
<div className="resource-management-page__workspace">
|
||||
<div className="resource-management-page__toolbar">
|
||||
<div className="resource-management-page__toolbar-main">
|
||||
<Text strong>{formatPathLabel(selectedDirectoryPath)}</Text>
|
||||
<div className="resource-management-page__toolbar-path">
|
||||
<Text strong ellipsis={{ tooltip: currentDirectoryLabel }}>
|
||||
{currentDirectoryLabel}
|
||||
</Text>
|
||||
</div>
|
||||
{selectedDirectoryPath ? (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -1481,7 +1686,18 @@ export function ResourceManagementPage() {
|
||||
|
||||
const renderPreviewCard = () => (
|
||||
<Card
|
||||
title={selectedFile ? `미리보기 / 편집 - ${selectedFile.name}` : '미리보기 / 편집'}
|
||||
title={
|
||||
renderCardTitle(
|
||||
'미리보기 / 편집',
|
||||
selectedFile ? (
|
||||
<span className="resource-management-page__preview-title-file" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
) : (
|
||||
'선택한 파일 없음'
|
||||
),
|
||||
)
|
||||
}
|
||||
className="resource-management-page__preview-card"
|
||||
extra={
|
||||
selectedFile && activePreviewTab === 'preview' ? (
|
||||
@@ -1504,11 +1720,36 @@ export function ResourceManagementPage() {
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<>
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text type="secondary">{formatPathLabel(selectedFile.path)}</Text>
|
||||
<Space size={[8, 8]} wrap className="resource-management-page__preview-meta">
|
||||
<Text type="secondary" ellipsis={{ tooltip: formatPathLabel(selectedFile.path) }}>
|
||||
{formatPathLabel(selectedFile.path)}
|
||||
</Text>
|
||||
<Text copyable={{ text: selectedFile.previewUrl }}>미리보기 URL 복사</Text>
|
||||
{isHtmlFile(selectedFile) ? (
|
||||
<Space.Compact className="resource-management-page__html-mode-switch">
|
||||
<Button
|
||||
size="small"
|
||||
type={htmlPreviewMode === 'browser' ? 'primary' : 'default'}
|
||||
onClick={() => setHtmlPreviewMode('browser')}
|
||||
>
|
||||
브라우저
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type={htmlPreviewMode === 'source' ? 'primary' : 'default'}
|
||||
onClick={() => setHtmlPreviewMode('source')}
|
||||
>
|
||||
소스
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
) : null}
|
||||
</Space>
|
||||
<Tabs activeKey={activePreviewTab} onChange={setActivePreviewTab} items={previewTabItems} />
|
||||
<Tabs
|
||||
className="resource-management-page__preview-tabs"
|
||||
activeKey={activePreviewTab}
|
||||
onChange={setActivePreviewTab}
|
||||
items={previewTabItems}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
@@ -1519,6 +1760,35 @@ export function ResourceManagementPage() {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderMobileNavButton = (panel: MobilePanel, label: string, icon: ReactNode, disabled = false) => {
|
||||
const isActive = mobilePanel === panel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'resource-management-page__mobile-nav-button',
|
||||
isActive ? 'resource-management-page__mobile-nav-button--active' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setMobilePanel(panel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="resource-management-page__mobile-nav-button-icon">{icon}</span>
|
||||
<span className="resource-management-page__mobile-nav-button-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -1535,7 +1805,7 @@ export function ResourceManagementPage() {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onSelectStartCapture={preventSelection}
|
||||
onMouseDownCapture={preventSelection}
|
||||
onDragStartCapture={preventSelection}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.pointerType === 'touch' && !isNativeContextMenuBypassTarget(event.target)) {
|
||||
@@ -1545,20 +1815,10 @@ export function ResourceManagementPage() {
|
||||
>
|
||||
{isMobileViewport ? (
|
||||
<>
|
||||
<div className="resource-management-page__mobile-nav">
|
||||
<Button type={mobilePanel === 'tree' ? 'primary' : 'default'} onClick={() => setMobilePanel('tree')}>
|
||||
트리
|
||||
</Button>
|
||||
<Button type={mobilePanel === 'list' ? 'primary' : 'default'} onClick={() => setMobilePanel('list')}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
type={mobilePanel === 'preview' ? 'primary' : 'default'}
|
||||
disabled={!selectedFile}
|
||||
onClick={() => setMobilePanel('preview')}
|
||||
>
|
||||
미리보기
|
||||
</Button>
|
||||
<div className="resource-management-page__mobile-nav" role="tablist" aria-label="리소스 보기 전환">
|
||||
{renderMobileNavButton('tree', '트리', <FolderOpenOutlined />)}
|
||||
{renderMobileNavButton('list', '목록', <FileTextOutlined />)}
|
||||
{renderMobileNavButton('preview', '미리보기', <EyeOutlined />, !selectedFile)}
|
||||
</div>
|
||||
<div className="resource-management-page__mobile-card">
|
||||
{mobilePanel === 'tree' ? renderTreeCard() : null}
|
||||
@@ -1600,6 +1860,12 @@ export function ResourceManagementPage() {
|
||||
<CopyOutlined />
|
||||
<span>복사</span>
|
||||
</button>
|
||||
{contextMenu.target.entry.type === 'directory' ? (
|
||||
<button type="button" {...bindContextMenuAction('copy-path')}>
|
||||
<CopyOutlined />
|
||||
<span>경로 복사</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" {...bindContextMenuAction('move')}>
|
||||
<ScissorOutlined />
|
||||
<span>이동</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
export type ChatDefaultContextRecord = {
|
||||
@@ -29,30 +29,12 @@ type ChatContextSettingsStore = {
|
||||
roomContexts: ChatRoomContextSettings[];
|
||||
};
|
||||
|
||||
const CHAT_CONTEXT_SETTINGS_STORAGE_KEY = 'work-app:chat-context-settings';
|
||||
export type ChatContextSettingsStoreSource = 'server' | 'optimistic';
|
||||
|
||||
const CHAT_CONTEXT_SETTINGS_SYNC_EVENT = 'work-app:chat-context-settings-changed';
|
||||
const CHAT_CONTEXT_SETTINGS_API_PATH = '/chat-context-settings';
|
||||
const CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
|
||||
{
|
||||
id: 'chat-default-mobile-verification',
|
||||
title: '모바일 검증',
|
||||
content:
|
||||
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'chat-default-resource-output',
|
||||
title: '리소스 출력',
|
||||
content:
|
||||
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
@@ -94,7 +76,7 @@ function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefin
|
||||
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, ChatDefaultContextRecord>();
|
||||
|
||||
[...(items ?? []), ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
|
||||
(items ?? [])
|
||||
.map((item) => normalizeDefaultContext(item))
|
||||
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
@@ -176,23 +158,7 @@ function sanitizeStore(input: Partial<ChatContextSettingsStore> | null | undefin
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
if (typeof window === 'undefined') {
|
||||
return sanitizeStore(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return sanitizeStore(null);
|
||||
}
|
||||
|
||||
return sanitizeStore(JSON.parse(raw) as Partial<ChatContextSettingsStore>);
|
||||
} catch {
|
||||
return sanitizeStore(null);
|
||||
}
|
||||
}
|
||||
const EMPTY_CHAT_CONTEXT_SETTINGS_STORE = sanitizeStore(null);
|
||||
|
||||
function emitStoreChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -202,17 +168,6 @@ function emitStoreChange() {
|
||||
window.dispatchEvent(new Event(CHAT_CONTEXT_SETTINGS_SYNC_EVENT));
|
||||
}
|
||||
|
||||
function saveStore(store: ChatContextSettingsStore) {
|
||||
if (typeof window === 'undefined') {
|
||||
return store;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeStore(store);
|
||||
window.localStorage.setItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY, JSON.stringify(sanitized));
|
||||
emitStoreChange();
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function resolveChatContextSettingsApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
@@ -247,19 +202,33 @@ const CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL = resolveChatContextSettingsFallba
|
||||
async function requestChatContextSettingsOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const normalizedMethod = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS);
|
||||
|
||||
// 기본 유형 설정은 origin별 분기 없이 전역 설정을 source of truth로 사용한다.
|
||||
headers.delete('X-App-Origin');
|
||||
headers.delete('X-App-Domain');
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
headers.set('Cache-Control', 'no-cache, no-store, max-age=0');
|
||||
headers.set('Pragma', 'no-cache');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`, {
|
||||
const requestUrl =
|
||||
normalizedMethod === 'GET'
|
||||
? `${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}?__ts=${Date.now()}`
|
||||
: `${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`;
|
||||
const response = await fetch(requestUrl, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
||||
cache: init?.cache ?? (normalizedMethod === 'GET' ? 'reload' : undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -315,6 +284,132 @@ async function saveStoreToServer(store: ChatContextSettingsStore) {
|
||||
return sanitizeStore(response.settings ?? sanitized);
|
||||
}
|
||||
|
||||
type ChatContextSettingsRegistrySnapshot = ChatContextSettingsStore & {
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
lastLoadedAt: string | null;
|
||||
lastFailedAt: string | null;
|
||||
hasLoadedFromServer: boolean;
|
||||
storeSource: ChatContextSettingsStoreSource;
|
||||
};
|
||||
|
||||
const EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT: ChatContextSettingsRegistrySnapshot = {
|
||||
...EMPTY_CHAT_CONTEXT_SETTINGS_STORE,
|
||||
isLoading: false,
|
||||
errorMessage: '',
|
||||
lastLoadedAt: null,
|
||||
lastFailedAt: null,
|
||||
hasLoadedFromServer: false,
|
||||
storeSource: 'server',
|
||||
};
|
||||
|
||||
let chatContextSettingsRegistrySnapshot = EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT;
|
||||
let chatContextSettingsRegistryRequestSequence = 0;
|
||||
let chatContextSettingsRegistryWindowEventsBound = false;
|
||||
const chatContextSettingsRegistryListeners = new Set<() => void>();
|
||||
|
||||
function emitChatContextSettingsRegistryChange() {
|
||||
chatContextSettingsRegistryListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
function getChatContextSettingsRegistrySnapshot() {
|
||||
return chatContextSettingsRegistrySnapshot;
|
||||
}
|
||||
|
||||
function setChatContextSettingsRegistrySnapshot(
|
||||
updater:
|
||||
| ChatContextSettingsRegistrySnapshot
|
||||
| ((current: ChatContextSettingsRegistrySnapshot) => ChatContextSettingsRegistrySnapshot),
|
||||
) {
|
||||
const nextSnapshot = typeof updater === 'function' ? updater(chatContextSettingsRegistrySnapshot) : updater;
|
||||
|
||||
chatContextSettingsRegistrySnapshot = nextSnapshot;
|
||||
emitChatContextSettingsRegistryChange();
|
||||
}
|
||||
|
||||
function subscribeChatContextSettingsRegistry(listener: () => void) {
|
||||
chatContextSettingsRegistryListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
chatContextSettingsRegistryListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadChatContextSettingsRegistryFromServer() {
|
||||
const requestSequence = chatContextSettingsRegistryRequestSequence + 1;
|
||||
chatContextSettingsRegistryRequestSequence = requestSequence;
|
||||
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const serverStore = await fetchStoreFromServer();
|
||||
const saved = sanitizeStore(serverStore);
|
||||
|
||||
if (chatContextSettingsRegistryRequestSequence === requestSequence) {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
...saved,
|
||||
isLoading: false,
|
||||
errorMessage: '',
|
||||
lastLoadedAt: new Date().toISOString(),
|
||||
lastFailedAt: null,
|
||||
hasLoadedFromServer: true,
|
||||
storeSource: 'server',
|
||||
}));
|
||||
}
|
||||
|
||||
return saved;
|
||||
} catch (error) {
|
||||
if (chatContextSettingsRegistryRequestSequence === requestSequence) {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...(current.lastLoadedAt ? current : EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT),
|
||||
isLoading: false,
|
||||
errorMessage: error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.',
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
hasLoadedFromServer: current.lastLoadedAt !== null,
|
||||
storeSource: current.lastLoadedAt ? current.storeSource : 'server',
|
||||
}));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureChatContextSettingsRegistryWindowEvents() {
|
||||
if (chatContextSettingsRegistryWindowEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
};
|
||||
const handleWindowFocus = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('pageshow', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
chatContextSettingsRegistryWindowEventsBound = true;
|
||||
}
|
||||
|
||||
export function resolveChatTypeDefaultContextIds(
|
||||
selections: ChatTypeDefaultContextSelection[],
|
||||
chatTypeId: string | null | undefined,
|
||||
@@ -452,70 +547,71 @@ export function pruneChatRoomContextSettings(roomContexts: ChatRoomContextSettin
|
||||
}
|
||||
|
||||
export function useChatContextSettingsRegistry() {
|
||||
const [store, setStoreState] = useState<ChatContextSettingsStore>(() => loadStore());
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const isMountedRef = useRef(true);
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeChatContextSettingsRegistry,
|
||||
getChatContextSettingsRegistrySnapshot,
|
||||
getChatContextSettingsRegistrySnapshot,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const syncStore = async () => {
|
||||
try {
|
||||
const serverStore = await fetchStoreFromServer();
|
||||
const saved = saveStore(serverStore);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(saved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
} catch (error) {
|
||||
const localStore = loadStore();
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(localStore);
|
||||
setErrorMessage(error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncStore();
|
||||
const handleSync = () => {
|
||||
void syncStore();
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||
window.addEventListener('storage', syncStore);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
window.removeEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||
window.removeEventListener('storage', syncStore);
|
||||
};
|
||||
ensureChatContextSettingsRegistryWindowEvents();
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...store,
|
||||
errorMessage,
|
||||
...snapshot,
|
||||
reload: reloadChatContextSettingsRegistryFromServer,
|
||||
setStore: (
|
||||
updater:
|
||||
| ChatContextSettingsStore
|
||||
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
|
||||
) => {
|
||||
const nextStore = typeof updater === 'function' ? updater(loadStore()) : updater;
|
||||
const saved = saveStore(nextStore);
|
||||
const currentStore: ChatContextSettingsStore = {
|
||||
defaultContexts: snapshot.defaultContexts,
|
||||
chatTypeDefaults: snapshot.chatTypeDefaults,
|
||||
roomContexts: snapshot.roomContexts,
|
||||
};
|
||||
const nextStore = typeof updater === 'function' ? updater(currentStore) : updater;
|
||||
const saved = sanitizeStore(nextStore);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(saved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
...saved,
|
||||
errorMessage: '',
|
||||
lastFailedAt: null,
|
||||
storeSource: 'optimistic',
|
||||
}));
|
||||
|
||||
return saveStoreToServer(saved).then((serverSaved) => {
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(serverSaved);
|
||||
}
|
||||
return saveStoreToServer(saved)
|
||||
.then((serverSaved) => {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
...serverSaved,
|
||||
errorMessage: '',
|
||||
lastLoadedAt: new Date().toISOString(),
|
||||
lastFailedAt: null,
|
||||
hasLoadedFromServer: true,
|
||||
storeSource: 'server',
|
||||
}));
|
||||
|
||||
return serverSaved;
|
||||
});
|
||||
emitStoreChange();
|
||||
return serverSaved;
|
||||
})
|
||||
.catch(async (error) => {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
errorMessage: error instanceof Error ? error.message : '채팅 Context 설정 저장에 실패했습니다.',
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
try {
|
||||
await reloadChatContextSettingsRegistryFromServer();
|
||||
} catch {
|
||||
// 저장 실패 직후에도 서버 원본 재조회는 best-effort로 유지한다.
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ export type ChatTypeRecord = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ChatTypeRegistrySnapshot = {
|
||||
builtInChatTypes: ChatTypeRecord[];
|
||||
customChatTypes: ChatTypeRecord[];
|
||||
chatTypes: ChatTypeRecord[];
|
||||
};
|
||||
|
||||
export type ChatTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -114,6 +120,11 @@ function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null |
|
||||
return sanitizeChatTypes([...(chatTypes ?? []), ...DEFAULT_CHAT_TYPES]);
|
||||
}
|
||||
|
||||
function stripBuiltInChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null | undefined) {
|
||||
const builtInIds = new Set(DEFAULT_CHAT_TYPES.map((item) => item.id));
|
||||
return sanitizeChatTypes(chatTypes ?? []).filter((item) => !builtInIds.has(item.id));
|
||||
}
|
||||
|
||||
function emitChatTypesChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -200,26 +211,58 @@ async function requestChatTypes<T>(init?: RequestInit) {
|
||||
}
|
||||
|
||||
async function fetchChatTypesFromServer() {
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
||||
const response = await requestChatTypes<{
|
||||
ok: boolean;
|
||||
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
customChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
chatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
}>({
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.chatTypes == null) {
|
||||
return mergeWithDefaultChatTypes(DEFAULT_CHAT_TYPES);
|
||||
}
|
||||
const builtInChatTypes =
|
||||
response.builtInChatTypes != null ? sanitizeChatTypes(response.builtInChatTypes) : sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||
const customChatTypes =
|
||||
response.customChatTypes != null
|
||||
? stripBuiltInChatTypes(response.customChatTypes)
|
||||
: stripBuiltInChatTypes(response.chatTypes);
|
||||
const chatTypes =
|
||||
response.chatTypes != null ? mergeWithDefaultChatTypes(response.chatTypes) : mergeWithDefaultChatTypes(customChatTypes);
|
||||
|
||||
return mergeWithDefaultChatTypes(response.chatTypes);
|
||||
return {
|
||||
builtInChatTypes,
|
||||
customChatTypes,
|
||||
chatTypes,
|
||||
} satisfies ChatTypeRegistrySnapshot;
|
||||
}
|
||||
|
||||
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
||||
const resolved = mergeWithDefaultChatTypes(chatTypes);
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] }>({
|
||||
const customChatTypes = stripBuiltInChatTypes(chatTypes);
|
||||
const response = await requestChatTypes<{
|
||||
ok: boolean;
|
||||
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
customChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
chatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
}>({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ chatTypes: resolved }),
|
||||
body: JSON.stringify({ customChatTypes }),
|
||||
});
|
||||
|
||||
emitChatTypesChange();
|
||||
return mergeWithDefaultChatTypes(response.chatTypes);
|
||||
const builtInChatTypes =
|
||||
response.builtInChatTypes != null ? sanitizeChatTypes(response.builtInChatTypes) : sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||
const nextCustomChatTypes =
|
||||
response.customChatTypes != null
|
||||
? stripBuiltInChatTypes(response.customChatTypes)
|
||||
: stripBuiltInChatTypes(response.chatTypes);
|
||||
const nextChatTypes =
|
||||
response.chatTypes != null ? mergeWithDefaultChatTypes(response.chatTypes) : mergeWithDefaultChatTypes(nextCustomChatTypes);
|
||||
|
||||
return {
|
||||
builtInChatTypes,
|
||||
customChatTypes: nextCustomChatTypes,
|
||||
chatTypes: nextChatTypes,
|
||||
} satisfies ChatTypeRegistrySnapshot;
|
||||
}
|
||||
|
||||
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
|
||||
@@ -264,9 +307,27 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
|
||||
|
||||
export function useChatTypeRegistry() {
|
||||
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
|
||||
const [builtInChatTypes, setBuiltInChatTypesState] = useState<ChatTypeRecord[]>(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
|
||||
const [customChatTypes, setCustomChatTypesState] = useState<ChatTypeRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const isMountedRef = useRef(true);
|
||||
const syncChatTypesRef = useRef<() => Promise<ChatTypeRegistrySnapshot>>(async () => ({
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes: [],
|
||||
chatTypes: DEFAULT_CHAT_TYPES,
|
||||
}));
|
||||
|
||||
const applySnapshot = (snapshot: ChatTypeRegistrySnapshot) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBuiltInChatTypesState(snapshot.builtInChatTypes);
|
||||
setCustomChatTypesState(snapshot.customChatTypes);
|
||||
setChatTypesState(snapshot.chatTypes);
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -274,26 +335,39 @@ export function useChatTypeRegistry() {
|
||||
const syncChatTypes = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
let resolvedChatTypeSnapshot: ChatTypeRegistrySnapshot = {
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes: [],
|
||||
chatTypes: DEFAULT_CHAT_TYPES,
|
||||
};
|
||||
|
||||
try {
|
||||
const serverChatTypes = await fetchChatTypesFromServer();
|
||||
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolvedChatTypes);
|
||||
}
|
||||
const serverChatTypeSnapshot = await fetchChatTypesFromServer();
|
||||
resolvedChatTypeSnapshot = serverChatTypeSnapshot ?? resolvedChatTypeSnapshot;
|
||||
applySnapshot(resolvedChatTypeSnapshot);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
setBuiltInChatTypesState(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
|
||||
setCustomChatTypesState([]);
|
||||
setChatTypesState(DEFAULT_CHAT_TYPES);
|
||||
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
|
||||
}
|
||||
resolvedChatTypeSnapshot = {
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes: [],
|
||||
chatTypes: DEFAULT_CHAT_TYPES,
|
||||
};
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedChatTypeSnapshot;
|
||||
};
|
||||
|
||||
syncChatTypesRef.current = syncChatTypes;
|
||||
|
||||
void syncChatTypes();
|
||||
|
||||
const handleSync = () => {
|
||||
@@ -310,14 +384,15 @@ export function useChatTypeRegistry() {
|
||||
|
||||
return {
|
||||
chatTypes,
|
||||
builtInChatTypes,
|
||||
customChatTypes,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
reload: async () => syncChatTypesRef.current(),
|
||||
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
|
||||
const resolved = await saveChatTypesToServer(nextChatTypes);
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
await saveChatTypesToServer(nextChatTypes);
|
||||
const resolved = await fetchChatTypesFromServer();
|
||||
applySnapshot(resolved);
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,17 @@ export type DefaultChatTypeRecord = {
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
||||
|
||||
export const MD_CONTEXT_MANAGED_CHAT_TYPE_ID = 'md-context-managed';
|
||||
export const MD_CONTEXT_MANAGED_CHAT_TYPE_NAME = 'MD 기준 관리';
|
||||
export const MD_CONTEXT_MANAGED_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.';
|
||||
|
||||
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_ID = 'chat-maximized-bottom-safe';
|
||||
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_NAME = '채팅 최대화 하단 안전영역';
|
||||
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.';
|
||||
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
|
||||
@@ -39,7 +49,23 @@ export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
||||
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: MD_CONTEXT_MANAGED_CHAT_TYPE_ID,
|
||||
name: MD_CONTEXT_MANAGED_CHAT_TYPE_NAME,
|
||||
description: MD_CONTEXT_MANAGED_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_ID,
|
||||
name: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_NAME,
|
||||
description: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ConversationRoomPane({
|
||||
showScrollToBottom={false}
|
||||
copiedMessageId={null}
|
||||
draft=""
|
||||
draftVersion={0}
|
||||
composerAttachments={[]}
|
||||
requestStateMap={requestStateMap}
|
||||
isConversationLoading={isLoading}
|
||||
@@ -85,6 +86,7 @@ export function ConversationRoomPane({
|
||||
onCancelMessage={() => {}}
|
||||
onDeleteRequest={() => {}}
|
||||
onRemoveQueuedRequest={() => {}}
|
||||
onSubmitPrompt={async () => false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
clearChatConversationRoom,
|
||||
createChatConversationRoom,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
@@ -57,6 +58,7 @@ export type ChatGateway = {
|
||||
>
|
||||
>,
|
||||
) => Promise<ChatConversationSummary>;
|
||||
clearConversation: (sessionId: string) => Promise<ChatConversationSummary>;
|
||||
deleteConversation: (sessionId: string) => Promise<void>;
|
||||
deleteConversationRequest: (sessionId: string, requestId: string) => Promise<void>;
|
||||
markConversationRead: (sessionId: string) => Promise<void>;
|
||||
@@ -73,6 +75,7 @@ export const chatGateway: ChatGateway = {
|
||||
createConversation: createChatConversationRoom,
|
||||
renameConversation: renameChatConversationRoom,
|
||||
updateConversation: updateChatConversationRoom,
|
||||
clearConversation: clearChatConversationRoom,
|
||||
deleteConversation: async (sessionId) => {
|
||||
await deleteChatConversationRoom(sessionId);
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ type UseConversationComposerControllerOptions = {
|
||||
maxContextMessages: number;
|
||||
maxContextChars: number;
|
||||
};
|
||||
draft: string;
|
||||
getDraft: () => string;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
isComposerAttachmentUploading: boolean;
|
||||
selectedChatType: SelectedChatType;
|
||||
@@ -74,7 +74,17 @@ type UseConversationComposerControllerOptions = {
|
||||
setShowScrollToBottom: (value: boolean) => void;
|
||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
||||
syncConversationPreviewForRequest: (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
requestedAt?: string,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
mode?: 'queue' | 'direct';
|
||||
queueSize?: number;
|
||||
jobMessage?: string | null;
|
||||
},
|
||||
) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
|
||||
@@ -97,7 +107,7 @@ type SendMessageOptions = {
|
||||
export function useConversationComposerController({
|
||||
activeSessionId,
|
||||
appConfigChat,
|
||||
draft,
|
||||
getDraft,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType,
|
||||
@@ -268,7 +278,12 @@ export function useConversationComposerController({
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt);
|
||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
|
||||
requestId,
|
||||
mode: 'queue',
|
||||
queueSize: 1,
|
||||
jobMessage: '대기열 등록 중',
|
||||
});
|
||||
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
@@ -304,6 +319,12 @@ export function useConversationComposerController({
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
|
||||
requestId,
|
||||
mode: 'direct',
|
||||
queueSize: 0,
|
||||
jobMessage: '즉시 요청 실행 대기 중',
|
||||
});
|
||||
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
@@ -374,7 +395,7 @@ export function useConversationComposerController({
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
@@ -423,7 +444,7 @@ export function useConversationComposerController({
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
getDraft,
|
||||
executeSendMessage,
|
||||
isComposerAttachmentUploading,
|
||||
messagesRef,
|
||||
|
||||
@@ -48,6 +48,32 @@ function mergeConversationItemsPreservingRequestedSession(
|
||||
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
||||
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
|
||||
currentRequestId:
|
||||
item.currentRequestId?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
|
||||
null,
|
||||
currentJobStatus:
|
||||
item.currentJobStatus ??
|
||||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||
? previousItem.currentJobStatus
|
||||
: null),
|
||||
currentJobMessage:
|
||||
item.currentJobMessage?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
|
||||
null,
|
||||
currentQueueSize:
|
||||
item.currentQueueSize > 0
|
||||
? item.currentQueueSize
|
||||
: item.currentJobStatus === 'queued'
|
||||
? Math.max(1, previousItem.currentQueueSize)
|
||||
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
|
||||
? Math.max(1, previousItem.currentQueueSize)
|
||||
: item.currentQueueSize,
|
||||
currentStatusUpdatedAt:
|
||||
item.currentStatusUpdatedAt ||
|
||||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||
? previousItem.currentStatusUpdatedAt
|
||||
: null),
|
||||
};
|
||||
});
|
||||
const normalizedRequestedSessionId = requestedSessionId.trim();
|
||||
|
||||
@@ -359,8 +359,51 @@ export function useConversationRoomActionsController({
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearConversation = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const item = await chatGateway.clearConversation(sessionId);
|
||||
sessionMessageCacheRef.current.set(sessionId, []);
|
||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === sessionId ? item : entry)));
|
||||
|
||||
if (sessionId === activeSessionId) {
|
||||
chatConnectionGateway.resetLastReceivedEventId(sessionId);
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus('채팅방 데이터를 초기화했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '채팅방 데이터 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
messageApi,
|
||||
sessionMessageCacheRef,
|
||||
setActivePreviewId,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setConversationItems,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsPreviewModalOpen,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
cancelPendingRequest,
|
||||
handleClearConversation,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
|
||||
|
||||
function mergeConversationRequests(
|
||||
|
||||
@@ -54,6 +54,18 @@ export function appendClientIdHeader(headersInit?: HeadersInit) {
|
||||
headers.set('X-Client-Id', clientId);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { origin, hostname } = window.location;
|
||||
|
||||
if (origin && !headers.has('X-App-Origin')) {
|
||||
headers.set('X-App-Origin', origin);
|
||||
}
|
||||
|
||||
if (hostname && !headers.has('X-App-Domain')) {
|
||||
headers.set('X-App-Domain', hostname);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ export function MainLayout() {
|
||||
useGesturePageState('anyway');
|
||||
useGestureLayer({
|
||||
id: 'main-layout',
|
||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
|
||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
|
||||
gestures: [
|
||||
{
|
||||
id: 'mobile-top-right-pull-alert',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { docsMarkdownEntries } from '../../manifests/docs.manifest';
|
||||
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
|
||||
import { DOCS_DEFAULT_FOLDER } from '../routes';
|
||||
|
||||
const DOCS_FOLDER_ORDER = ['worklogs', 'features', 'components', 'templates'] as const;
|
||||
const DOCS_FOLDER_ORDER = ['project'] as const;
|
||||
|
||||
export function useMainLayoutData() {
|
||||
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
|
||||
323
src/app/main/mainChatPanel/ChatActivityChecklist.tsx
Normal file
323
src/app/main/mainChatPanel/ChatActivityChecklist.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { GENERAL_REQUEST_CHAT_TYPE_ID } from '../chatTypeDefaults';
|
||||
import type { ChatConversationRequest } from './types';
|
||||
|
||||
type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error';
|
||||
type ActivityChecklistStageKey = 'intake' | 'analysis' | 'inspection' | 'execution' | 'result';
|
||||
|
||||
type ActivityChecklistEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
state: ActivityChecklistState;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const CHECKLIST_STAGE_ORDER: ActivityChecklistStageKey[] = ['intake', 'analysis', 'inspection', 'execution', 'result'];
|
||||
|
||||
const CHECKLIST_STAGE_LABELS: Record<ActivityChecklistStageKey, string> = {
|
||||
intake: '요청 접수',
|
||||
analysis: '요청 분석',
|
||||
inspection: '관련 확인',
|
||||
execution: '구현·응답 작성',
|
||||
result: '검증·결과 정리',
|
||||
};
|
||||
|
||||
const CHECKLIST_STAGE_PATTERNS: Record<ActivityChecklistStageKey, RegExp[]> = {
|
||||
intake: [/요청을 접수/i, /대기열 등록/i, /즉시 실행 대기/i, /요청을 처리합니다/i],
|
||||
analysis: [/요청 분석/i, /분석/i, /생각 중/i, /의도/i, /문맥/i],
|
||||
inspection: [
|
||||
/\bdb\b/i,
|
||||
/데이터베이스/i,
|
||||
/\bapi\b/i,
|
||||
/엔드포인트/i,
|
||||
/응답/i,
|
||||
/소스/i,
|
||||
/코드/i,
|
||||
/파일/i,
|
||||
/흐름/i,
|
||||
/쿼리/i,
|
||||
/집계/i,
|
||||
/resource/i,
|
||||
/리소스/i,
|
||||
/화면/i,
|
||||
],
|
||||
execution: [/구현/i, /수정/i, /변경/i, /작성/i, /빌드/i, /patch/i, /diff/i, /실시간으로 전송 중/i],
|
||||
result: [/검증/i, /테스트/i, /캡처/i, /preview/i, /스크린샷/i, /완료/i, /결과/i, /정리/i],
|
||||
};
|
||||
|
||||
function stripActivityPrefix(line: string) {
|
||||
return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeActivitySummary(value: string) {
|
||||
const candidates = stripActivityPrefix(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(line) =>
|
||||
!line.startsWith('$ ') &&
|
||||
!line.startsWith('# 결과:') &&
|
||||
!line.startsWith('# 출력:') &&
|
||||
!line.startsWith('# command-runner') &&
|
||||
!/^\[(stderr|stdout)\]/i.test(line),
|
||||
);
|
||||
|
||||
const summary = candidates[0] ?? '';
|
||||
|
||||
if (!summary) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return summary.length > 160 ? `${summary.slice(0, 157).trimEnd()}...` : summary;
|
||||
}
|
||||
|
||||
function normalizeLines(lines: string[]) {
|
||||
return lines.map((line) => String(line ?? '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function getLastStageSummary(lines: string[], stageKey: ActivityChecklistStageKey) {
|
||||
const patterns = CHECKLIST_STAGE_PATTERNS[stageKey];
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patterns.some((pattern) => pattern.test(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveObservationSummary(lines: string[]) {
|
||||
const labels = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const normalized = sanitizeActivitySummary(line);
|
||||
|
||||
if (/\bdb\b/i.test(normalized) || /데이터베이스|sql|쿼리|집계/i.test(normalized)) {
|
||||
labels.add('DB');
|
||||
}
|
||||
|
||||
if (/\bapi\b/i.test(normalized) || /엔드포인트|fetch|호출|응답/i.test(normalized)) {
|
||||
labels.add('API');
|
||||
}
|
||||
|
||||
if (/소스|코드|파일|tsx|ts|js|css|흐름/i.test(normalized)) {
|
||||
labels.add('소스');
|
||||
}
|
||||
|
||||
if (/화면|리소스|preview|캡처|스크린샷/i.test(normalized)) {
|
||||
labels.add('화면');
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(labels).join(' · ');
|
||||
}
|
||||
|
||||
function resolveCurrentStageKey(lines: string[], request?: ChatConversationRequest) {
|
||||
if (request?.status === 'completed' || request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed') {
|
||||
return 'result' as const;
|
||||
}
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const stageKey of ['result', 'execution', 'inspection', 'analysis', 'intake'] as const) {
|
||||
if (CHECKLIST_STAGE_PATTERNS[stageKey].some((pattern) => pattern.test(candidate))) {
|
||||
return stageKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request?.status === 'started') {
|
||||
return 'analysis' as const;
|
||||
}
|
||||
|
||||
return 'intake' as const;
|
||||
}
|
||||
|
||||
function resolveResultNote(request?: ChatConversationRequest) {
|
||||
const normalizedStatusMessage = String(request?.statusMessage ?? '').trim();
|
||||
|
||||
if (request?.status === 'completed') {
|
||||
return normalizedStatusMessage || '응답과 결과 정리가 완료되었습니다.';
|
||||
}
|
||||
|
||||
if (request?.status === 'failed') {
|
||||
return normalizedStatusMessage || '오류로 종료되었습니다.';
|
||||
}
|
||||
|
||||
if (request?.status === 'cancelled') {
|
||||
return normalizedStatusMessage || '사용자 요청으로 중단되었습니다.';
|
||||
}
|
||||
|
||||
if (request?.status === 'removed') {
|
||||
return normalizedStatusMessage || '요청 기록이 제거되었습니다.';
|
||||
}
|
||||
|
||||
return normalizedStatusMessage || '최종 결과를 정리하는 단계입니다.';
|
||||
}
|
||||
|
||||
function buildChecklistEntries(lines: string[], request?: ChatConversationRequest) {
|
||||
const currentStageKey = resolveCurrentStageKey(lines, request);
|
||||
const currentStageIndex = CHECKLIST_STAGE_ORDER.indexOf(currentStageKey);
|
||||
const isTerminalComplete = request?.status === 'completed';
|
||||
const isTerminalError = request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed';
|
||||
const observationSummary = resolveObservationSummary(lines);
|
||||
|
||||
return CHECKLIST_STAGE_ORDER.map<ActivityChecklistEntry>((stageKey, index) => {
|
||||
const summary = getLastStageSummary(lines, stageKey);
|
||||
let state: ActivityChecklistState = 'pending';
|
||||
|
||||
if (isTerminalComplete) {
|
||||
state = 'complete';
|
||||
} else if (isTerminalError && index === currentStageIndex) {
|
||||
state = 'error';
|
||||
} else if (index < currentStageIndex) {
|
||||
state = 'complete';
|
||||
} else if (index === currentStageIndex) {
|
||||
state = 'current';
|
||||
}
|
||||
|
||||
let note = summary;
|
||||
|
||||
if (!note) {
|
||||
switch (stageKey) {
|
||||
case 'intake':
|
||||
note = request?.status === 'queued' ? '대기열 접수 후 순차 실행을 기다립니다.' : '요청을 접수하고 실행 준비를 시작합니다.';
|
||||
break;
|
||||
case 'analysis':
|
||||
note = '요청 의도와 현재 화면 문맥을 정리합니다.';
|
||||
break;
|
||||
case 'inspection':
|
||||
note = observationSummary ? `${observationSummary} 기준으로 확인합니다.` : 'DB, API, 소스, 화면 중 필요한 대상을 확인합니다.';
|
||||
break;
|
||||
case 'execution':
|
||||
note = request?.hasResponse ? '응답 초안 또는 변경 결과를 작성 중입니다.' : '필요한 구현과 응답 작성을 진행합니다.';
|
||||
break;
|
||||
case 'result':
|
||||
note = resolveResultNote(request);
|
||||
break;
|
||||
}
|
||||
} else if (stageKey === 'inspection' && observationSummary && !note.includes('·')) {
|
||||
note = `${note} (${observationSummary})`;
|
||||
}
|
||||
|
||||
return {
|
||||
key: stageKey,
|
||||
label: CHECKLIST_STAGE_LABELS[stageKey],
|
||||
state,
|
||||
note,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderStateIcon(state: ActivityChecklistState) {
|
||||
if (state === 'complete') {
|
||||
return <CheckCircleFilled aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (state === 'current') {
|
||||
return <LoadingOutlined aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
return <CloseCircleFilled aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <ClockCircleOutlined aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
|
||||
const completedCount = entries.filter((entry) => entry.state === 'complete').length;
|
||||
const currentEntry = entries.find((entry) => entry.state === 'current');
|
||||
const errorEntry = entries.find((entry) => entry.state === 'error');
|
||||
|
||||
if (errorEntry) {
|
||||
return `${errorEntry.label} 단계에서 확인 필요`;
|
||||
}
|
||||
|
||||
if (currentEntry) {
|
||||
return `${completedCount}/${entries.length} 완료 · ${currentEntry.label} 진행 중`;
|
||||
}
|
||||
|
||||
if (completedCount === entries.length) {
|
||||
return '체크리스트 완료';
|
||||
}
|
||||
|
||||
return `${completedCount}/${entries.length} 완료`;
|
||||
}
|
||||
|
||||
export function ChatActivityChecklist({
|
||||
lines,
|
||||
request,
|
||||
chatTypeId,
|
||||
}: {
|
||||
lines: string[];
|
||||
request?: ChatConversationRequest;
|
||||
chatTypeId?: string | null;
|
||||
}) {
|
||||
if ((chatTypeId ?? '').trim() !== GENERAL_REQUEST_CHAT_TYPE_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedLines = normalizeLines(lines);
|
||||
const entries = buildChecklistEntries(normalizedLines, request);
|
||||
|
||||
return (
|
||||
<section className="app-chat-activity-checklist" aria-label="Plan 체크리스트">
|
||||
<div className="app-chat-activity-checklist__header">
|
||||
<div className="app-chat-activity-checklist__title-group">
|
||||
<span className="app-chat-activity-checklist__title">Plan 체크리스트</span>
|
||||
<span className="app-chat-activity-checklist__summary">{buildSummaryLabel(entries)}</span>
|
||||
</div>
|
||||
<span className="app-chat-activity-checklist__legend">
|
||||
<MinusCircleOutlined aria-hidden="true" />
|
||||
<span>실시간 반영</span>
|
||||
</span>
|
||||
</div>
|
||||
<ol className="app-chat-activity-checklist__list">
|
||||
{entries.map((entry) => (
|
||||
<li
|
||||
key={entry.key}
|
||||
className={`app-chat-activity-checklist__item app-chat-activity-checklist__item--${entry.state}`}
|
||||
aria-current={entry.state === 'current' ? 'step' : undefined}
|
||||
>
|
||||
<span className="app-chat-activity-checklist__icon">{renderStateIcon(entry.state)}</span>
|
||||
<div className="app-chat-activity-checklist__content">
|
||||
<div className="app-chat-activity-checklist__row">
|
||||
<span className="app-chat-activity-checklist__label">{entry.label}</span>
|
||||
<span className="app-chat-activity-checklist__state">
|
||||
{entry.state === 'complete'
|
||||
? '완료'
|
||||
: entry.state === 'current'
|
||||
? '진행중'
|
||||
: entry.state === 'error'
|
||||
? '확인필요'
|
||||
: '대기'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="app-chat-activity-checklist__note">{entry.note}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import {
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -45,7 +44,9 @@ import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
|
||||
import { ChatActivityChecklist } from './ChatActivityChecklist';
|
||||
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
||||
import { buildPromptResponseText, ChatPromptCard, type PromptDraftSelection } from './ChatPromptCard';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
@@ -109,6 +110,11 @@ type PendingComposerUpload = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type PendingPromptSelection = PromptDraftSelection & {
|
||||
promptTitle: string;
|
||||
target: Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||
};
|
||||
|
||||
type PreviewFetchError = Error & {
|
||||
status?: number;
|
||||
};
|
||||
@@ -128,6 +134,7 @@ type MessageRenderPayload = {
|
||||
diffBlocks: string[];
|
||||
rankedLinkTargets: RankedLinkPreviewTarget[];
|
||||
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||||
};
|
||||
|
||||
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
||||
@@ -172,6 +179,11 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function isHtmlPreviewUrl(url: string) {
|
||||
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||
}
|
||||
|
||||
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -453,20 +465,23 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
|
||||
}
|
||||
|
||||
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
||||
const seen = new Set<string>();
|
||||
const targets: InlinePreviewTarget[] = [];
|
||||
|
||||
for (const matchedUrl of matches) {
|
||||
const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => {
|
||||
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
|
||||
const kind = classifyInlinePreviewKind(normalizedUrl);
|
||||
|
||||
if (kind === 'file') {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain HTML artifact paths should stay as text unless the reply explicitly opts into preview rendering.
|
||||
if (!options?.allowHtml && isHtmlPreviewUrl(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(normalizedUrl)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(normalizedUrl);
|
||||
@@ -475,7 +490,14 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||
label: buildInlinePreviewLabel(normalizedUrl),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
extractAutoDetectedPreviewUrls(text).forEach((matchedUrl) => {
|
||||
pushTarget(matchedUrl);
|
||||
});
|
||||
extractHiddenPreviewUrls(text).forEach((matchedUrl) => {
|
||||
pushTarget(matchedUrl, { allowHtml: true });
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
@@ -558,6 +580,18 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
|
||||
...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 promptTargets = [
|
||||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||
].filter(
|
||||
(part, index, collection) =>
|
||||
collection.findIndex(
|
||||
(candidate) =>
|
||||
candidate.title === part.title &&
|
||||
candidate.options.map((option) => `${option.value}:${option.label}`).join(',') ===
|
||||
part.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||
) === index,
|
||||
);
|
||||
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
@@ -572,6 +606,7 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
|
||||
diffBlocks,
|
||||
rankedLinkTargets,
|
||||
linkCardTargets,
|
||||
promptTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -977,6 +1012,7 @@ type ChatConversationViewProps = {
|
||||
showScrollToBottom: boolean;
|
||||
copiedMessageId: number | null;
|
||||
draft: string;
|
||||
draftVersion: number;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
requestStateMap: Map<string, ChatConversationRequest>;
|
||||
isConversationLoading: boolean;
|
||||
@@ -1015,6 +1051,7 @@ type ChatConversationViewProps = {
|
||||
onCancelMessage: (message: ChatMessage) => void;
|
||||
onDeleteRequest: (message: ChatMessage) => void;
|
||||
onRemoveQueuedRequest: (requestId: string) => void;
|
||||
onSubmitPrompt: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function ChatConversationView({
|
||||
@@ -1026,6 +1063,7 @@ export function ChatConversationView({
|
||||
showScrollToBottom,
|
||||
copiedMessageId,
|
||||
draft,
|
||||
draftVersion,
|
||||
composerAttachments,
|
||||
requestStateMap,
|
||||
isConversationLoading,
|
||||
@@ -1064,7 +1102,10 @@ export function ChatConversationView({
|
||||
onCancelMessage,
|
||||
onDeleteRequest,
|
||||
onRemoveQueuedRequest,
|
||||
onSubmitPrompt,
|
||||
}: ChatConversationViewProps) {
|
||||
const [composerDraft, setComposerDraft] = useState(draft);
|
||||
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingPromptSelection>>({});
|
||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||
@@ -1073,37 +1114,41 @@ export function ChatConversationView({
|
||||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||||
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
||||
const [composerDraft, setComposerDraft] = useState(draft);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
|
||||
const lastReportedDraftRef = useRef(draft);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft === lastReportedDraftRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setComposerDraft(draft);
|
||||
}, [draft]);
|
||||
}, [draft, draftVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (composerDraft === lastReportedDraftRef.current) {
|
||||
return;
|
||||
const pendingPromptSelectionEntries = useMemo(
|
||||
() => Object.entries(pendingPromptSelections).sort(([left], [right]) => left.localeCompare(right)),
|
||||
[pendingPromptSelections],
|
||||
);
|
||||
const pendingPromptSelectionCount = pendingPromptSelectionEntries.length;
|
||||
|
||||
const buildComposerOutboundText = (draftText: string) => {
|
||||
const trimmedDraftText = draftText.trim();
|
||||
|
||||
if (pendingPromptSelectionEntries.length === 0) {
|
||||
return draftText;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
lastReportedDraftRef.current = composerDraft;
|
||||
startTransition(() => {
|
||||
onDraftChange(composerDraft);
|
||||
});
|
||||
}, 120);
|
||||
const promptTexts = pendingPromptSelectionEntries.map(([, selection], index) => {
|
||||
const mergedFreeText = [selection.freeText.trim(), index === pendingPromptSelectionEntries.length - 1 ? trimmedDraftText : '']
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [composerDraft, onDraftChange]);
|
||||
return buildPromptResponseText(selection.target, {
|
||||
...selection,
|
||||
freeText: mergedFreeText,
|
||||
});
|
||||
});
|
||||
|
||||
return promptTexts.join('\n\n');
|
||||
};
|
||||
|
||||
const orderedMessages = useMemo(() => {
|
||||
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
|
||||
@@ -1178,6 +1223,39 @@ export function ChatConversationView({
|
||||
|
||||
return [...ordered, ...orphanActivityMessages];
|
||||
}, [requestStateMap, visibleMessages]);
|
||||
const lastNonSystemMessageId = useMemo(() => {
|
||||
for (let index = orderedMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = orderedMessages[index];
|
||||
if (message.author !== 'system') {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [orderedMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const activePromptKeys = new Set<string>();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const { promptTargets } = extractMessageRenderPayload(message);
|
||||
|
||||
promptTargets.forEach((target, index) => {
|
||||
activePromptKeys.add(`${message.id}:${index}:${target.title}`);
|
||||
});
|
||||
});
|
||||
|
||||
setPendingPromptSelections((current) => {
|
||||
const nextEntries = Object.entries(current).filter(([key]) => activePromptKeys.has(key));
|
||||
|
||||
if (nextEntries.length === Object.keys(current).length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [orderedMessages]);
|
||||
|
||||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||||
const isChatTypeReadonly = isChatTypeSelectionLocked;
|
||||
const visiblePreviewItems = useMemo(() => {
|
||||
@@ -1546,8 +1624,12 @@ export function ChatConversationView({
|
||||
const composerPlaceholder = isComposerDisabled
|
||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||
: isMobileViewport
|
||||
? '메시지를 입력하세요.'
|
||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||
? pendingPromptSelectionCount > 0
|
||||
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다.'
|
||||
: '메시지를 입력하세요.'
|
||||
: pendingPromptSelectionCount > 0
|
||||
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다. Ctrl+Enter로 바로 전송할 수 있습니다.'
|
||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||
|
||||
const renderActivityCard = (message: ChatMessage) => {
|
||||
const requestId = message.clientRequestId?.trim() || String(message.id);
|
||||
@@ -1555,6 +1637,7 @@ export function ChatConversationView({
|
||||
const lines = extractActivityLines(message);
|
||||
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
|
||||
const activityCountLabel = `${lines.length}개 로그`;
|
||||
const request = requestStateMap.get(requestId);
|
||||
|
||||
return (
|
||||
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
|
||||
@@ -1601,9 +1684,12 @@ export function ChatConversationView({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
|
||||
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
||||
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
||||
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
||||
<div className="app-chat-activity-card__summary-grid">
|
||||
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
||||
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
||||
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
||||
</div>
|
||||
<ChatActivityChecklist lines={lines} request={request} chatTypeId={selectedChatTypeId} />
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
@@ -1748,7 +1834,8 @@ export function ChatConversationView({
|
||||
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
|
||||
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
|
||||
}`;
|
||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
|
||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, promptTargets } =
|
||||
extractMessageRenderPayload(message);
|
||||
const renderedText = isRecoveredMissingRequest
|
||||
? getMissingRequestMessageText(message)
|
||||
: isRecoveredExecutionFailure
|
||||
@@ -1761,9 +1848,14 @@ export function ChatConversationView({
|
||||
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||
const hasPreviewCards =
|
||||
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
|
||||
diffBlocks.length > 0 ||
|
||||
inlinePreviewTargets.length > 0 ||
|
||||
rankedLinkTargets.length > 0 ||
|
||||
linkCardTargets.length > 0 ||
|
||||
promptTargets.length > 0;
|
||||
const shouldRenderStandalonePreview =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
const isPromptReadOnly = message.id !== lastNonSystemMessageId;
|
||||
const stackClassName = [
|
||||
`app-chat-message-stack app-chat-message-stack--${message.author}`,
|
||||
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
|
||||
@@ -1909,6 +2001,42 @@ export function ChatConversationView({
|
||||
{linkCardTargets.map((target) => (
|
||||
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
{promptTargets.map((target, index) => (
|
||||
(() => {
|
||||
const selectionKey = `${message.id}:${index}:${target.title}`;
|
||||
|
||||
return (
|
||||
<ChatPromptCard
|
||||
key={`${message.id}-prompt-${index}-${target.title}`}
|
||||
target={target}
|
||||
onSubmit={onSubmitPrompt}
|
||||
readOnly={isPromptReadOnly}
|
||||
onSelectionChange={(selection) => {
|
||||
setPendingPromptSelections((current) => {
|
||||
if (!selection) {
|
||||
if (!(selectionKey in current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = { ...current };
|
||||
delete next[selectionKey];
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[selectionKey]: {
|
||||
...selection,
|
||||
promptTitle: target.title,
|
||||
target,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{rankedLinkTargets.map((target) => (
|
||||
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
@@ -2064,9 +2192,7 @@ export function ChatConversationView({
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label="즉시 요청"
|
||||
onClick={() => {
|
||||
lastReportedDraftRef.current = composerDraft;
|
||||
onDraftChange(composerDraft);
|
||||
onSendImmediate(composerDraft);
|
||||
onSendImmediate(buildComposerOutboundText(composerDraft));
|
||||
}}
|
||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||
/>
|
||||
@@ -2075,9 +2201,7 @@ export function ChatConversationView({
|
||||
icon={<SendOutlined />}
|
||||
aria-label="큐로 보내기"
|
||||
onClick={() => {
|
||||
lastReportedDraftRef.current = composerDraft;
|
||||
onDraftChange(composerDraft);
|
||||
onSend(composerDraft);
|
||||
onSend(buildComposerOutboundText(composerDraft));
|
||||
}}
|
||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||
/>
|
||||
@@ -2096,6 +2220,27 @@ export function ChatConversationView({
|
||||
|
||||
{composerAttachmentStrip}
|
||||
|
||||
{pendingPromptSelectionCount > 0 ? (
|
||||
<div className="app-chat-panel__composer-prompt-strip" aria-live="polite">
|
||||
{pendingPromptSelectionEntries.map(([selectionKey, selection]) => {
|
||||
const selectionLabel = selection.target.options
|
||||
.filter((option) => selection.selectedValues.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div key={selectionKey} className="app-chat-panel__composer-prompt-chip">
|
||||
<span className="app-chat-panel__composer-prompt-chip-title">{selection.promptTitle}</span>
|
||||
<span className="app-chat-panel__composer-prompt-chip-value">
|
||||
{selection.summaryText || selectionLabel || selection.selectedValues.join(', ')}
|
||||
</span>
|
||||
<span className="app-chat-panel__composer-prompt-chip-meta">일반 전송에 포함됨</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`app-chat-panel__composer-input-shell${
|
||||
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
||||
@@ -2138,7 +2283,9 @@ export function ChatConversationView({
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isComposerDisabled}
|
||||
onChange={(event) => {
|
||||
setComposerDraft(event.target.value);
|
||||
const nextValue = event.target.value;
|
||||
setComposerDraft(nextValue);
|
||||
onDraftChange(nextValue);
|
||||
}}
|
||||
onPaste={handleComposerPaste}
|
||||
onKeyDown={(event) => {
|
||||
@@ -2158,9 +2305,7 @@ export function ChatConversationView({
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
lastReportedDraftRef.current = event.currentTarget.value;
|
||||
onDraftChange(event.currentTarget.value);
|
||||
onSend(event.currentTarget.value);
|
||||
onSend(buildComposerOutboundText(composerDraft));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
1160
src/app/main/mainChatPanel/ChatPromptCard.tsx
Normal file
1160
src/app/main/mainChatPanel/ChatPromptCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
|
||||
function extractEmbeddedResourcePath(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
@@ -11,13 +12,23 @@ function extractEmbeddedResourcePath(value: string) {
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
|
||||
if (apiMarkerIndex >= 0) {
|
||||
return normalized.slice(apiMarkerIndex);
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
|
||||
if (publicMarkerIndex >= 0) {
|
||||
return normalized.slice(publicMarkerIndex);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
|
||||
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||
import { reportClientError } from '../errorLogApi';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
ChatConversationActivityLog,
|
||||
@@ -839,32 +840,7 @@ export async function diagnoseConnectionFailure(targetUrl: string, closeEvent?:
|
||||
}
|
||||
|
||||
export async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy');
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('복사 명령이 거부되었습니다.');
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
return copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
export type PreviewCopyResult = 'text' | 'image' | 'url';
|
||||
@@ -1419,6 +1395,23 @@ export async function deleteChatConversationRoom(sessionId: string) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function clearChatConversationRoom(sessionId: string) {
|
||||
const clientId = getOrCreateClientId();
|
||||
const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>(
|
||||
`/conversations/${encodeURIComponent(sessionId)}/clear`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteChatConversationRequest(sessionId: string, requestId: string) {
|
||||
const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>(
|
||||
`/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`,
|
||||
@@ -1450,13 +1443,16 @@ function areChatMessagesEquivalent(left: ChatMessage[], right: ChatMessage[]) {
|
||||
|
||||
return left.every((message, index) => {
|
||||
const other = right[index];
|
||||
const leftParts = JSON.stringify(message.parts ?? []);
|
||||
const rightParts = JSON.stringify(other?.parts ?? []);
|
||||
|
||||
return (
|
||||
other &&
|
||||
message.id === other.id &&
|
||||
message.author === other.author &&
|
||||
message.text === other.text &&
|
||||
message.timestamp === other.timestamp
|
||||
message.timestamp === other.timestamp &&
|
||||
leftParts === rightParts
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1491,6 +1487,7 @@ export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage
|
||||
...existingMessage,
|
||||
...incoming,
|
||||
text: nextText,
|
||||
parts: incoming.parts ?? existingMessage.parts ?? [],
|
||||
deliveryStatus: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
createIntroMessage,
|
||||
createLocalMessage,
|
||||
cancelChatRuntimeJob,
|
||||
clearChatConversationRoom,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { ChatMessagePart } from './types';
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\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;
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||
type PromptOption = PromptPart['options'][number];
|
||||
type PromptPreview = NonNullable<PromptOption['preview']>;
|
||||
type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
@@ -21,6 +29,25 @@ function normalizeUrl(value: string) {
|
||||
return `/${malformedResourceMatch[1]}`;
|
||||
}
|
||||
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
if (apiMarkerIndex >= 0) {
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -28,6 +55,116 @@ function normalizeUrl(value: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizePromptPreview(value: unknown): PromptPreview | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const type =
|
||||
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
||||
? record.type
|
||||
: null;
|
||||
const url = normalizeUrl(normalizeText(record.url));
|
||||
const content = String(record.content ?? '').trim() || null;
|
||||
const alt = normalizeText(record.alt) || null;
|
||||
const title = normalizeText(record.title) || null;
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'image' || type === 'resource') {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
} else if (!content && !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
url: url || null,
|
||||
content,
|
||||
alt,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function isPromptOption(value: PromptOption | null): value is PromptOption {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
function normalizePromptOption(value: unknown): PromptOption | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const optionValue = normalizeText(record.value);
|
||||
const label = normalizeText(record.label);
|
||||
|
||||
if (!optionValue || !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: optionValue,
|
||||
label,
|
||||
description: normalizeText(record.description) || null,
|
||||
preview: normalizePromptPreview(record.preview),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptSelectedValues(value: unknown) {
|
||||
return [
|
||||
...(Array.isArray(value) ? value : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((item, index, array) => array.indexOf(item) === index);
|
||||
}
|
||||
|
||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const key = normalizeText(record.key) || `step-${index + 1}`;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options.map((option) => normalizePromptOption(option)).filter(isPromptOption)
|
||||
: [];
|
||||
|
||||
if (!title || options.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key,
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
|
||||
multiple: record.multiple === true,
|
||||
optional: record.optional === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
selectedValues: normalizePromptSelectedValues(record.selectedValues),
|
||||
options,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function decodeUrlComponentSafely(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
@@ -135,6 +272,64 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options.map((item) => normalizePromptOption(item)).filter(isPromptOption)
|
||||
: [];
|
||||
const steps = normalizePromptSteps(record.steps);
|
||||
|
||||
if (!title || (options.length === 0 && steps.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
|
||||
const selectedValues = [
|
||||
...normalizePromptSelectedValues(record.selectedValues),
|
||||
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
const resolvedBy =
|
||||
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
|
||||
? record.resolvedBy
|
||||
: null;
|
||||
|
||||
return {
|
||||
type: 'prompt',
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode,
|
||||
multiple: record.multiple === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
resultText: normalizeText(record.resultText) || null,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatMessageParts(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
@@ -145,7 +340,38 @@ export function extractChatMessageParts(text: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
||||
const dedupeKey =
|
||||
nextPart.type === 'link_card'
|
||||
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
|
||||
: [
|
||||
nextPart.type,
|
||||
nextPart.title,
|
||||
nextPart.options
|
||||
.map((option) =>
|
||||
[
|
||||
option.value,
|
||||
option.label,
|
||||
option.preview?.type ?? '',
|
||||
option.preview?.url ?? '',
|
||||
option.preview?.content ?? '',
|
||||
option.preview?.title ?? '',
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
(nextPart.steps ?? [])
|
||||
.map((step) =>
|
||||
[
|
||||
step.key,
|
||||
step.title,
|
||||
step.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
nextPart.selectedValues?.join(',') ?? '',
|
||||
nextPart.resolvedBy ?? '',
|
||||
nextPart.resultText ?? '',
|
||||
nextPart.readOnly === true ? 'readonly' : '',
|
||||
].join(':');
|
||||
|
||||
if (seenLinkKeys.has(dedupeKey)) {
|
||||
return true;
|
||||
@@ -157,6 +383,15 @@ export function extractChatMessageParts(text: string) {
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const promptMatched = line.match(PROMPT_LINE_PATTERN);
|
||||
|
||||
if (promptMatched) {
|
||||
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
|
||||
keptLines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
@@ -190,7 +425,7 @@ export function extractChatMessageParts(text: string) {
|
||||
}
|
||||
|
||||
const latestPart = parts.at(-1);
|
||||
if (latestPart && isInternalResourceUrl(latestPart.url)) {
|
||||
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
|
||||
parts.pop();
|
||||
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
||||
keptLines.push(latestPart.url);
|
||||
|
||||
1019
src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css
Normal file
1019
src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css
Normal file
File diff suppressed because it is too large
Load Diff
1692
src/app/main/mainChatPanel/styles/MainChatPanel.layout.css
Normal file
1692
src/app/main/mainChatPanel/styles/MainChatPanel.layout.css
Normal file
File diff suppressed because it is too large
Load Diff
1980
src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css
Normal file
1980
src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,64 @@ export type ChatMessagePart =
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'prompt';
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
currentStepKey?: string | null;
|
||||
steps?: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
optional?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
selectedValues?: string[];
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
}>;
|
||||
readOnly?: boolean;
|
||||
selectedValues?: string[];
|
||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||
resolvedAt?: string | null;
|
||||
resultText?: string | null;
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
@@ -58,6 +116,8 @@ export type ChatConversationSummary = {
|
||||
currentJobMessage: string | null;
|
||||
currentQueueSize: number;
|
||||
currentStatusUpdatedAt: string | null;
|
||||
isPendingWork: boolean;
|
||||
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
|
||||
lastRequestPreview: string;
|
||||
lastMessagePreview: string;
|
||||
lastResponsePreview: string;
|
||||
|
||||
@@ -10,13 +10,10 @@ export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||
error: '실패 (0)',
|
||||
};
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||
|
||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||
worklogs: '작업일지',
|
||||
features: '기능문서',
|
||||
components: '컴포넌트문서',
|
||||
templates: '문서템플릿',
|
||||
project: '프로젝트 구조',
|
||||
};
|
||||
|
||||
export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
|
||||
@@ -22,16 +22,13 @@ export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'mana
|
||||
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
|
||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
|
||||
export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
|
||||
export const PLAN_GROUP_LABEL = '작업';
|
||||
|
||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||
worklogs: '작업일지',
|
||||
features: '기능문서',
|
||||
components: '컴포넌트문서',
|
||||
templates: '문서템플릿',
|
||||
project: '프로젝트 구조',
|
||||
};
|
||||
|
||||
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||
|
||||
@@ -10,26 +10,48 @@ const featureMarkdownModules = import.meta.glob('../../features/**/*.md', {
|
||||
import: 'default',
|
||||
}) as Record<string, () => Promise<string>>;
|
||||
|
||||
function createMarkdownEntries(
|
||||
const DOCS_ENTRY_DEFINITIONS: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>> = [
|
||||
{
|
||||
path: '/docs/README.md',
|
||||
folder: 'project',
|
||||
title: '프로젝트 구조',
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURE_ENTRY_PATTERNS = [/\/overview\.md$/i, /\/README\.md$/i];
|
||||
|
||||
function createExplicitMarkdownEntries(
|
||||
modules: Record<string, () => Promise<string>>,
|
||||
entries: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>>,
|
||||
): MarkdownDocumentEntry[] {
|
||||
const sortedPaths = Object.keys(modules).sort((left, right) => {
|
||||
const isLeftWorklog = left.includes('/docs/worklogs/');
|
||||
const isRightWorklog = right.includes('/docs/worklogs/');
|
||||
|
||||
if (isLeftWorklog && isRightWorklog) {
|
||||
return right.localeCompare(left);
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return sortedPaths.map((path, index) => ({
|
||||
path,
|
||||
load: modules[path],
|
||||
order: index,
|
||||
}));
|
||||
return entries
|
||||
.filter((entry) => typeof modules[entry.path] === 'function')
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
load: modules[entry.path],
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
export const docsMarkdownEntries = createMarkdownEntries(docsMarkdownModules);
|
||||
export const featureMarkdownEntries = createMarkdownEntries(featureMarkdownModules);
|
||||
function createFilteredMarkdownEntries(
|
||||
modules: Record<string, () => Promise<string>>,
|
||||
includePatterns: RegExp[],
|
||||
): MarkdownDocumentEntry[] {
|
||||
return Object.keys(modules)
|
||||
.filter((path) => includePatterns.some((pattern) => pattern.test(path)))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((path, index) => ({
|
||||
path,
|
||||
load: modules[path],
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
export const docsMarkdownEntries = createExplicitMarkdownEntries(
|
||||
docsMarkdownModules,
|
||||
DOCS_ENTRY_DEFINITIONS,
|
||||
);
|
||||
export const featureMarkdownEntries = createFilteredMarkdownEntries(
|
||||
featureMarkdownModules,
|
||||
FEATURE_ENTRY_PATTERNS,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user