chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -5,18 +5,20 @@ import { ChatPage } from './pages/ChatPage';
import { DocsPage } from './pages/DocsPage';
import { PlansPage } from './pages/PlansPage';
import { PlayPage } from './pages/PlayPage';
import { buildDocsPath, buildPlansPath } from './routes';
import { buildChatPath, buildDocsPath } from './routes';
export function AppShell() {
return (
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<Navigate to={buildPlansPath('all')} replace />} />
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
<Route path="docs/:folder" element={<DocsPage />} />
<Route path="apis/:section" element={<ApisPage />} />
<Route path="plans/:section" element={<PlansPage />} />
<Route path="chat/:section" element={<ChatPage />} />
<Route path="play/layout" element={<PlayPage />} />
<Route path="play/test" element={<PlayPage />} />
<Route path="play/cbt" element={<PlayPage />} />
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
</Route>

View File

@@ -0,0 +1,426 @@
import {
ArrowsAltOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
SaveOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteChatDefaultContext,
pruneChatRoomContextSettings,
pruneChatTypeDefaultSelections,
upsertChatDefaultContext,
useChatContextSettingsRegistry,
type ChatDefaultContextRecord,
} from './chatContextSettingsAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Text, Title } = Typography;
type ChatDefaultContextFormValue = {
id?: string;
title: string;
content: string;
enabled: boolean;
};
const EMPTY_FORM_VALUE: ChatDefaultContextFormValue = {
title: '',
content: '',
enabled: true,
};
function toFormValue(record: ChatDefaultContextRecord | null): ChatDefaultContextFormValue {
if (!record) {
return EMPTY_FORM_VALUE;
}
return {
id: record.id,
title: record.title,
content: record.content,
enabled: record.enabled,
};
}
export function ChatDefaultContextManagementPage() {
const { hasAccess } = useTokenAccess();
const {
defaultContexts,
chatTypeDefaults,
roomContexts,
errorMessage: contextSettingsErrorMessage,
setStore,
} = useChatContextSettingsRegistry();
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<ChatDefaultContextFormValue>();
const selectedContext = useMemo(
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
[defaultContexts, selectedContextId],
);
useEffect(() => {
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
return;
}
setSelectedContextId(defaultContexts[0]?.id ?? null);
}, [defaultContexts, selectedContextId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedContext));
}, [detailMode, form, isCreating, selectedContext]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const openCreateForm = () => {
setIsCreating(true);
setSelectedContextId(null);
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (contextId: string) => {
setIsCreating(false);
setSelectedContextId(contextId);
setDetailMode('detail');
setMaximizedPane('none');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
};
const handleDelete = async () => {
if (!selectedContext) {
return;
}
if (!window.confirm(`"${selectedContext.title}" 기본 유형을 삭제할까요?`)) {
return;
}
const nextDefaultContexts = deleteChatDefaultContext(defaultContexts, selectedContext.id);
const nextChatTypeDefaults = pruneChatTypeDefaultSelections(chatTypeDefaults, selectedContext.id);
const nextRoomContexts = pruneChatRoomContextSettings(roomContexts, selectedContext.id);
await setStore({
defaultContexts: nextDefaultContexts,
chatTypeDefaults: nextChatTypeDefaults,
roomContexts: nextRoomContexts,
});
setSelectedContextId(nextDefaultContexts[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
if (!hasAccess) {
return (
<Card title="기본 유형 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 기본 유형을 관리하세요."
/>
</Card>
);
}
return (
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
maximizedPane !== 'none' ? ' chat-type-management-page--pane-maximized' : ''
}`}
>
{detailMode === 'list' ? (
<Card
title="기본 유형 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
</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>
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
</div>
</div>
</List.Item>
)}
/>
) : (
<Empty description="등록된 기본 유형이 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? '기본 유형 등록' : '기본 유형 상세'}
className={`chat-type-management-page__card${
maximizedPane !== 'none' ? ' chat-type-management-page__card--pane-maximized' : ''
}`}
extra={
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
{!isCreating && selectedContext ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
aria-label="삭제"
onClick={() => {
void handleDelete();
}}
/>
) : null}
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Space>
}
>
<div className="chat-type-management-page__editor">
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
<Form
className="chat-type-management-page__editor-form"
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={async (values) => {
setSaveErrorMessage('');
try {
const nextDefaultContexts = upsertChatDefaultContext(defaultContexts, values);
const savedContext = nextDefaultContexts.find((item) => item.id === values.id || item.title === values.title) ?? null;
await setStore({
defaultContexts: nextDefaultContexts,
chatTypeDefaults,
roomContexts,
});
setIsCreating(false);
setSelectedContextId(savedContext?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '기본 유형 저장에 실패했습니다.');
}
}}
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${maximizedPane !== 'none' ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="기본 유형명"
name="title"
rules={[{ required: true, message: '기본 유형명을 입력하세요.' }]}
>
<Input placeholder="예: 모바일 검증 공통 규칙" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
</div>
<Form.Item name="content" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={'## 적용 기준\n- 기본 유형의 공통 규칙을 Markdown으로 정의하세요.'}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.content !== next.content}>
{({ getFieldValue }) => {
const content = String(getFieldValue('content') ?? '').trim();
return content ? (
<MarkdownPreviewContent content={content} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 기본 유형 본문이 없습니다." />
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Form>
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAppStore } from '../../store';
import { getChatClientSessionId } from './mainChatPanel';
import { chatConnectionGateway, chatGateway } from './chatV2';
import type { ChatMessage, ChatViewContext } from './mainChatPanel/types';
@@ -17,8 +17,26 @@ function isStandaloneDisplayMode() {
export function ChatRuntimeBridgeV2() {
const { currentPage, focusedComponentId } = useAppStore();
const [sessionId] = useState(() => getChatClientSessionId());
const location = useLocation();
const [, setMessages] = useState<ChatMessage[]>([]);
const sessionId = useMemo(() => {
if (typeof window === 'undefined') {
return '';
}
if (currentPage.topMenu !== 'chat') {
return '';
}
const currentUrl = new URL(window.location.href);
const pathname = currentUrl.pathname.replace(/\/+$/, '') || '/';
if (pathname !== '/chat/live') {
return '';
}
return currentUrl.searchParams.get('sessionId')?.trim() || '';
}, [currentPage.topMenu, location.pathname, location.search]);
const currentContext: ChatViewContext = useMemo(
() => ({

View File

@@ -7,10 +7,6 @@
overflow: hidden;
}
.chat-type-management-page--detail {
container-type: inline-size;
}
.chat-type-management-page .ant-card,
.chat-type-management-page .ant-card-body,
.chat-type-management-page__card {
@@ -61,6 +57,7 @@
flex: 1;
min-height: 0;
overflow: auto;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
.chat-type-management-page__editor-form {
@@ -81,7 +78,11 @@
flex-direction: column;
gap: 4px;
overflow: auto;
padding: 0 0 8px;
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 {
@@ -128,6 +129,55 @@
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;
@@ -190,6 +240,7 @@
.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 {
@@ -236,13 +287,13 @@
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 360px;
min-height: clamp(360px, calc(100dvh - 360px), 720px);
resize: none;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 360px;
min-height: clamp(360px, calc(100dvh - 360px), 720px);
overflow: auto !important;
resize: none;
}
@@ -325,12 +376,17 @@
.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;
}
@@ -414,6 +470,10 @@
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;
}
@@ -421,6 +481,8 @@
.chat-type-management-page__markdown-grid {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
height: 100%;
min-height: 0;
overflow: hidden;
}
@@ -465,6 +527,110 @@
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;
}

View File

@@ -20,6 +20,11 @@ import {
type ChatPermissionRole,
type ChatTypeRecord,
} from './chatTypeAccess';
import {
resolveChatTypeDefaultContextIds,
upsertChatTypeDefaultContextSelection,
useChatContextSettingsRegistry,
} from './chatContextSettingsAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
@@ -57,6 +62,13 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const {
defaultContexts,
chatTypeDefaults,
roomContexts,
errorMessage: contextSettingsErrorMessage,
setStore,
} = useChatContextSettingsRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
@@ -65,6 +77,7 @@ export function ChatTypeManagementPage() {
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none';
@@ -89,7 +102,14 @@ export function ChatTypeManagementPage() {
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
}, [detailMode, form, isCreating, selectedChatType]);
setSelectedDefaultContextIds(
isCreating
? []
: resolveChatTypeDefaultContextIds(chatTypeDefaults, selectedChatType?.id).filter((contextId) =>
defaultContexts.some((context) => context.id === contextId && context.enabled),
),
);
}, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -268,6 +288,7 @@ export function ChatTypeManagementPage() {
>
<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>
@@ -279,6 +300,9 @@ export function ChatTypeManagementPage() {
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'
@@ -322,6 +346,11 @@ export function ChatTypeManagementPage() {
{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>
@@ -341,6 +370,7 @@ export function ChatTypeManagementPage() {
>
<div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<Form
@@ -356,6 +386,14 @@ export function ChatTypeManagementPage() {
try {
const savedChatTypes = await setChatTypes(nextChatTypes);
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
const nextChatTypeDefaults = savedChatType
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
: chatTypeDefaults;
await setStore({
defaultContexts,
chatTypeDefaults: nextChatTypeDefaults,
roomContexts,
});
setIsCreating(false);
setSelectedChatTypeId(savedChatType?.id ?? null);
setDetailMode('detail');
@@ -400,6 +438,60 @@ export function ChatTypeManagementPage() {
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className={`chat-type-management-page__default-context-field${isPaneMaximized ? ' chat-type-management-page__default-context-field--hidden' : ''}`}>
<div className="chat-type-management-page__default-context-header">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
{defaultContexts.filter((context) => context.enabled).length > 0 ? (
<>
<Checkbox.Group
className="chat-type-management-page__default-context-options"
value={selectedDefaultContextIds}
onChange={(checkedValues) => {
setSelectedDefaultContextIds(
checkedValues
.map((value) => String(value).trim())
.filter((value) => defaultContexts.some((context) => context.id === value && context.enabled)),
);
}}
>
<Space direction="vertical" size={10} className="chat-type-management-page__default-context-space">
{defaultContexts
.filter((context) => context.enabled)
.map((context) => (
<label key={context.id} className="chat-type-management-page__default-context-option">
<Checkbox value={context.id}>{context.title}</Checkbox>
<Text type="secondary" className="chat-type-management-page__default-context-option-copy">
{context.content.split('\n')[0]?.replace(/^#+\s*/, '') || '설명 없음'}
</Text>
</label>
))}
</Space>
</Checkbox.Group>
{selectedDefaultContextIds.length > 0 ? (
<div className="chat-type-management-page__default-context-preview">
{selectedDefaultContextIds.map((contextId) => {
const context = defaultContexts.find((item) => item.id === contextId);
return context ? (
<Tag key={`preview-${context.id}`} color="gold">
{context.title}
</Tag>
) : null;
})}
</div>
) : null}
</>
) : (
<Alert
showIcon
type="info"
message="등록된 기본 유형이 없습니다."
description="채팅 관리 > 기본 유형 관리에서 Markdown 스타일 기본 유형을 먼저 등록하세요."
/>
)}
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">

View File

@@ -3,8 +3,8 @@
display: flex;
flex-direction: column;
overflow: hidden;
height: calc(100dvh - 128px);
max-height: calc(100dvh - 128px);
height: 100%;
max-height: 100%;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 30%),
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.12), transparent 34%),
@@ -186,6 +186,147 @@
border-radius: 16px;
}
.app-chat-panel__action-group {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.22);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
}
.app-chat-panel__action-group .ant-btn {
border-radius: 999px;
}
.app-chat-panel__action-group--mobile {
justify-content: flex-end;
min-width: 0;
margin-left: auto;
}
.app-chat-panel__mobile-actions {
display: inline-flex;
align-items: center;
}
.app-chat-panel__context-drawer {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-nav {
flex: 0 0 auto;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-content-holder,
.app-chat-panel__context-drawer-tabs .ant-tabs-content {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-active {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-hidden {
display: none;
}
.app-chat-panel__context-drawer-section {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.18);
}
.app-chat-panel__context-drawer-section--editor {
flex: 1;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-section-head {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 4px;
}
.app-chat-panel__context-drawer-space,
.app-chat-panel__context-drawer-radio,
.app-chat-panel__context-drawer-checkbox {
width: 100%;
}
.app-chat-panel__context-drawer-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.96);
}
.app-chat-panel__context-drawer-card--readonly {
gap: 6px;
}
.app-chat-panel__context-drawer-card-copy {
padding-left: 24px;
}
.app-chat-panel__context-drawer-card-title {
font-weight: 600;
}
.app-chat-panel__context-drawer-textarea-shell {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 220px;
}
.app-chat-panel__context-drawer-textarea {
height: 100%;
flex: 1 1 auto;
min-height: 220px;
resize: none;
}
.app-chat-panel__context-drawer-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-chat-panel__error-layout {
position: relative;
display: flex;
@@ -661,15 +802,15 @@
@media (max-width: 1080px) {
.app-chat-panel {
position: static;
height: calc(100dvh - 112px);
max-height: calc(100dvh - 112px);
height: 100%;
max-height: 100%;
}
}
@media (max-width: 768px) {
.app-chat-panel {
height: calc(100dvh - 76px);
max-height: calc(100dvh - 76px);
height: 100%;
max-height: 100%;
border-radius: 0;
}
@@ -749,4 +890,44 @@
align-items: stretch;
flex-direction: column;
}
.app-chat-panel__action-group {
gap: 4px;
padding: 3px;
}
.app-chat-panel__action-group--mobile {
gap: 6px;
padding: 3px;
}
.app-chat-panel__context-drawer {
flex: 1;
gap: 12px;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-nav {
margin-bottom: 12px;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tab {
padding: 8px 0;
}
.app-chat-panel__context-drawer-section {
padding: 12px;
border-radius: 16px;
}
.app-chat-panel__context-drawer-section--editor {
flex: 1;
}
.app-chat-panel__context-drawer-textarea-shell {
min-height: 0;
}
.app-chat-panel__context-drawer-textarea {
min-height: 320px;
}
}

View File

@@ -39,7 +39,7 @@
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list {
width: 100%;
min-width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
@@ -245,6 +245,12 @@
gap: 6px;
}
.app-chat-panel__conversation-section-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.app-chat-panel__conversation-section-header {
display: flex;
align-items: center;
@@ -253,6 +259,25 @@
padding: 2px 2px 0;
}
.app-chat-panel__conversation-section--reorderable {
position: relative;
transition: transform 0.18s ease;
}
.app-chat-panel__conversation-section-header--reorderable,
.app-chat-panel__conversation-section-toggle--reorderable {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.app-chat-panel__conversation-section-header-main {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.app-chat-panel__conversation-section-header--muted {
margin-top: 6px;
}
@@ -316,6 +341,120 @@
color: #475569;
}
.app-chat-panel__conversation-section-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
color: #0f172a;
text-align: left;
}
.app-chat-panel__conversation-section-mobile-header {
display: flex;
align-items: center;
gap: 8px;
}
.app-chat-panel__conversation-section-mobile-header .app-chat-panel__conversation-section-toggle {
flex: 1 1 auto;
min-width: 0;
}
.app-chat-panel__conversation-section-toggle-main {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.app-chat-panel__conversation-section-toggle-actions,
.app-chat-panel__conversation-section-move-controls {
display: inline-flex;
align-items: center;
gap: 6px;
}
.app-chat-panel__conversation-section-move-controls {
flex-shrink: 0;
}
.app-chat-panel__conversation-section-move-activator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(226, 232, 240, 0.9);
color: #475569;
}
.app-chat-panel__conversation-section-move-activator.is-active {
background: rgba(191, 219, 254, 0.95);
color: #1d4ed8;
}
.app-chat-panel__conversation-section-move-button {
min-width: 0;
padding: 5px 8px;
border: 0;
border-radius: 999px;
background: rgba(226, 232, 240, 0.9);
color: #334155;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.app-chat-panel__conversation-section-move-button:disabled {
background: rgba(226, 232, 240, 0.52);
color: rgba(100, 116, 139, 0.72);
}
.app-chat-panel__conversation-section-move-button:not(:disabled):active {
background: rgba(191, 219, 254, 0.95);
color: #1d4ed8;
}
.app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle-caret {
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
}
.app-chat-panel__conversation-section-toggle.is-open {
box-shadow:
inset 0 0 0 1px rgba(59, 130, 246, 0.18),
0 10px 24px rgba(59, 130, 246, 0.08);
}
.app-chat-panel__conversation-section-toggle--processing .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--processing .app-chat-panel__conversation-section-title {
color: #b45309;
}
.app-chat-panel__conversation-section-toggle--failed .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--failed .app-chat-panel__conversation-section-title {
color: #b91c1c;
}
.app-chat-panel__conversation-section-toggle--unread .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--unread .app-chat-panel__conversation-section-title {
color: #1d4ed8;
}
.app-chat-panel__conversation-item {
position: relative;
display: flex;
@@ -649,16 +788,52 @@
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.18);
}
.app-chat-panel__conversation-item-delete.ant-btn {
.app-chat-panel__conversation-item-flag--section {
color: #475569;
background: rgba(226, 232, 240, 0.76);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
}
.app-chat-panel__conversation-item-flag--request {
color: #0f766e;
background: rgba(204, 251, 241, 0.96);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.16);
}
.app-chat-panel__conversation-item-actions {
display: flex;
flex-direction: column;
align-self: stretch;
justify-content: center;
gap: 2px;
padding: 4px 4px 4px 0;
}
.app-chat-panel__conversation-item-folder.ant-btn,
.app-chat-panel__conversation-item-delete.ant-btn {
flex-shrink: 0;
width: 28px;
min-width: 28px;
height: auto;
margin-right: 4px;
height: 28px;
color: #94a3b8;
}
.app-chat-panel__conversation-item-delete.ant-btn {
margin-right: 0;
}
.app-chat-panel__general-section-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.app-chat-panel__general-section-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-chat-panel__conversation-main {
display: flex;
flex: 1 1 0%;
@@ -948,6 +1123,46 @@
text-align: left;
}
.app-chat-panel__resource-chip-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.app-chat-panel__resource-chip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex: 0 0 22px;
border-radius: 8px;
background: rgba(226, 232, 240, 0.9);
color: #1e293b;
font-size: 12px;
}
.app-chat-panel__resource-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-chat-panel__resource-chip-meta {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.08);
color: #334155;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-chat-panel__title-input {
width: min(240px, 48vw);
}
@@ -1246,6 +1461,32 @@
}
}
@media (min-width: 820px) and (max-width: 1366px) {
.app-chat-panel--ipad-readable .app-chat-message__body,
.app-chat-panel--ipad-readable .app-chat-message__body.ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__block,
.app-chat-panel--ipad-readable .app-chat-message__block .ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__block span,
.app-chat-panel--ipad-readable .app-chat-message__block a {
font-size: 22px !important;
line-height: 1.6;
}
}
@media (min-width: 768px) and (pointer: fine) {
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 19px !important;
}
}
@media (min-width: 768px) and (max-width: 1366px) and (pointer: fine) {
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 22px !important;
}
}
.app-chat-panel__conversation-header {
display: flex;
align-items: center;
@@ -1634,11 +1875,15 @@
gap: 8px;
width: 100%;
max-width: none;
padding: 8px 0 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
padding: 8px 1px 12px;
border: 0;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
box-sizing: border-box;
overflow: clip;
height: auto;
}
@@ -1682,9 +1927,12 @@
}
.app-chat-preview-card--collapsed .app-chat-preview-card__header {
border: 1px solid rgba(148, 163, 184, 0.22);
border: 0;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.app-chat-preview-card__meta {
@@ -1884,8 +2132,9 @@
display: flex;
min-height: 0;
border-top: 1px solid rgba(148, 163, 184, 0.18);
padding-top: 8px;
padding: 8px 0 1px;
width: 100%;
box-sizing: border-box;
}
.app-chat-preview-card--fullscreen {
@@ -1969,6 +2218,8 @@
.app-chat-panel__preview-rich {
width: 100%;
min-width: 0;
padding-bottom: 1px;
box-sizing: border-box;
}
.app-chat-panel__preview-rich .previewer-ui__editor,
@@ -2030,6 +2281,66 @@
min-width: 0;
}
.app-chat-panel__preview-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
min-width: 0;
min-height: 0;
}
.app-chat-panel__preview-table-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.app-chat-panel__preview-table-scroll {
overflow: auto;
max-height: min(420px, 70vh);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
}
.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;
}
.app-chat-panel__preview-table-grid th,
.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;
}
.app-chat-panel__preview-table-grid th {
position: sticky;
top: 0;
z-index: 1;
background: #eff6ff;
color: #1e3a8a;
font-weight: 700;
}
.app-chat-panel__preview-table-grid tbody tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.82);
}
.app-chat-panel__preview-table-grid tbody tr:last-child td {
border-bottom: 0;
}
.app-chat-message__preview-image,
.app-chat-message__preview-video,
.app-chat-message__preview-frame {
@@ -2923,6 +3234,7 @@
.app-chat-panel__conversation-list,
.app-chat-panel__conversation-main {
height: 100%;
min-width: 0;
overflow: hidden;
}
@@ -2949,6 +3261,17 @@
-webkit-overflow-scrolling: touch;
}
.app-chat-panel input,
.app-chat-panel textarea,
.app-chat-panel .ant-input,
.app-chat-panel .ant-input-affix-wrapper input,
.app-chat-panel .ant-select-selection-item,
.app-chat-panel .ant-select-selection-placeholder,
.app-chat-panel .ant-select-selector,
.app-chat-panel .ant-input-textarea textarea.ant-input {
font-size: 16px !important;
}
.app-chat-panel__messages,
.app-chat-panel__composer,
.app-chat-panel__resource-strip {
@@ -2977,15 +3300,21 @@
.app-chat-panel__messages,
.app-chat-panel__preview-stage,
.app-chat-panel__resource-strip {
width: 100%;
min-width: 0;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
}
.app-chat-panel__composer {
width: 100%;
min-width: 0;
padding-left: 10px;
padding-right: 10px;
padding-top: 4px;
padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px));
box-sizing: border-box;
}
.app-chat-panel__composer textarea.ant-input {
@@ -2993,6 +3322,7 @@
min-height: clamp(104px, 16dvh, 136px);
padding-top: 8px;
padding-bottom: 8px;
line-height: 1.5;
}
.app-chat-panel__composer-input-shell {

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@ import { useAppStore } from '../../store';
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
import { ChatDefaultContextManagementPage } from './ChatDefaultContextManagementPage';
import { ResourceManagementPage } from './ResourceManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
@@ -30,6 +32,7 @@ const { Paragraph, Text, Title } = Typography;
export function MainContent({
contentExpanded,
sidebarOverlayActive = false,
onToggleContentExpanded,
children,
}: MainContentProps) {
@@ -204,10 +207,18 @@ export function MainContent({
return <ChatSourceChangesPage />;
}
if (selectionId === 'page:chat:resources') {
return <ResourceManagementPage />;
}
if (selectionId === 'page:chat:manage') {
return <ChatTypeManagementPage />;
}
if (selectionId === 'page:chat:manage-defaults') {
return <ChatDefaultContextManagementPage />;
}
if (selectionId === 'page:play:layout') {
return <LayoutPlaygroundView />;
}
@@ -217,7 +228,13 @@ export function MainContent({
return (
<Content
className={contentExpanded ? 'app-main-content app-main-content--expanded' : 'app-main-content'}
className={[
'app-main-content',
contentExpanded ? 'app-main-content--expanded' : '',
sidebarOverlayActive ? 'app-main-content--under-sidebar-overlay' : '',
]
.filter(Boolean)
.join(' ')}
onClickCapture={(event) => {
handleFocusCapture(event.target);
}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,17 @@
.app-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
max-height: var(--app-viewport-height);
width: 100%;
overflow-x: hidden;
overflow: hidden;
background: transparent;
}
.app-shell:has(.app-chat-panel) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
.app-shell:has(.app-chat-panel) > .ant-layout {
.app-shell__body.ant-layout {
flex: 1 1 auto;
min-height: 0;
height: calc(100dvh - 60px);
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved) > .ant-layout {
min-height: 0;
height: calc(100dvh - 60px);
overflow: hidden;
}
@@ -200,6 +186,7 @@
align-items: center;
gap: 10px;
min-width: 132px;
max-width: min(100%, 240px);
padding: 10px 12px;
border: 0;
border-radius: 14px;
@@ -304,6 +291,130 @@
font-weight: 600;
}
.app-header__settings-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.app-header__settings-meta {
display: block;
min-width: 0;
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-header__restart-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.2), transparent 28%),
linear-gradient(135deg, rgba(2, 6, 23, 0.84), rgba(15, 23, 42, 0.92));
backdrop-filter: blur(10px);
}
.app-header__restart-overlay-card {
width: min(100%, 420px);
padding: 24px 22px;
border: 1px solid rgba(96, 165, 250, 0.2);
border-radius: 26px;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.94), rgba(2, 6, 23, 0.98)),
rgba(15, 23, 42, 0.96);
box-shadow:
0 26px 60px rgba(15, 23, 42, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
color: #e2e8f0;
}
.app-header__restart-overlay-eyebrow {
display: inline-flex;
margin-bottom: 10px;
color: rgba(147, 197, 253, 0.88);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
}
.app-header__restart-overlay-title {
display: block;
margin-bottom: 14px;
color: #f8fafc;
font-size: clamp(22px, 4vw, 28px);
line-height: 1.2;
}
.app-header__restart-overlay-status {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: #bfdbfe;
font-size: 14px;
font-weight: 600;
}
.app-header__restart-overlay-detail {
margin: 0 0 16px;
color: #cbd5e1;
font-size: 13px;
line-height: 1.55;
}
.app-header__restart-overlay-steps {
display: grid;
gap: 10px;
}
.app-header__restart-overlay-step {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 14px;
background: rgba(15, 23, 42, 0.52);
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.app-header__restart-overlay-step-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: currentColor;
flex-shrink: 0;
}
.app-header__restart-overlay-step--done {
color: #38bdf8;
}
.app-header__restart-overlay-step--active {
color: #f8fafc;
border-color: rgba(96, 165, 250, 0.28);
background: rgba(30, 41, 59, 0.86);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.14);
}
.app-header__restart-overlay-step--pending {
color: #64748b;
}
.app-header__settings-group-arrow {
display: inline-flex;
align-items: center;
@@ -432,6 +543,9 @@
}
.app-sider.ant-layout-sider {
height: 100%;
min-height: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.72);
border-right: 1px solid rgba(148, 163, 184, 0.14);
}
@@ -444,7 +558,7 @@
min-width: 100vw !important;
max-width: 100vw;
flex: 0 0 100vw !important;
height: calc(100vh - 72px);
height: calc(var(--app-viewport-height) - 72px);
border-right: 0;
background: rgba(255, 255, 255, 0.98);
transition: none !important;
@@ -479,36 +593,32 @@
.app-main-content.ant-layout-content {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: calc(100dvh - 60px);
min-height: 0;
height: 100%;
max-height: 100%;
width: 100%;
padding: 0;
overflow-x: hidden;
}
.app-main-content.ant-layout-content:has(.app-chat-panel) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved) {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
.app-main-content--under-sidebar-overlay.ant-layout-content {
visibility: hidden;
pointer-events: none;
}
.app-main-content--expanded.ant-layout-content {
position: relative;
display: flex;
min-height: 100vh;
min-height: var(--app-viewport-height);
padding: 20px;
}
.app-main-panel {
display: flex;
min-width: 0;
min-height: 0;
width: 100%;
}
@@ -518,7 +628,7 @@
.app-main-panel--play-saved {
height: 100%;
min-height: calc(100dvh - 60px);
min-height: calc(var(--app-viewport-height) - 60px);
overflow: hidden;
}
@@ -535,18 +645,21 @@
}
.app-main-content--expanded.ant-layout-content:has(.app-main-panel--play-saved) {
min-height: calc(100dvh - 60px);
min-height: calc(var(--app-viewport-height) - 60px);
padding: 0;
overflow: hidden;
}
.app-main-panel:has(.app-chat-panel) {
flex: 1 1 auto;
height: 100%;
min-height: 100%;
overflow: hidden;
}
.app-main-layout:has(.app-chat-panel) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
overflow: hidden;
@@ -557,10 +670,16 @@
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
gap: 16px;
flex: 1;
min-height: 100%;
min-height: 0;
min-width: 0;
height: 100%;
max-height: 100%;
padding: 16px;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.app-main-layout:has(.chat-type-management-page) {
@@ -569,13 +688,124 @@
padding: 4px 12px 12px;
}
.app-main-panel:has(.board-page),
.app-main-panel:has(.history-page),
.app-main-panel:has(.chat-source-changes-page),
.app-main-panel:has(.docs-page) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-layout:has(.board-page),
.app-main-layout:has(.history-page),
.app-main-layout:has(.chat-source-changes-page),
.app-main-layout:has(.docs-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 4px 12px 12px;
height: 100%;
min-height: 0;
}
.app-main-layout:has(.docs-page) {
overflow-y: auto;
overflow-x: hidden;
}
.docs-page {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.docs-page__card,
.docs-page__card.ant-card,
.docs-page__card.ant-card .ant-card-body {
width: 100%;
min-width: 0;
min-height: 0;
}
.docs-page__card.ant-card {
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
}
.docs-page__card.ant-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
.docs-page__scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 0 0 calc(16px + env(safe-area-inset-bottom, 0px));
}
.docs-page__stack {
min-width: 0;
}
.app-main-panel:has(.resource-management-page) {
height: 100%;
min-height: 100%;
overflow: hidden;
}
.app-main-layout:has(.resource-management-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 4px 12px 12px;
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-panel:has(.plan-board-page),
.app-main-panel:has(.plan-schedule-page),
.app-main-panel:has(.release-review-page),
.app-main-panel:has(.server-command-page),
.app-main-panel:has(.test-play-app),
.app-main-panel:has(.layout-playground__editor-card) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-layout:has(.plan-board-page),
.app-main-layout:has(.plan-schedule-page),
.app-main-layout:has(.release-review-page),
.app-main-layout:has(.server-command-page),
.app-main-layout:has(.test-play-app),
.app-main-layout:has(.layout-playground__editor-card) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-panel--play:has(.test-play-app),
.app-main-panel--play:has(.layout-playground__editor-card) {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 720px) {
html,
body,
#root {
height: 100dvh;
height: var(--app-viewport-height);
overflow-x: hidden;
overflow-y: auto;
overflow-y: hidden;
}
html:has(.chat-type-management-page),
@@ -595,12 +825,16 @@
.app-shell,
.app-main-content.ant-layout-content {
overflow-x: hidden;
overflow-y: auto;
overflow-y: hidden;
}
.app-main-panel,
.app-main-layout {
overflow: visible;
overflow: hidden;
}
.app-main-layout {
overflow-y: auto;
}
.app-main-panel:has(.app-chat-panel),
@@ -609,12 +843,128 @@
}
.app-shell:has(.chat-type-management-page),
.app-shell:has(.chat-type-management-page) > .ant-layout {
width: 100%;
min-width: 100%;
max-width: 100%;
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
overflow: hidden;
}
.app-main-content.ant-layout-content:has(.chat-type-management-page),
.app-main-panel:has(.chat-type-management-page),
.app-main-layout:has(.chat-type-management-page),
.chat-type-management-page,
.chat-type-management-page__card {
width: 100%;
min-width: 100%;
max-width: 100%;
}
.app-main-content.ant-layout-content:has(.chat-type-management-page),
.app-main-panel:has(.chat-type-management-page),
.app-main-layout:has(.chat-type-management-page) {
height: calc(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
.app-shell:has(.resource-management-page),
.app-shell:has(.resource-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.resource-management-page),
.app-main-panel:has(.resource-management-page),
.app-main-layout:has(.resource-management-page) {
width: 100%;
min-width: 100%;
max-width: 100%;
}
.app-shell:has(.resource-management-page),
.app-shell:has(.resource-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.resource-management-page),
.app-main-panel:has(.resource-management-page),
.app-main-layout:has(.resource-management-page) {
height: calc(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
.app-shell:has(.chat-type-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.chat-type-management-page),
.app-main-panel:has(.chat-type-management-page),
.app-main-layout:has(.chat-type-management-page) {
height: calc(100dvh - 52px);
min-height: calc(100dvh - 52px);
width: 100%;
}
.app-shell:has(.board-page),
.app-shell:has(.board-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.board-page),
.app-main-panel:has(.board-page),
.app-main-layout:has(.board-page),
.app-shell:has(.history-page),
.app-shell:has(.history-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.history-page),
.app-main-panel:has(.history-page),
.app-main-layout:has(.history-page),
.app-shell:has(.chat-source-changes-page),
.app-shell:has(.chat-source-changes-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.chat-source-changes-page),
.app-main-panel:has(.chat-source-changes-page),
.app-main-layout:has(.chat-source-changes-page),
.app-shell:has(.docs-page),
.app-shell:has(.docs-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.docs-page),
.app-main-panel:has(.docs-page),
.app-main-layout:has(.docs-page) {
height: 100%;
min-height: 0;
}
.app-shell:has(.docs-page),
.app-shell:has(.docs-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.docs-page),
.app-main-panel:has(.docs-page) {
overflow: hidden;
}
.app-main-layout:has(.docs-page) {
overflow-y: auto;
overflow-x: hidden;
}
.app-shell:has(.plan-board-page),
.app-shell:has(.plan-board-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.plan-board-page),
.app-main-panel:has(.plan-board-page),
.app-main-layout:has(.plan-board-page),
.app-shell:has(.plan-schedule-page),
.app-shell:has(.plan-schedule-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.plan-schedule-page),
.app-main-panel:has(.plan-schedule-page),
.app-main-layout:has(.plan-schedule-page),
.app-shell:has(.release-review-page),
.app-shell:has(.release-review-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.release-review-page),
.app-main-panel:has(.release-review-page),
.app-main-layout:has(.release-review-page),
.app-shell:has(.server-command-page),
.app-shell:has(.server-command-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.server-command-page),
.app-main-panel:has(.server-command-page),
.app-main-layout:has(.server-command-page),
.app-shell:has(.test-play-app),
.app-shell:has(.test-play-app) > .ant-layout,
.app-main-content.ant-layout-content:has(.test-play-app),
.app-main-panel:has(.test-play-app),
.app-main-layout:has(.test-play-app),
.app-shell:has(.layout-playground__editor-card),
.app-shell:has(.layout-playground__editor-card) > .ant-layout,
.app-main-content.ant-layout-content:has(.layout-playground__editor-card),
.app-main-panel:has(.layout-playground__editor-card),
.app-main-layout:has(.layout-playground__editor-card) {
height: 100%;
min-height: 0;
overflow: hidden;
}
@@ -623,8 +973,8 @@
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
.app-main-layout:has(.app-main-panel--play-saved),
.app-main-panel--play-saved {
height: calc(100dvh - 52px);
min-height: calc(100dvh - 52px);
height: calc(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
@@ -718,7 +1068,7 @@
position: relative;
width: 100%;
height: 100%;
min-height: calc(100dvh - 92px);
min-height: calc(var(--app-viewport-height) - 92px);
overflow: hidden;
border-radius: 24px;
}
@@ -806,14 +1156,14 @@
@media (max-width: 1180px) {
.app-main-panel:has(.app-chat-panel) {
height: calc(100dvh - 60px);
min-height: calc(100dvh - 60px);
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-layout:has(.app-chat-panel) {
height: calc(100dvh - 60px);
min-height: calc(100dvh - 60px);
height: 100%;
min-height: 0;
padding: 0;
gap: 0;
overflow: hidden;
@@ -821,10 +1171,6 @@
}
@media (max-width: 768px) {
.app-shell:has(.app-chat-panel) > .ant-layout {
height: calc(100dvh - 52px);
}
.app-header {
padding: 6px 10px;
height: 52px;
@@ -874,7 +1220,7 @@
.app-sider--mobile.ant-layout-sider {
position: fixed;
inset: 52px 0 0;
height: calc(100vh - 52px);
height: calc(var(--app-viewport-height) - 52px);
}
.app-sider--mobile-inline.ant-layout-sider {
@@ -884,21 +1230,33 @@
.app-main-content.ant-layout-content {
padding: 0;
min-height: calc(100dvh - 52px);
}
.app-main-layout {
min-height: calc(100dvh - 52px);
padding: 8px;
gap: 8px;
.app-main-layout,
.app-main-layout:has(.chat-type-management-page),
.app-main-layout:has(.docs-page),
.app-main-layout:has(.resource-management-page),
.app-main-layout:has(.board-page),
.app-main-layout:has(.history-page),
.app-main-layout:has(.chat-source-changes-page),
.app-main-layout:has(.plan-board-page),
.app-main-layout:has(.plan-schedule-page),
.app-main-layout:has(.release-review-page),
.app-main-layout:has(.server-command-page),
.app-main-layout:has(.test-play-app),
.app-main-layout:has(.layout-playground__editor-card),
.app-main-layout:has(.app-main-panel--play-saved) {
width: 100%;
padding: 0;
gap: 0;
}
.app-main-panel--play-saved {
min-height: calc(100dvh - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
}
.app-main-layout:has(.app-main-panel--play-saved) {
min-height: calc(100dvh - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
}
.app-main-layout:has(.chat-type-management-page) {
@@ -906,12 +1264,17 @@
gap: 0;
}
.app-main-layout:has(.docs-page) {
padding: 0;
gap: 0;
}
.app-main-window-layer {
inset: 8px;
}
.app-main-window-layer__stage {
min-height: calc(100dvh - 68px);
min-height: calc(var(--app-viewport-height) - 68px);
border-radius: 18px;
}
@@ -935,8 +1298,28 @@
padding: 10px 16px 16px;
}
.docs-page__scroll {
padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
}
.app-main-panel:has(.app-chat-panel) {
height: calc(100dvh - 76px);
min-height: calc(100dvh - 76px);
height: 100%;
min-height: 0;
}
.app-main-layout:has(.plan-board-page),
.app-main-layout:has(.plan-schedule-page),
.app-main-layout:has(.release-review-page),
.app-main-layout:has(.server-command-page),
.app-main-layout:has(.test-play-app),
.app-main-layout:has(.layout-playground__editor-card) {
overflow: hidden;
}
.app-main-panel:has(.test-play-app),
.app-main-panel:has(.layout-playground__editor-card) {
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -89,7 +89,7 @@ export function MainSidebar({
: [...(docsMenuItems ?? []), ...(apiMenuItems ?? [])]
: effectiveTopMenu === 'play'
? [...(playMenuItems ?? [])]
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
: [...(chatMenuItems ?? []), ...(planMenuItems ?? [])];
const rootKeys = sidebarItems.flatMap((item) =>
item && typeof item === 'object' && 'key' in item && typeof item.key === 'string' ? [item.key] : [],
);

View File

@@ -0,0 +1,657 @@
.resource-management-page {
position: relative;
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(320px, 1fr) minmax(360px, 1.08fr);
gap: 16px;
height: 100%;
min-height: 0;
overflow: hidden;
-webkit-touch-callout: none;
}
.resource-management-page__mobile-nav,
.resource-management-page__mobile-card {
display: none;
}
.resource-management-page__sidebar,
.resource-management-page__content,
.resource-management-page__preview-card {
min-height: 0;
border-radius: 22px;
overflow: clip;
box-sizing: border-box;
}
.resource-management-page__sidebar.ant-card,
.resource-management-page__content.ant-card,
.resource-management-page__preview-card.ant-card {
display: flex;
flex-direction: column;
min-height: 0;
border: 0;
box-shadow:
inset 0 0 0 1px rgba(191, 204, 229, 0.92),
0 16px 40px rgba(15, 23, 42, 0.08);
}
.resource-management-page__sidebar .ant-card-head,
.resource-management-page__content .ant-card-head,
.resource-management-page__preview-card .ant-card-head {
min-height: 58px;
}
.resource-management-page__sidebar .ant-card-body,
.resource-management-page__content .ant-card-body,
.resource-management-page__preview-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
padding: 16px;
min-height: 0;
height: auto;
}
.resource-management-page__scope-copy {
display: block;
}
.resource-management-page__tree {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.resource-management-page__tree .ant-tree-node-content-wrapper {
width: 100%;
border-radius: 12px;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__tree-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__content {
min-height: 0;
}
.resource-management-page__preview-card {
min-height: 0;
}
.resource-management-page__workspace {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.resource-management-page__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.resource-management-page__toolbar-main,
.resource-management-page__toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.resource-management-page__guide {
margin: 0;
}
.resource-management-page__list-shell {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border: 0;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(249, 251, 255, 0.96), rgba(241, 246, 255, 0.92)),
#fff;
box-shadow: inset 0 0 0 1px #dfe6f4;
overflow: clip;
box-sizing: border-box;
-webkit-touch-callout: none;
}
.resource-management-page__list-header,
.resource-management-page__list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px 88px 76px;
gap: 12px;
align-items: center;
}
.resource-management-page__list-header {
padding: 12px 16px;
border-bottom: 1px solid #e9eef8;
color: #6b7280;
font-size: 12px;
font-weight: 600;
}
.resource-management-page__list-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.resource-management-page__list-row {
padding: 12px 16px;
border-bottom: 1px solid #eef2fa;
cursor: pointer;
transition: background-color 0.18s ease, transform 0.18s ease;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__list-row:hover {
background: rgba(222, 234, 255, 0.42);
}
.resource-management-page__list-row--selected {
background: rgba(186, 209, 255, 0.34);
}
.resource-management-page__list-row--parent {
background: rgba(241, 245, 255, 0.92);
}
.resource-management-page__list-name,
.resource-management-page__entry-name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.resource-management-page__list-meta {
display: contents;
}
.resource-management-page__entry-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-management-page__preview-card .ant-card-body {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
padding-bottom: 17px;
}
.resource-management-page__preview-frame {
width: 100%;
min-height: 0;
flex: 1;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
}
.resource-management-page__text-preview {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
color: #111827;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.resource-management-page__image-preview {
width: 100%;
max-height: 100%;
object-fit: contain;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
}
.resource-management-page__zoom-shell,
.resource-management-page__preview-modal-body {
display: flex;
flex: 1;
min-height: 0;
}
.resource-management-page__zoom-shell {
align-items: center;
justify-content: center;
overflow: clip;
border: 0;
border-radius: 16px;
background: #0b1220;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
scrollbar-gutter: stable both-edges;
}
.resource-management-page__zoom-shell--touch-zoom {
touch-action: none;
overscroll-behavior: contain;
}
.resource-management-page__zoom-shell--image {
padding: 16px;
}
.resource-management-page__image-preview--zoomable {
width: 100%;
height: 100%;
object-fit: contain;
border: 0;
border-radius: 0;
transform-origin: center center;
will-change: transform;
}
.resource-management-page__frame-zoom-shell {
width: 100%;
min-width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
transform-origin: center center;
will-change: transform;
}
.resource-management-page__preview-frame--zoomable {
min-width: 100%;
min-height: 100%;
border: 0;
border-radius: 0;
transform-origin: center center;
pointer-events: none;
}
.resource-management-page__preview-modal-toolbar {
display: flex;
align-items: center;
justify-content: stretch;
gap: 12px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid rgba(217, 225, 242, 0.9);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(14px);
flex: 0 0 auto;
min-height: 72px;
box-sizing: border-box;
}
.resource-management-page__preview-modal-toolbar-slider {
flex: 1 1 auto;
min-width: 0;
}
.resource-management-page__preview-modal-toolbar-slider .ant-slider {
margin: 0;
}
.resource-management-page__preview-modal-toolbar-button {
width: 44px;
min-width: 44px;
height: 44px;
padding-inline: 0;
flex: 0 0 44px;
}
.resource-management-page__preview-modal-toolbar-value {
flex: 0 0 56px;
width: 56px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.resource-management-page__preview-modal-body {
overflow: hidden;
background: #0b1220;
}
.resource-management-page__preview-modal-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.resource-management-page__preview-modal-shell .resource-management-page__preview-modal-body {
flex: 1 1 auto;
min-height: 0;
padding: 0;
}
.resource-management-page__editor {
min-height: 260px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.resource-management-page__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
padding: 24px 16px;
}
.resource-management-page__context-menu {
position: fixed;
z-index: 1200;
display: flex;
flex-direction: column;
min-width: 208px;
padding: 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(18px);
}
.resource-management-page__context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 1199;
background: transparent;
}
.resource-management-page__context-menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: #111827;
font: inherit;
text-align: left;
}
.resource-management-page__context-menu button:hover {
background: rgba(222, 234, 255, 0.42);
}
.resource-management-page__context-menu button.danger {
color: #b42318;
}
.resource-management-page__context-menu button.danger:hover {
background: rgba(254, 226, 226, 0.72);
}
.resource-management-page__file-input {
position: fixed;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
inset: auto auto -100vh -100vw;
}
@media (max-width: 1180px) {
.resource-management-page {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.resource-management-page__preview-card {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.resource-management-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.resource-management-page--mobile {
min-height: 0;
padding-inline: 1px;
padding-bottom: max(10px, calc(env(safe-area-inset-bottom, 0px) + 6px));
box-sizing: border-box;
}
.resource-management-page--mobile > .resource-management-page__sidebar,
.resource-management-page--mobile > .resource-management-page__content,
.resource-management-page--mobile > .resource-management-page__preview-card {
display: none;
}
.resource-management-page__mobile-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
flex: 0 0 auto;
}
.resource-management-page__mobile-card {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.resource-management-page__mobile-card > .ant-card {
flex: 1 1 auto;
min-height: 0;
height: auto;
}
.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;
}
.resource-management-page__list-header {
display: none;
}
.resource-management-page__list-row {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'name actions'
'meta meta';
row-gap: 6px;
}
.resource-management-page__list-name {
grid-area: name;
}
.resource-management-page__list-meta {
grid-area: meta;
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: #6b7280;
}
.resource-management-page__list-meta span {
font-size: 12px;
}
.resource-management-page__list-row > .ant-space {
grid-area: actions;
justify-self: end;
}
.resource-management-page__context-menu {
min-width: min(208px, calc(100vw - 24px));
}
.resource-management-page__toolbar {
align-items: stretch;
}
.resource-management-page__toolbar-main,
.resource-management-page__toolbar-actions {
width: 100%;
}
.resource-management-page__toolbar-actions .ant-btn {
flex: 1 1 0;
}
.resource-management-page__preview-frame,
.resource-management-page__image-preview,
.resource-management-page__text-preview {
min-height: 220px;
}
.resource-management-page__editor {
min-height: 0;
height: 100%;
}
.resource-management-page__preview-card .ant-tabs,
.resource-management-page__preview-card .ant-tabs-content-holder,
.resource-management-page__preview-card .ant-tabs-content,
.resource-management-page__preview-card .ant-tabs-tabpane {
min-height: 0;
height: 100%;
}
.resource-management-page__preview-card .ant-tabs-content-holder {
overflow: hidden;
padding-bottom: 1px;
box-sizing: border-box;
}
.resource-management-page__preview-card .ant-tabs-tabpane-active {
display: flex;
flex-direction: column;
}
.resource-management-page--panel-preview .resource-management-page__preview-card .ant-card-body {
gap: 8px;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal {
width: 100vw !important;
max-width: 100vw;
margin: 0;
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-content {
border-radius: 0;
min-height: 100dvh;
background: #0b1220;
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-header {
padding: calc(12px + env(safe-area-inset-top, 0px)) 64px 12px 16px;
margin-bottom: 0;
background: #0b1220;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-title {
color: #f8fafc;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-close {
top: calc(8px + env(safe-area-inset-top, 0px));
right: 8px;
width: 44px;
height: 44px;
color: #f8fafc;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(226, 232, 240, 0.22);
border-radius: 999px;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-close:hover,
.resource-management-page__preview-modal-wrap--mobile .ant-modal-close:focus-visible {
color: #ffffff;
background: rgba(30, 41, 59, 0.98);
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-body {
height: calc(100dvh - 56px) !important;
background: #0b1220;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-shell .resource-management-page__preview-modal-body {
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__zoom-shell {
border: 0;
border-radius: 0;
min-height: 100%;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__zoom-shell--image {
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-frame,
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__image-preview {
border: 0;
border-radius: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-toolbar {
gap: 10px;
padding-inline: 12px;
min-height: 76px;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-toolbar .ant-btn {
flex: 0 0 auto;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-toolbar-value {
flex-basis: 52px;
width: 52px;
}
}

File diff suppressed because it is too large Load Diff

641
src/app/main/appConfig.js Normal file
View File

@@ -0,0 +1,641 @@
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_APP_CONFIG = exports.APP_CONFIG_STORAGE_KEY = void 0;
exports.fetchAppConfigFromServer = fetchAppConfigFromServer;
exports.saveAppConfigToServer = saveAppConfigToServer;
exports.saveAutomationNotificationPreferenceToServer = saveAutomationNotificationPreferenceToServer;
exports.syncAppConfigFromServer = syncAppConfigFromServer;
exports.getStoredAppConfig = getStoredAppConfig;
exports.setStoredAppConfig = setStoredAppConfig;
exports.updateStoredAppConfig = updateStoredAppConfig;
exports.useAppConfig = useAppConfig;
exports.describeAutoReceiveSchedule = describeAutoReceiveSchedule;
exports.getWeeklyScheduleOptions = getWeeklyScheduleOptions;
var react_1 = require("react");
var clientIdentity_1 = require("./clientIdentity");
var notificationIdentity_1 = require("./notificationIdentity");
exports.APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
var APP_CONFIG_EVENT = 'work-server:app-config';
var APP_CONFIG_API_PATH = '/app-config';
var AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';
var APP_CONFIG_REQUEST_TIMEOUT_MS = 8000;
var cachedConfig = null;
var cachedRawConfig = null;
exports.DEFAULT_APP_CONFIG = {
chat: {
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
},
automation: {
autoRefreshEnabled: true,
autoRefreshIntervalSeconds: 5,
autoReceiveScheduleType: 'interval',
autoReceiveIntervalSeconds: 30,
autoReceiveDailyTime: '09:00',
autoReceiveWeeklyDay: 'mon',
autoReceiveWeeklyTime: '09:00',
notifyOnAutomationStart: true,
notifyOnAutomationProgress: true,
notifyOnAutomationCompletion: true,
notifyOnAutomationRelease: true,
notifyOnAutomationMain: true,
notifyOnAutomationFailure: true,
notifyOnAutomationRestart: true,
notifyOnAutomationIssueResolved: true,
},
worklogAutomation: {
autoCreateDailyWorklog: false,
dailyCreateTime: '18:00',
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
includeScreenshots: true,
includeChangedFiles: true,
includeCommandLogs: true,
template: 'detailed',
},
planDefaults: {
jangsingProcessingRequired: true,
autoDeployToMain: true,
openEditorAfterCreate: true,
},
planCost: {
baseCostPerMillionTokens: 10000,
retryCostMultiplierPercent: 15,
hourlyCostMultiplierPercent: 0,
timeCostUnit: 'hour',
attentionCostThresholdMultiplier: 1,
warningCostThresholdMultiplier: 2,
highCostThresholdMultiplier: 4,
},
gestureShortcuts: {
openSearch: 'Mod+K',
openWindowSearch: 'Mod+Shift+K',
},
};
var WEEKLY_DAY_LABELS = {
mon: '월요일',
tue: '화요일',
wed: '수요일',
thu: '목요일',
fri: '금요일',
sat: '토요일',
sun: '일요일',
};
var AUTOMATION_NOTIFICATION_KEYS = [
'notifyOnAutomationStart',
'notifyOnAutomationProgress',
'notifyOnAutomationCompletion',
'notifyOnAutomationRelease',
'notifyOnAutomationMain',
'notifyOnAutomationFailure',
'notifyOnAutomationRestart',
'notifyOnAutomationIssueResolved',
];
function clampIntervalSeconds(value, fallback) {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(3600, Math.max(1, Math.round(value)));
}
function normalizeTimeValue(value, fallback) {
if (/^\d{2}:\d{2}$/.test(value)) {
return value;
}
return fallback;
}
function normalizeScheduleType(value) {
if (value === 'daily' || value === 'weekly') {
return value;
}
return 'interval';
}
function normalizeWeeklyDay(value) {
if (value && value in WEEKLY_DAY_LABELS) {
return value;
}
return exports.DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyDay;
}
function normalizeWorklogTemplate(value) {
if (value === 'simple') {
return 'simple';
}
return 'detailed';
}
function normalizeShortcutValue(value, fallback) {
var trimmed = value === null || value === void 0 ? void 0 : value.trim();
if (!trimmed) {
return fallback;
}
return trimmed
.split('+')
.map(function (token) { return token.trim(); })
.filter(Boolean)
.join('+');
}
function normalizePlanCostValue(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(1000000, Math.max(100, Math.round(value)));
}
function normalizePlanCostMultiplierValue(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(100, Math.max(0.1, Math.round(value * 10) / 10));
}
function normalizePlanCostPercentValue(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(500, Math.max(0, Math.round(value)));
}
function normalizePlanCostTimeUnit(value) {
if (value === 'minute' || value === 'second') {
return value;
}
return 'hour';
}
function normalizeChatContextMessageLimit(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(50, Math.max(1, Math.round(value)));
}
function normalizeChatContextCharLimit(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(20000, Math.max(500, Math.round(value)));
}
function normalizeCodexLiveMaxExecutionSeconds(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(7200, Math.max(60, Math.round(value)));
}
function normalizeCodexLiveIdleTimeoutSeconds(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(3600, Math.max(30, Math.round(value)));
}
function normalizeBooleanValue(value, fallback) {
if (typeof value !== 'boolean') {
return fallback;
}
return value;
}
function normalizeConfig(raw) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
var chat = raw === null || raw === void 0 ? void 0 : raw.chat;
var automation = raw === null || raw === void 0 ? void 0 : raw.automation;
var worklogAutomation = raw === null || raw === void 0 ? void 0 : raw.worklogAutomation;
var planDefaults = raw === null || raw === void 0 ? void 0 : raw.planDefaults;
var planCost = raw === null || raw === void 0 ? void 0 : raw.planCost;
var gestureShortcuts = raw === null || raw === void 0 ? void 0 : raw.gestureShortcuts;
return {
chat: {
maxContextMessages: normalizeChatContextMessageLimit(chat === null || chat === void 0 ? void 0 : chat.maxContextMessages, exports.DEFAULT_APP_CONFIG.chat.maxContextMessages),
maxContextChars: normalizeChatContextCharLimit(chat === null || chat === void 0 ? void 0 : chat.maxContextChars, exports.DEFAULT_APP_CONFIG.chat.maxContextChars),
codexLiveMaxExecutionSeconds: normalizeCodexLiveMaxExecutionSeconds(chat === null || chat === void 0 ? void 0 : chat.codexLiveMaxExecutionSeconds, exports.DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds),
codexLiveIdleTimeoutSeconds: normalizeCodexLiveIdleTimeoutSeconds(chat === null || chat === void 0 ? void 0 : chat.codexLiveIdleTimeoutSeconds, exports.DEFAULT_APP_CONFIG.chat.codexLiveIdleTimeoutSeconds),
receiveRoomNotifications: normalizeBooleanValue(chat === null || chat === void 0 ? void 0 : chat.receiveRoomNotifications, exports.DEFAULT_APP_CONFIG.chat.receiveRoomNotifications),
},
automation: {
autoRefreshEnabled: (_a = automation === null || automation === void 0 ? void 0 : automation.autoRefreshEnabled) !== null && _a !== void 0 ? _a : exports.DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
autoRefreshIntervalSeconds: clampIntervalSeconds((_b = automation === null || automation === void 0 ? void 0 : automation.autoRefreshIntervalSeconds) !== null && _b !== void 0 ? _b : exports.DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds, exports.DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds),
autoReceiveScheduleType: normalizeScheduleType(automation === null || automation === void 0 ? void 0 : automation.autoReceiveScheduleType),
autoReceiveIntervalSeconds: clampIntervalSeconds((_c = automation === null || automation === void 0 ? void 0 : automation.autoReceiveIntervalSeconds) !== null && _c !== void 0 ? _c : exports.DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds, exports.DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds),
autoReceiveDailyTime: normalizeTimeValue((_d = automation === null || automation === void 0 ? void 0 : automation.autoReceiveDailyTime) !== null && _d !== void 0 ? _d : exports.DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime, exports.DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime),
autoReceiveWeeklyDay: normalizeWeeklyDay(automation === null || automation === void 0 ? void 0 : automation.autoReceiveWeeklyDay),
autoReceiveWeeklyTime: normalizeTimeValue((_e = automation === null || automation === void 0 ? void 0 : automation.autoReceiveWeeklyTime) !== null && _e !== void 0 ? _e : exports.DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime, exports.DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime),
notifyOnAutomationStart: (_f = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationStart) !== null && _f !== void 0 ? _f : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationStart,
notifyOnAutomationProgress: (_g = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationProgress) !== null && _g !== void 0 ? _g : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationProgress,
notifyOnAutomationCompletion: (_h = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationCompletion) !== null && _h !== void 0 ? _h : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationCompletion,
notifyOnAutomationRelease: (_j = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRelease) !== null && _j !== void 0 ? _j : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationRelease,
notifyOnAutomationMain: (_k = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationMain) !== null && _k !== void 0 ? _k : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationMain,
notifyOnAutomationFailure: (_l = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationFailure) !== null && _l !== void 0 ? _l : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationFailure,
notifyOnAutomationRestart: (_m = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRestart) !== null && _m !== void 0 ? _m : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationRestart,
notifyOnAutomationIssueResolved: (_o = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationIssueResolved) !== null && _o !== void 0 ? _o : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationIssueResolved,
},
worklogAutomation: {
autoCreateDailyWorklog: (_p = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.autoCreateDailyWorklog) !== null && _p !== void 0 ? _p : exports.DEFAULT_APP_CONFIG.worklogAutomation.autoCreateDailyWorklog,
dailyCreateTime: normalizeTimeValue((_q = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.dailyCreateTime) !== null && _q !== void 0 ? _q : exports.DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime, exports.DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime),
repeatRequestEnabled: false,
repeatIntervalMinutes: exports.DEFAULT_APP_CONFIG.worklogAutomation.repeatIntervalMinutes,
includeScreenshots: (_r = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.includeScreenshots) !== null && _r !== void 0 ? _r : exports.DEFAULT_APP_CONFIG.worklogAutomation.includeScreenshots,
includeChangedFiles: (_s = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.includeChangedFiles) !== null && _s !== void 0 ? _s : exports.DEFAULT_APP_CONFIG.worklogAutomation.includeChangedFiles,
includeCommandLogs: (_t = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.includeCommandLogs) !== null && _t !== void 0 ? _t : exports.DEFAULT_APP_CONFIG.worklogAutomation.includeCommandLogs,
template: normalizeWorklogTemplate(worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.template),
},
planDefaults: {
jangsingProcessingRequired: (_u = planDefaults === null || planDefaults === void 0 ? void 0 : planDefaults.jangsingProcessingRequired) !== null && _u !== void 0 ? _u : exports.DEFAULT_APP_CONFIG.planDefaults.jangsingProcessingRequired,
autoDeployToMain: (_v = planDefaults === null || planDefaults === void 0 ? void 0 : planDefaults.autoDeployToMain) !== null && _v !== void 0 ? _v : exports.DEFAULT_APP_CONFIG.planDefaults.autoDeployToMain,
openEditorAfterCreate: (_w = planDefaults === null || planDefaults === void 0 ? void 0 : planDefaults.openEditorAfterCreate) !== null && _w !== void 0 ? _w : exports.DEFAULT_APP_CONFIG.planDefaults.openEditorAfterCreate,
},
planCost: {
baseCostPerMillionTokens: normalizePlanCostValue(planCost === null || planCost === void 0 ? void 0 : planCost.baseCostPerMillionTokens, exports.DEFAULT_APP_CONFIG.planCost.baseCostPerMillionTokens),
retryCostMultiplierPercent: normalizePlanCostPercentValue(planCost === null || planCost === void 0 ? void 0 : planCost.retryCostMultiplierPercent, exports.DEFAULT_APP_CONFIG.planCost.retryCostMultiplierPercent),
hourlyCostMultiplierPercent: normalizePlanCostPercentValue(planCost === null || planCost === void 0 ? void 0 : planCost.hourlyCostMultiplierPercent, exports.DEFAULT_APP_CONFIG.planCost.hourlyCostMultiplierPercent),
timeCostUnit: normalizePlanCostTimeUnit(planCost === null || planCost === void 0 ? void 0 : planCost.timeCostUnit),
attentionCostThresholdMultiplier: normalizePlanCostMultiplierValue(planCost === null || planCost === void 0 ? void 0 : planCost.attentionCostThresholdMultiplier, exports.DEFAULT_APP_CONFIG.planCost.attentionCostThresholdMultiplier),
warningCostThresholdMultiplier: normalizePlanCostMultiplierValue(planCost === null || planCost === void 0 ? void 0 : planCost.warningCostThresholdMultiplier, exports.DEFAULT_APP_CONFIG.planCost.warningCostThresholdMultiplier),
highCostThresholdMultiplier: normalizePlanCostMultiplierValue(planCost === null || planCost === void 0 ? void 0 : planCost.highCostThresholdMultiplier, exports.DEFAULT_APP_CONFIG.planCost.highCostThresholdMultiplier),
},
gestureShortcuts: {
openSearch: normalizeShortcutValue(gestureShortcuts === null || gestureShortcuts === void 0 ? void 0 : gestureShortcuts.openSearch, exports.DEFAULT_APP_CONFIG.gestureShortcuts.openSearch),
openWindowSearch: normalizeShortcutValue(gestureShortcuts === null || gestureShortcuts === void 0 ? void 0 : gestureShortcuts.openWindowSearch, exports.DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch),
},
};
}
function pickAutomationNotificationSettings(automation) {
return AUTOMATION_NOTIFICATION_KEYS.reduce(function (picked, key) {
picked[key] = automation[key];
return picked;
}, {});
}
function mergeAutomationNotificationSettings(config, automation) {
if (!automation) {
return config;
}
return normalizeConfig(__assign(__assign({}, config), { automation: __assign(__assign({}, config.automation), automation) }));
}
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
function emitConfigChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(APP_CONFIG_EVENT));
}
function resolveAppConfigApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveAppConfigFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
var isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl();
var APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl();
var AppConfigApiError = /** @class */ (function (_super) {
__extends(AppConfigApiError, _super);
function AppConfigApiError(message, status) {
var _this = _super.call(this, message) || this;
_this.name = 'AppConfigApiError';
_this.status = status;
return _this;
}
return AppConfigApiError;
}(Error));
function requestAppConfigOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, controller, timeoutId, appOrigin, appDomain, response, error_1, text, payload, contentType, text;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
controller = new AbortController();
timeoutId = setTimeout(function () { return controller.abort(); }, APP_CONFIG_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
appOrigin = getCurrentAppOrigin();
appDomain = getCurrentAppDomain();
if (appOrigin && !headers.has('X-App-Origin')) {
headers.set('X-App-Origin', appOrigin);
}
if (appDomain && !headers.has('X-App-Domain')) {
headers.set('X-App-Domain', appDomain);
}
_e.label = 1;
case 1:
_e.trys.push([1, 3, , 4]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _e.sent();
return [3 /*break*/, 4];
case 3:
error_1 = _e.sent();
clearTimeout(timeoutId);
if (error_1 instanceof DOMException && error_1.name === 'AbortError') {
throw new AppConfigApiError('서버 응답이 지연됩니다.', 408);
}
throw error_1;
case 4:
clearTimeout(timeoutId);
if (!!response.ok) return [3 /*break*/, 6];
return [4 /*yield*/, response.text()];
case 5:
text = _e.sent();
try {
payload = JSON.parse(text);
throw new AppConfigApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
}
catch (_f) {
throw new AppConfigApiError(text || '요청 처리에 실패했습니다.', response.status);
}
_e.label = 6;
case 6:
contentType = (_d = response.headers.get('content-type')) !== null && _d !== void 0 ? _d : '';
if (!!contentType.toLowerCase().includes('application/json')) return [3 /*break*/, 8];
return [4 /*yield*/, response.text()];
case 7:
text = _e.sent();
throw new AppConfigApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
case 8: return [2 /*return*/, response.json()];
}
});
});
}
function requestAppConfig(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_2, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestAppConfigOnce(APP_CONFIG_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_2 = _a.sent();
shouldRetryWithFallback = APP_CONFIG_FALLBACK_BASE_URL &&
APP_CONFIG_FALLBACK_BASE_URL !== APP_CONFIG_API_BASE_URL &&
(error_2 instanceof AppConfigApiError
? error_2.status === 404 || error_2.status === 408 || error_2.status === 502
: error_2 instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error_2.message));
if (!shouldRetryWithFallback) {
throw error_2;
}
return [2 /*return*/, requestAppConfigOnce(APP_CONFIG_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function fetchAppConfigFromServer() {
return __awaiter(this, void 0, void 0, function () {
var response, config, preference, _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_b.trys.push([0, 3, , 4]);
return [4 /*yield*/, requestAppConfig(APP_CONFIG_API_PATH)];
case 1:
response = _b.sent();
config = normalizeConfig(response.config);
return [4 /*yield*/, fetchAutomationNotificationPreferenceFromServer()];
case 2:
preference = _b.sent();
return [2 /*return*/, mergeAutomationNotificationSettings(config, preference)];
case 3:
_a = _b.sent();
return [2 /*return*/, null];
case 4: return [2 /*return*/];
}
});
});
}
function fetchAutomationNotificationPreferenceFromServer() {
return __awaiter(this, void 0, void 0, function () {
var target, query, response, _a;
var _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
_c.trys.push([0, 2, , 3]);
target = (0, notificationIdentity_1.getAutomationNotificationPreferenceTarget)();
query = target ? "?".concat(new URLSearchParams(target).toString()) : '';
return [4 /*yield*/, requestAppConfig("".concat(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH).concat(query))];
case 1:
response = _c.sent();
return [2 /*return*/, (_b = response.automation) !== null && _b !== void 0 ? _b : null];
case 2:
_a = _c.sent();
return [2 /*return*/, null];
case 3: return [2 /*return*/];
}
});
});
}
function saveAppConfigToServer(config) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestAppConfig(APP_CONFIG_API_PATH, {
method: 'PUT',
body: JSON.stringify({ config: config }),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizeConfig(response.config)];
}
});
});
}
function saveAutomationNotificationPreferenceToServer(config) {
return __awaiter(this, void 0, void 0, function () {
var target, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
target = (0, notificationIdentity_1.getAutomationNotificationPreferenceTarget)();
return [4 /*yield*/, requestAppConfig(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH, {
method: 'PUT',
body: JSON.stringify(__assign(__assign({}, (target !== null && target !== void 0 ? target : {})), { automation: pickAutomationNotificationSettings(config.automation) })),
})];
case 1:
response = _a.sent();
return [2 /*return*/, mergeAutomationNotificationSettings(config, response.automation)];
}
});
});
}
function syncAppConfigFromServer() {
return __awaiter(this, void 0, void 0, function () {
var config;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, fetchAppConfigFromServer()];
case 1:
config = _a.sent();
if (!config) {
return [2 /*return*/, false];
}
setStoredAppConfig(config);
return [2 /*return*/, true];
}
});
});
}
function getStoredAppConfig() {
if (typeof window === 'undefined') {
return exports.DEFAULT_APP_CONFIG;
}
try {
var raw = window.localStorage.getItem(exports.APP_CONFIG_STORAGE_KEY);
if (!raw) {
cachedConfig = exports.DEFAULT_APP_CONFIG;
cachedRawConfig = null;
return exports.DEFAULT_APP_CONFIG;
}
if (raw === cachedRawConfig && cachedConfig) {
return cachedConfig;
}
var normalized = normalizeConfig(JSON.parse(raw));
cachedConfig = normalized;
cachedRawConfig = raw;
return normalized;
}
catch (_a) {
cachedConfig = exports.DEFAULT_APP_CONFIG;
cachedRawConfig = null;
return exports.DEFAULT_APP_CONFIG;
}
}
function setStoredAppConfig(config) {
if (typeof window === 'undefined') {
return;
}
var normalized = normalizeConfig(config);
var raw = JSON.stringify(normalized);
cachedConfig = normalized;
cachedRawConfig = raw;
window.localStorage.setItem(exports.APP_CONFIG_STORAGE_KEY, raw);
emitConfigChange();
}
function updateStoredAppConfig(updater) {
var current = getStoredAppConfig();
var next = updater(current);
setStoredAppConfig(next);
}
function subscribeToAppConfig(callback) {
if (typeof window === 'undefined') {
return function () { return undefined; };
}
var handleChange = function () {
callback();
};
window.addEventListener(APP_CONFIG_EVENT, handleChange);
window.addEventListener('storage', handleChange);
return function () {
window.removeEventListener(APP_CONFIG_EVENT, handleChange);
window.removeEventListener('storage', handleChange);
};
}
function useAppConfig() {
return (0, react_1.useSyncExternalStore)(subscribeToAppConfig, getStoredAppConfig, function () { return exports.DEFAULT_APP_CONFIG; });
}
function describeAutoReceiveSchedule(config) {
var automation = config.automation;
if (automation.autoReceiveScheduleType === 'daily') {
return "\uB9E4\uC77C ".concat(automation.autoReceiveDailyTime);
}
if (automation.autoReceiveScheduleType === 'weekly') {
return "\uB9E4\uC8FC ".concat(WEEKLY_DAY_LABELS[automation.autoReceiveWeeklyDay], " ").concat(automation.autoReceiveWeeklyTime);
}
return "".concat(automation.autoReceiveIntervalSeconds, "\uCD08\uB9C8\uB2E4");
}
function getWeeklyScheduleOptions() {
return Object.entries(WEEKLY_DAY_LABELS).map(function (_a) {
var value = _a[0], label = _a[1];
return ({
value: value,
label: label,
});
});
}

View File

@@ -21,6 +21,7 @@ export type AppConfig = {
codexLiveMaxExecutionSeconds: number;
codexLiveIdleTimeoutSeconds: number;
receiveRoomNotifications: boolean;
restartReservationCompletionDelaySeconds: number;
};
automation: {
autoRefreshEnabled: boolean;
@@ -76,6 +77,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
restartReservationCompletionDelaySeconds: 10,
},
automation: {
autoRefreshEnabled: true,
@@ -265,6 +267,14 @@ function normalizeCodexLiveIdleTimeoutSeconds(value: number | undefined, fallbac
return Math.min(3600, Math.max(30, Math.round(value)));
}
function normalizeRestartReservationCompletionDelaySeconds(value: number | undefined, fallback: number) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(300, Math.max(1, Math.round(value)));
}
function normalizeBooleanValue(value: boolean | undefined, fallback: boolean) {
if (typeof value !== 'boolean') {
return fallback;
@@ -300,6 +310,10 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
chat?.receiveRoomNotifications,
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
),
restartReservationCompletionDelaySeconds: normalizeRestartReservationCompletionDelaySeconds(
chat?.restartReservationCompletionDelaySeconds,
DEFAULT_APP_CONFIG.chat.restartReservationCompletionDelaySeconds,
),
},
automation: {
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
@@ -423,6 +437,22 @@ function mergeAutomationNotificationSettings(
});
}
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
function emitConfigChange() {
if (typeof window === 'undefined') {
return;
@@ -485,6 +515,17 @@ async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: Req
headers.set('Content-Type', 'application/json');
}
const appOrigin = getCurrentAppOrigin();
const appDomain = getCurrentAppDomain();
if (appOrigin && !headers.has('X-App-Origin')) {
headers.set('X-App-Origin', appOrigin);
}
if (appDomain && !headers.has('X-App-Domain')) {
headers.set('X-App-Domain', appDomain);
}
let response: Response;
try {

View File

@@ -0,0 +1,398 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_AUTOMATION_CONTEXTS = void 0;
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
exports.upsertAutomationContext = upsertAutomationContext;
exports.deleteAutomationContext = deleteAutomationContext;
exports.buildAutomationContextOptions = buildAutomationContextOptions;
exports.resolveDefaultAutomationContextIds = resolveDefaultAutomationContextIds;
exports.useAutomationContextRegistry = useAutomationContextRegistry;
var react_1 = require("react");
var clientIdentity_1 = require("./clientIdentity");
var AUTOMATION_CONTEXTS_API_PATH = '/automation-contexts';
var AUTOMATION_CONTEXT_SYNC_EVENT = 'work-app:automation-contexts-changed';
var AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS = 8000;
exports.DEFAULT_AUTOMATION_CONTEXTS = [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none-default',
title: '기본 처리',
content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content: '## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
];
function normalizeText(value) {
var _a;
return (_a = value === null || value === void 0 ? void 0 : value.trim()) !== null && _a !== void 0 ? _a : '';
}
function compareContextUpdatedAt(left, right) {
var leftTime = Date.parse(left.updatedAt);
var rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record) {
var title = normalizeText(record.title);
var content = normalizeText(record.content);
if (!title && !content) {
return null;
}
var id = normalizeText(record.id) ||
"automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
return {
id: id,
title: title || 'Context',
content: content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationContexts(items) {
var byId = new Map();
var bySemanticKey = new Map();
(items !== null && items !== void 0 ? items : [])
.map(function (item) { return normalizeAutomationContext(item); })
.filter(function (item) { return Boolean(item); })
.forEach(function (item) {
var currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
var item = _a[_i];
var semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
var current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
return values.length > 0 ? values : exports.DEFAULT_AUTOMATION_CONTEXTS;
}
function emitAutomationContextsChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(AUTOMATION_CONTEXT_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var API_BASE_URL = resolveApiBaseUrl();
var FALLBACK_BASE_URL = resolveFallbackBaseUrl();
function requestOnce(baseUrl, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, controller, timeoutId, response, _a;
var _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && (init === null || init === void 0 ? void 0 : init.body) !== null;
controller = new AbortController();
timeoutId = window.setTimeout(function () { return controller.abort(); }, AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
_d.label = 1;
case 1:
_d.trys.push([1, , 5, 6]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(AUTOMATION_CONTEXTS_API_PATH), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_b = init === null || init === void 0 ? void 0 : init.cache) !== null && _b !== void 0 ? _b : (((_c = init === null || init === void 0 ? void 0 : init.method) === null || _c === void 0 ? void 0 : _c.toUpperCase()) === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _d.sent();
if (!!response.ok) return [3 /*break*/, 4];
_a = Error.bind;
return [4 /*yield*/, response.text()];
case 3: throw new (_a.apply(Error, [void 0, (_d.sent()) || '자동화 Context 요청에 실패했습니다.']))();
case 4: return [2 /*return*/, response.json()];
case 5:
window.clearTimeout(timeoutId);
return [7 /*endfinally*/];
case 6: return [2 /*return*/];
}
});
});
}
function requestAutomationContexts(init) {
return __awaiter(this, void 0, void 0, function () {
var error_1, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(API_BASE_URL, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_1 = _a.sent();
shouldRetryWithFallback = FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
error_1 instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error_1.message);
if (!shouldRetryWithFallback) {
throw error_1;
}
return [2 /*return*/, requestOnce(FALLBACK_BASE_URL, init)];
case 3: return [2 /*return*/];
}
});
});
}
function loadAutomationContextsFromServer() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestAutomationContexts({
method: 'GET',
})];
case 1:
response = _a.sent();
if (response.automationContexts == null) {
return [2 /*return*/, exports.DEFAULT_AUTOMATION_CONTEXTS];
}
return [2 /*return*/, sanitizeAutomationContexts(response.automationContexts)];
}
});
});
}
function saveAutomationContextsToServer(items) {
return __awaiter(this, void 0, void 0, function () {
var resolved, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
resolved = sanitizeAutomationContexts(items);
return [4 /*yield*/, requestAutomationContexts({
method: 'PUT',
body: JSON.stringify({ automationContexts: resolved }),
})];
case 1:
response = _a.sent();
return [2 /*return*/, sanitizeAutomationContexts(response.automationContexts)];
}
});
});
}
function upsertAutomationContext(items, input) {
var nextItem = normalizeAutomationContext(input);
if (!nextItem) {
return sanitizeAutomationContexts(items);
}
var nextItems = items.filter(function (item) { return item.id !== nextItem.id; });
nextItems.push(nextItem);
return sanitizeAutomationContexts(nextItems);
}
function deleteAutomationContext(items, automationContextId) {
var normalizedId = normalizeText(automationContextId);
if (!normalizedId) {
return sanitizeAutomationContexts(items);
}
return sanitizeAutomationContexts(items.filter(function (item) { return item.id !== normalizedId; }));
}
function buildAutomationContextOptions(items, selectedContextIds) {
if (selectedContextIds === void 0) { selectedContextIds = []; }
var contexts = sanitizeAutomationContexts(items);
var selectedSet = new Set(selectedContextIds);
var enabledIds = new Set(contexts.filter(function (item) { return item.enabled; }).map(function (item) { return item.id; }));
return contexts
.filter(function (item) { return enabledIds.has(item.id) || selectedSet.has(item.id); })
.map(function (item) { return ({
label: item.title,
value: item.id,
}); });
}
function resolveDefaultAutomationContextIds(items) {
return sanitizeAutomationContexts(items)
.filter(function (item) { return item.enabled && item.defaultSelected; })
.map(function (item) { return item.id; });
}
function useAutomationContextRegistry() {
var _this = this;
var _a = (0, react_1.useState)(exports.DEFAULT_AUTOMATION_CONTEXTS), automationContexts = _a[0], setAutomationContextsState = _a[1];
var _b = (0, react_1.useState)(true), isLoading = _b[0], setIsLoading = _b[1];
var _c = (0, react_1.useState)(''), errorMessage = _c[0], setErrorMessage = _c[1];
var mountedRef = (0, react_1.useRef)(true);
(0, react_1.useEffect)(function () {
mountedRef.current = true;
return function () {
mountedRef.current = false;
};
}, []);
(0, react_1.useEffect)(function () {
var cancelled = false;
var load = function () { return __awaiter(_this, void 0, void 0, function () {
var nextAutomationContexts, error_2;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
setIsLoading(true);
setErrorMessage('');
_a.label = 1;
case 1:
_a.trys.push([1, 3, 4, 5]);
return [4 /*yield*/, loadAutomationContextsFromServer()];
case 2:
nextAutomationContexts = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationContextsState(nextAutomationContexts);
}
return [3 /*break*/, 5];
case 3:
error_2 = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationContextsState(exports.DEFAULT_AUTOMATION_CONTEXTS);
setErrorMessage(error_2 instanceof Error ? error_2.message : '자동화 Context를 불러오지 못했습니다.');
}
return [3 /*break*/, 5];
case 4:
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
return [7 /*endfinally*/];
case 5: return [2 /*return*/];
}
});
}); };
void load();
var handleSync = function () {
void load();
};
window.addEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
return function () {
cancelled = true;
window.removeEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
};
}, []);
var setAutomationContexts = function (nextItems) { return __awaiter(_this, void 0, void 0, function () {
var saved;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, saveAutomationContextsToServer(nextItems)];
case 1:
saved = _a.sent();
if (mountedRef.current) {
setAutomationContextsState(saved);
setErrorMessage('');
}
emitAutomationContextsChange();
return [2 /*return*/, saved];
}
});
}); };
return {
automationContexts: automationContexts,
setAutomationContexts: setAutomationContexts,
isLoading: isLoading,
errorMessage: errorMessage,
};
}

View File

@@ -0,0 +1,577 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AUTOMATION_BEHAVIOR_LABELS = exports.AUTOMATION_BEHAVIOR_TYPES = void 0;
exports.normalizeAutomationTypeId = normalizeAutomationTypeId;
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
exports.upsertAutomationType = upsertAutomationType;
exports.deleteAutomationType = deleteAutomationType;
exports.resolveAutomationTypeLabel = resolveAutomationTypeLabel;
exports.buildAutomationTypeOptions = buildAutomationTypeOptions;
exports.buildAutomationContextOptions = buildAutomationContextOptions;
exports.resolveDefaultAutomationContextIds = resolveDefaultAutomationContextIds;
exports.useAutomationTypeRegistry = useAutomationTypeRegistry;
var react_1 = require("react");
var clientIdentity_1 = require("./clientIdentity");
exports.AUTOMATION_BEHAVIOR_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
];
var AUTOMATION_TYPES_API_PATH = '/automation-types';
var AUTOMATION_TYPE_SYNC_EVENT = 'work-app:automation-types-changed';
var AUTOMATION_TYPE_REQUEST_TIMEOUT_MS = 8000;
exports.AUTOMATION_BEHAVIOR_LABELS = {
none: '기본유형',
plan: '작업 요청 등록',
command_execution: 'Command 실행',
non_source_work: '비 소스작업',
auto_worker: 'autoWorker',
};
var DEFAULT_AUTOMATION_TYPES = [
{
id: 'general-inquiry',
name: '일반 문의',
description: '일반 문의/확인 요청으로 처리합니다.',
contexts: [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none',
name: '기본유형',
description: '기본 자동화 처리용 유형입니다.',
contexts: [
{
id: 'none-default',
title: '기본 처리',
content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'plan',
name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
contexts: [
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'plan',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'command_execution',
name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
contexts: [
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'non_source_work',
name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
contexts: [
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'non_source_work',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'auto_worker',
name: 'autoWorker',
description: '자동화 작업메모로 처리합니다.',
contexts: [
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content: '## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'auto_worker',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
];
function normalizeText(value) {
var _a;
return (_a = value === null || value === void 0 ? void 0 : value.trim()) !== null && _a !== void 0 ? _a : '';
}
function normalizeAutomationTypeId(value) {
var normalized = normalizeText(typeof value === 'string' ? value : '');
if (normalized === 'stock-alert') {
return 'general-inquiry';
}
if (normalized === 'plan_registration') {
return 'plan';
}
if (normalized === 'general_development') {
return 'auto_worker';
}
return normalized || 'none';
}
function normalizeBehaviorType(value) {
var normalized = normalizeAutomationTypeId(value);
return exports.AUTOMATION_BEHAVIOR_TYPES.includes(normalized)
? normalized
: 'none';
}
function getSemanticKey(record) {
return "".concat(record.behaviorType, ":").concat(normalizeText(record.name).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'));
}
function compareUpdatedAt(left, right) {
var leftTime = Date.parse(left.updatedAt);
var rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function compareContextUpdatedAt(left, right) {
var leftTime = Date.parse(left.updatedAt);
var rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record) {
var title = normalizeText(record.title);
var content = normalizeText(record.content);
if (!title && !content) {
return null;
}
var id = normalizeText(record.id) ||
"automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
return {
id: id,
title: title || 'Context',
content: content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationContexts(items) {
var byId = new Map();
var bySemanticKey = new Map();
(items !== null && items !== void 0 ? items : [])
.map(function (item) { return normalizeAutomationContext(item); })
.filter(function (item) { return Boolean(item); })
.forEach(function (item) {
var currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
var item = _a[_i];
var semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
var current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
}
function normalizeAutomationType(record) {
var name = normalizeText(record.name);
if (!name) {
return null;
}
var id = normalizeText(record.id) ||
"automation-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
return {
id: id,
name: name,
description: normalizeText(record.description),
contexts: sanitizeAutomationContexts(record.contexts),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationTypes(items) {
var byId = new Map();
var bySemanticKey = new Map();
items
.map(function (item) { return normalizeAutomationType(item); })
.filter(function (item) { return Boolean(item); })
.forEach(function (item) {
var currentById = byId.get(item.id);
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
var item = _a[_i];
var semanticKey = getSemanticKey(item);
var current = bySemanticKey.get(semanticKey);
if (!current || compareUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return left.name.localeCompare(right.name, 'ko-KR'); });
return values.length > 0 ? values : DEFAULT_AUTOMATION_TYPES;
}
function emitAutomationTypesChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(AUTOMATION_TYPE_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var API_BASE_URL = resolveApiBaseUrl();
var FALLBACK_BASE_URL = resolveFallbackBaseUrl();
function requestOnce(baseUrl, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, controller, timeoutId, response, _a;
var _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && (init === null || init === void 0 ? void 0 : init.body) !== null;
controller = new AbortController();
timeoutId = window.setTimeout(function () { return controller.abort(); }, AUTOMATION_TYPE_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
_d.label = 1;
case 1:
_d.trys.push([1, , 5, 6]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(AUTOMATION_TYPES_API_PATH), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_b = init === null || init === void 0 ? void 0 : init.cache) !== null && _b !== void 0 ? _b : (((_c = init === null || init === void 0 ? void 0 : init.method) === null || _c === void 0 ? void 0 : _c.toUpperCase()) === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _d.sent();
if (!!response.ok) return [3 /*break*/, 4];
_a = Error.bind;
return [4 /*yield*/, response.text()];
case 3: throw new (_a.apply(Error, [void 0, (_d.sent()) || '자동화 처리 유형 요청에 실패했습니다.']))();
case 4: return [2 /*return*/, response.json()];
case 5:
window.clearTimeout(timeoutId);
return [7 /*endfinally*/];
case 6: return [2 /*return*/];
}
});
});
}
function requestAutomationTypes(init) {
return __awaiter(this, void 0, void 0, function () {
var error_1, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(API_BASE_URL, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_1 = _a.sent();
shouldRetryWithFallback = FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
error_1 instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error_1.message);
if (!shouldRetryWithFallback) {
throw error_1;
}
return [2 /*return*/, requestOnce(FALLBACK_BASE_URL, init)];
case 3: return [2 /*return*/];
}
});
});
}
function loadAutomationTypesFromServer() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestAutomationTypes({
method: 'GET',
})];
case 1:
response = _a.sent();
if (response.automationTypes == null) {
return [2 /*return*/, DEFAULT_AUTOMATION_TYPES];
}
return [2 /*return*/, sanitizeAutomationTypes(response.automationTypes)];
}
});
});
}
function saveAutomationTypesToServer(items) {
return __awaiter(this, void 0, void 0, function () {
var resolved, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
resolved = sanitizeAutomationTypes(items);
return [4 /*yield*/, requestAutomationTypes({
method: 'PUT',
body: JSON.stringify({ automationTypes: resolved }),
})];
case 1:
response = _a.sent();
return [2 /*return*/, sanitizeAutomationTypes(response.automationTypes)];
}
});
});
}
function upsertAutomationType(items, input) {
var nextItem = normalizeAutomationType(input);
if (!nextItem) {
return sanitizeAutomationTypes(items);
}
var nextItems = items.filter(function (item) { return item.id !== nextItem.id; });
nextItems.push(nextItem);
return sanitizeAutomationTypes(nextItems);
}
function deleteAutomationType(items, automationTypeId) {
var normalizedId = normalizeText(automationTypeId);
if (!normalizedId) {
return sanitizeAutomationTypes(items);
}
return sanitizeAutomationTypes(items.filter(function (item) { return item.id !== normalizedId; }));
}
function resolveAutomationTypeLabel(items, automationTypeId) {
var _a, _b;
var normalizedId = normalizeAutomationTypeId(automationTypeId);
return (_b = (_a = items.find(function (item) { return item.id === normalizedId; })) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : normalizedId;
}
function buildAutomationTypeOptions(items, value) {
var normalizedValue = normalizeAutomationTypeId(value);
var enabledItems = items.filter(function (item) { return item.enabled; });
var currentItem = items.find(function (item) { return item.id === normalizedValue; });
var source = currentItem && !enabledItems.some(function (item) { return item.id === currentItem.id; }) ? __spreadArray(__spreadArray([], enabledItems, true), [currentItem], false) : enabledItems;
if (source.length === 0) {
return [{ label: '선택 안함', value: 'none' }];
}
return source.map(function (item) { return ({
label: item.name,
value: item.id,
}); });
}
function buildAutomationContextOptions(items, automationTypeId, selectedContextIds) {
var _a;
if (selectedContextIds === void 0) { selectedContextIds = []; }
var normalizedId = normalizeAutomationTypeId(automationTypeId);
var automationType = (_a = items.find(function (item) { return item.id === normalizedId; })) !== null && _a !== void 0 ? _a : null;
var contexts = sanitizeAutomationContexts(automationType === null || automationType === void 0 ? void 0 : automationType.contexts);
var selectedSet = new Set(selectedContextIds);
var enabledIds = new Set(contexts.filter(function (item) { return item.enabled; }).map(function (item) { return item.id; }));
return contexts
.filter(function (item) { return enabledIds.has(item.id) || selectedSet.has(item.id); })
.map(function (item) { return ({
label: item.title,
value: item.id,
}); });
}
function resolveDefaultAutomationContextIds(items, automationTypeId) {
var _a;
var normalizedId = normalizeAutomationTypeId(automationTypeId);
var automationType = (_a = items.find(function (item) { return item.id === normalizedId; })) !== null && _a !== void 0 ? _a : null;
return sanitizeAutomationContexts(automationType === null || automationType === void 0 ? void 0 : automationType.contexts)
.filter(function (item) { return item.enabled && item.defaultSelected; })
.map(function (item) { return item.id; });
}
function useAutomationTypeRegistry() {
var _this = this;
var _a = (0, react_1.useState)(DEFAULT_AUTOMATION_TYPES), automationTypes = _a[0], setAutomationTypesState = _a[1];
var _b = (0, react_1.useState)(true), isLoading = _b[0], setIsLoading = _b[1];
var _c = (0, react_1.useState)(''), errorMessage = _c[0], setErrorMessage = _c[1];
var mountedRef = (0, react_1.useRef)(true);
(0, react_1.useEffect)(function () {
mountedRef.current = true;
return function () {
mountedRef.current = false;
};
}, []);
(0, react_1.useEffect)(function () {
var cancelled = false;
var load = function () { return __awaiter(_this, void 0, void 0, function () {
var nextAutomationTypes, error_2;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
setIsLoading(true);
setErrorMessage('');
_a.label = 1;
case 1:
_a.trys.push([1, 3, 4, 5]);
return [4 /*yield*/, loadAutomationTypesFromServer()];
case 2:
nextAutomationTypes = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationTypesState(nextAutomationTypes);
}
return [3 /*break*/, 5];
case 3:
error_2 = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationTypesState(DEFAULT_AUTOMATION_TYPES);
setErrorMessage(error_2 instanceof Error ? error_2.message : '자동화 처리 유형을 불러오지 못했습니다.');
}
return [3 /*break*/, 5];
case 4:
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
return [7 /*endfinally*/];
case 5: return [2 /*return*/];
}
});
}); };
void load();
var handleSync = function () {
void load();
};
window.addEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
return function () {
cancelled = true;
window.removeEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
};
}, []);
var setAutomationTypes = function (nextItems) { return __awaiter(_this, void 0, void 0, function () {
var saved;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, saveAutomationTypesToServer(nextItems)];
case 1:
saved = _a.sent();
if (mountedRef.current) {
setAutomationTypesState(saved);
setErrorMessage('');
}
emitAutomationTypesChange();
return [2 /*return*/, saved];
}
});
}); };
return {
automationTypes: automationTypes,
setAutomationTypes: setAutomationTypes,
isLoading: isLoading,
errorMessage: errorMessage,
};
}

View File

@@ -0,0 +1,521 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
export type ChatDefaultContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
updatedAt: string;
};
export type ChatTypeDefaultContextSelection = {
chatTypeId: string;
defaultContextIds: string[];
updatedAt: string;
};
export type ChatRoomContextSettings = {
sessionId: string;
defaultContextIds: string[];
customContextTitle: string;
customContextContent: string;
updatedAt: string;
};
type ChatContextSettingsStore = {
defaultContexts: ChatDefaultContextRecord[];
chatTypeDefaults: ChatTypeDefaultContextSelection[];
roomContexts: ChatRoomContextSettings[];
};
const CHAT_CONTEXT_SETTINGS_STORAGE_KEY = 'work-app:chat-context-settings';
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() ?? '';
}
function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeDefaultContext(record: Partial<ChatDefaultContextRecord>): ChatDefaultContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
return {
id:
normalizeText(record.id) ||
`chat-default-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
title: title || '기본 유형',
content,
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefined) {
return Array.from(new Set((defaultContextIds ?? []).map((item) => normalizeText(item)).filter(Boolean)));
}
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
const byId = new Map<string, ChatDefaultContextRecord>();
[...(items ?? []), ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
.map((item) => normalizeDefaultContext(item))
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
.forEach((item) => {
const current = byId.get(item.id);
if (!current || compareUpdatedAt(current, item) <= 0) {
byId.set(item.id, item);
}
});
return Array.from(byId.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
}
function sanitizeChatTypeDefaultSelections(items: Partial<ChatTypeDefaultContextSelection>[] | null | undefined) {
const byChatTypeId = new Map<string, ChatTypeDefaultContextSelection>();
(items ?? []).forEach((item) => {
const chatTypeId = normalizeText(item.chatTypeId);
if (!chatTypeId) {
return;
}
const nextRecord: ChatTypeDefaultContextSelection = {
chatTypeId,
defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds),
updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(),
};
const current = byChatTypeId.get(chatTypeId);
if (!current || compareUpdatedAt(current, nextRecord) <= 0) {
byChatTypeId.set(chatTypeId, nextRecord);
}
});
return Array.from(byChatTypeId.values()).sort((left, right) => left.chatTypeId.localeCompare(right.chatTypeId, 'ko-KR'));
}
function sanitizeRoomContexts(items: Partial<ChatRoomContextSettings>[] | null | undefined) {
const bySessionId = new Map<string, ChatRoomContextSettings>();
(items ?? []).forEach((item) => {
const sessionId = normalizeText(item.sessionId);
if (!sessionId) {
return;
}
const nextRecord: ChatRoomContextSettings = {
sessionId,
defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds),
customContextTitle: normalizeText(item.customContextTitle),
customContextContent: normalizeText(item.customContextContent),
updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(),
};
const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent);
const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0;
if (!hasCustomContext && !hasDefaultOverrides) {
return;
}
const current = bySessionId.get(sessionId);
if (!current || compareUpdatedAt(current, nextRecord) <= 0) {
bySessionId.set(sessionId, nextRecord);
}
});
return Array.from(bySessionId.values()).sort((left, right) => left.sessionId.localeCompare(right.sessionId, 'ko-KR'));
}
function sanitizeStore(input: Partial<ChatContextSettingsStore> | null | undefined): ChatContextSettingsStore {
return {
defaultContexts: sanitizeDefaultContexts(input?.defaultContexts),
chatTypeDefaults: sanitizeChatTypeDefaultSelections(input?.chatTypeDefaults),
roomContexts: sanitizeRoomContexts(input?.roomContexts),
};
}
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);
}
}
function emitStoreChange() {
if (typeof window === 'undefined') {
return;
}
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;
}
return '/api';
}
function resolveChatContextSettingsFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const CHAT_CONTEXT_SETTINGS_API_BASE_URL = resolveChatContextSettingsApiBaseUrl();
const CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL = resolveChatContextSettingsFallbackBaseUrl();
async function requestChatContextSettingsOnce<T>(baseUrl: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(`${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
throw new Error((await response.text()) || '채팅 Context 설정 요청에 실패했습니다.');
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestChatContextSettings<T>(init?: RequestInit) {
try {
return await requestChatContextSettingsOnce<T>(CHAT_CONTEXT_SETTINGS_API_BASE_URL, init);
} catch (error) {
const shouldRetryWithFallback =
CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL &&
CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL !== CHAT_CONTEXT_SETTINGS_API_BASE_URL &&
error instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
if (!shouldRetryWithFallback) {
throw error;
}
return requestChatContextSettingsOnce<T>(CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL, init);
}
}
async function fetchStoreFromServer() {
const response = await requestChatContextSettings<{
ok: boolean;
settings?: Partial<ChatContextSettingsStore> | null;
}>({
method: 'GET',
});
return sanitizeStore(response.settings);
}
async function saveStoreToServer(store: ChatContextSettingsStore) {
const sanitized = sanitizeStore(store);
const response = await requestChatContextSettings<{
ok: boolean;
settings?: Partial<ChatContextSettingsStore> | null;
}>({
method: 'PUT',
body: JSON.stringify({ settings: sanitized }),
});
emitStoreChange();
return sanitizeStore(response.settings ?? sanitized);
}
export function resolveChatTypeDefaultContextIds(
selections: ChatTypeDefaultContextSelection[],
chatTypeId: string | null | undefined,
) {
const normalizedChatTypeId = normalizeText(chatTypeId);
if (!normalizedChatTypeId) {
return [];
}
return (
selections.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds.map((item) => item.trim()).filter(Boolean) ??
[]
);
}
export function resolveChatRoomContextSettings(
roomContexts: ChatRoomContextSettings[],
sessionId: string | null | undefined,
) {
const normalizedSessionId = normalizeText(sessionId);
if (!normalizedSessionId) {
return null;
}
return roomContexts.find((item) => item.sessionId === normalizedSessionId) ?? null;
}
export function upsertChatDefaultContext(
defaultContexts: ChatDefaultContextRecord[],
input: Partial<ChatDefaultContextRecord>,
) {
const nextRecord = normalizeDefaultContext({
...input,
updatedAt: new Date().toISOString(),
});
if (!nextRecord) {
return sanitizeDefaultContexts(defaultContexts);
}
return sanitizeDefaultContexts([
...defaultContexts.filter((item) => item.id !== nextRecord.id),
nextRecord,
]);
}
export function deleteChatDefaultContext(defaultContexts: ChatDefaultContextRecord[], contextId: string) {
const normalizedContextId = normalizeText(contextId);
if (!normalizedContextId) {
return sanitizeDefaultContexts(defaultContexts);
}
return sanitizeDefaultContexts(defaultContexts.filter((item) => item.id !== normalizedContextId));
}
export function upsertChatTypeDefaultContextSelection(
selections: ChatTypeDefaultContextSelection[],
chatTypeId: string,
defaultContextIds: string[],
) {
const normalizedChatTypeId = normalizeText(chatTypeId);
if (!normalizedChatTypeId) {
return sanitizeChatTypeDefaultSelections(selections);
}
const nextRecord: ChatTypeDefaultContextSelection = {
chatTypeId: normalizedChatTypeId,
defaultContextIds: normalizeDefaultContextIds(defaultContextIds),
updatedAt: new Date().toISOString(),
};
return sanitizeChatTypeDefaultSelections([
...selections.filter((item) => item.chatTypeId !== normalizedChatTypeId),
nextRecord,
]);
}
export function pruneChatTypeDefaultSelections(
selections: ChatTypeDefaultContextSelection[],
contextId: string,
) {
const normalizedContextId = normalizeText(contextId);
if (!normalizedContextId) {
return sanitizeChatTypeDefaultSelections(selections);
}
return sanitizeChatTypeDefaultSelections(
selections.map((item) => ({
...item,
defaultContextIds: item.defaultContextIds.filter((candidate) => candidate !== normalizedContextId),
})),
);
}
export function upsertChatRoomContextSettings(
roomContexts: ChatRoomContextSettings[],
input: Partial<ChatRoomContextSettings> & { sessionId: string },
) {
const nextRecord: ChatRoomContextSettings = {
sessionId: normalizeText(input.sessionId),
defaultContextIds: normalizeDefaultContextIds(input.defaultContextIds),
customContextTitle: normalizeText(input.customContextTitle),
customContextContent: normalizeText(input.customContextContent),
updatedAt: new Date().toISOString(),
};
if (!nextRecord.sessionId) {
return sanitizeRoomContexts(roomContexts);
}
return sanitizeRoomContexts([
...roomContexts.filter((item) => item.sessionId !== nextRecord.sessionId),
nextRecord,
]);
}
export function pruneChatRoomContextSettings(roomContexts: ChatRoomContextSettings[], contextId: string) {
const normalizedContextId = normalizeText(contextId);
if (!normalizedContextId) {
return sanitizeRoomContexts(roomContexts);
}
return sanitizeRoomContexts(
roomContexts.map((item) => ({
...item,
defaultContextIds: item.defaultContextIds.filter((candidate) => candidate !== normalizedContextId),
})),
);
}
export function useChatContextSettingsRegistry() {
const [store, setStoreState] = useState<ChatContextSettingsStore>(() => loadStore());
const [errorMessage, setErrorMessage] = useState('');
const isMountedRef = useRef(true);
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);
};
}, []);
return {
...store,
errorMessage,
setStore: (
updater:
| ChatContextSettingsStore
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
) => {
const nextStore = typeof updater === 'function' ? updater(loadStore()) : updater;
const saved = saveStore(nextStore);
if (isMountedRef.current) {
setStoreState(saved);
setErrorMessage('');
}
return saveStoreToServer(saved).then((serverSaved) => {
if (isMountedRef.current) {
setStoreState(serverSaved);
}
return serverSaved;
});
},
};
}

View File

@@ -1,10 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import {
LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
LAYOUT_EDITOR_CHAT_TYPE_ID,
LAYOUT_EDITOR_CHAT_TYPE_NAME,
} from './chatTypeDefaults';
import { DEFAULT_CHAT_TYPES } from './chatTypeDefaults';
export type ChatPermissionRole = 'guest' | 'token-user';
@@ -33,43 +29,6 @@ export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
'token-user': '토큰 사용자',
};
const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
},
{
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-27T00:00:00.000Z',
},
{
id: 'api-request-template',
name: 'API요청',
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
},
{
id: 'general-inquiry',
name: '일반 문의',
description:
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
@@ -151,6 +110,10 @@ function sanitizeChatTypes(chatTypes: Partial<ChatTypeRecord>[]) {
);
}
function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null | undefined) {
return sanitizeChatTypes([...(chatTypes ?? []), ...DEFAULT_CHAT_TYPES]);
}
function emitChatTypesChange() {
if (typeof window === 'undefined') {
return;
@@ -242,21 +205,21 @@ async function fetchChatTypesFromServer() {
});
if (response.chatTypes == null) {
return null;
return mergeWithDefaultChatTypes(DEFAULT_CHAT_TYPES);
}
return sanitizeChatTypes(response.chatTypes);
return mergeWithDefaultChatTypes(response.chatTypes);
}
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
const resolved = sanitizeChatTypes(chatTypes);
const resolved = mergeWithDefaultChatTypes(chatTypes);
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ chatTypes: resolved }),
});
emitChatTypesChange();
return sanitizeChatTypes(response.chatTypes);
return mergeWithDefaultChatTypes(response.chatTypes);
}
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {

View File

@@ -1,6 +1,76 @@
export type DefaultChatTypeRecord = {
id: string;
name: string;
description: string;
permissions: Array<'guest' | 'token-user'>;
enabled: boolean;
updatedAt: string;
};
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에서는 별도 요청이 없는 한 참조하지 마세요.';
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
export const LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
export const LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID = 'layout-editor-guided-execution';
export const LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME = 'Layout editor 단계별 실행';
export const LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION =
'## 기본 처리\n- Layout editor 요청을 단계별로 나눠 진행합니다.\n- 현재 단계 결과를 확인하면서 다음 단계로 이어가는 실행 흐름에 사용합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 이 채팅유형은 특정 메뉴 하나의 전용 context를 기본값으로 두지 않고, Layout editor 화면 구성 전반에서 공통으로 사용합니다.\n\n## 레이아웃 패키지 기준\n- 특정 레이아웃에서만 쓰는 전용 기능은 공용 app 계층으로 퍼뜨리지 말고 해당 layout feature package 안에서만 구조화합니다.\n- 패키지 개선이 필요하면 전용 화면을 해당 package 내부의 page, utils, types, chat helper 등으로 먼저 분리합니다.\n- 공통 컴포넌트 승격은 다른 화면 재사용 근거가 확인될 때만 진행합니다.\n\n## 화면 정리 기준\n- 현재 단계에서 필요한 입력 영역과 참고 영역은 한 화면에 섞어 두지 말고 분리합니다.\n- 상시 노출 액션은 화면 맥락에 맞게 간결하게 유지하고, 필요하면 tooltip과 aria-label로 의미를 보완합니다.\n- 실제 수정본이 있으면 문서 설명보다 화면 결과와 preview 검증을 우선합니다.\n\n## 산출물 기준\n- 단계별 산출물은 현재 요청 범위에 맞춰 유지합니다.\n- 특정 메뉴 전용 문서 저장 위치나 세부 규칙은 해당 메뉴 요청이 있을 때만 그 패키지 기준으로 추가 적용합니다.';
export const API_REQUEST_CHAT_TYPE_ID = 'api-request-template';
export const API_REQUEST_CHAT_TYPE_NAME = 'API요청';
export const API_REQUEST_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.';
export const GENERAL_INQUIRY_CHAT_TYPE_ID = 'general-inquiry';
export const GENERAL_INQUIRY_CHAT_TYPE_NAME = '일반 문의';
export const GENERAL_INQUIRY_CHAT_TYPE_DESCRIPTION =
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.';
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
{
id: GENERAL_REQUEST_CHAT_TYPE_ID,
name: GENERAL_REQUEST_CHAT_TYPE_NAME,
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
},
{
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-27T00:00:00.000Z',
},
{
id: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-02T00:00:00.000Z',
},
{
id: API_REQUEST_CHAT_TYPE_ID,
name: API_REQUEST_CHAT_TYPE_NAME,
description: API_REQUEST_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
},
{
id: GENERAL_INQUIRY_CHAT_TYPE_ID,
name: GENERAL_INQUIRY_CHAT_TYPE_NAME,
description: GENERAL_INQUIRY_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];

View File

@@ -47,7 +47,13 @@ export type ChatGateway = {
payload: Partial<
Pick<
ChatConversationSummary,
'title' | 'chatTypeId' | 'lastChatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'
| 'title'
| 'chatTypeId'
| 'lastChatTypeId'
| 'generalSectionName'
| 'contextLabel'
| 'contextDescription'
| 'notifyOffline'
>
>,
) => Promise<ChatConversationSummary>;

View File

@@ -20,6 +20,7 @@ type PendingChatRequest = {
requestId: string;
text: string;
mode: 'queue' | 'direct';
omitPromptHistory?: boolean;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
@@ -35,6 +36,7 @@ type PendingContextConfirm = {
chatTypeDescription: string;
includedContextCount: number;
omittedContextCount: number;
omitPromptHistory?: boolean;
};
type SelectedChatType = {
@@ -87,6 +89,11 @@ type UseConversationComposerControllerOptions = {
scrollViewportToBottom: () => void;
};
type SendMessageOptions = {
mode: 'queue' | 'direct';
draftText?: string;
};
export function useConversationComposerController({
activeSessionId,
appConfigChat,
@@ -219,13 +226,14 @@ export function useConversationComposerController({
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription } = request;
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, omitPromptHistory } = request;
const requestId = `client-${Date.now().toString(36)}`;
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
requestId,
text,
mode,
omitPromptHistory: omitPromptHistory === true,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
@@ -361,12 +369,12 @@ export function useConversationComposerController({
);
const sendMessage = useCallback(
(mode: 'queue' | 'direct') => {
({ mode, draftText }: SendMessageOptions) => {
if (isComposerAttachmentUploading) {
return;
}
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
if (!trimmed) {
return;
@@ -427,11 +435,11 @@ export function useConversationComposerController({
);
const handleSend = useCallback(() => {
sendMessage('queue');
sendMessage({ mode: 'queue' });
}, [sendMessage]);
const handleSendImmediate = useCallback(() => {
sendMessage('direct');
sendMessage({ mode: 'direct' });
}, [sendMessage]);
return {

View File

@@ -6,6 +6,7 @@ import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = {
requestedSessionId: string;
enabled?: boolean;
};
type UseConversationListDataResult = {
@@ -17,37 +18,71 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
const normalizedNextItems = nextItems.map((item) => {
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
}
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
const lastChatTypeId =
item.lastChatTypeId?.trim() ||
chatTypeId ||
previousItem.lastChatTypeId?.trim() ||
previousItem.chatTypeId?.trim() ||
null;
return {
...item,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
const preservedTransientItems = previousItems.filter((item) => {
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
});
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries(nextItems);
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasRequestedSession = nextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries(nextItems);
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries(nextItems);
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
return sortChatConversationSummaries([preservedRequestedSession, ...nextItems]);
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
}
export function useConversationListData({
requestedSessionId,
enabled = true,
}: UseConversationListDataOptions): UseConversationListDataResult {
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
@@ -104,65 +139,17 @@ export function useConversationListData({
useEffect(() => {
isMountedRef.current = true;
setIsConversationListLoading(true);
void loadConversationItems();
if (enabled) {
setIsConversationListLoading(true);
void loadConversationItems();
} else {
setIsConversationListLoading(false);
}
return () => {
isMountedRef.current = false;
};
}, [loadConversationItems]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let intervalId: number | null = null;
const startPolling = () => {
if (intervalId != null || document.visibilityState !== 'visible') {
return;
}
intervalId = window.setInterval(() => {
void loadConversationItems({ silent: true });
}, CONVERSATION_LIST_POLL_INTERVAL_MS);
};
const stopPolling = () => {
if (intervalId == null) {
return;
}
window.clearInterval(intervalId);
intervalId = null;
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadConversationItems({ silent: true });
startPolling();
return;
}
stopPolling();
};
const handleFocus = () => {
void loadConversationItems({ silent: true });
startPolling();
};
startPolling();
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [loadConversationItems]);
}, [enabled, loadConversationItems]);
return {
conversationItems,

View File

@@ -14,6 +14,7 @@ type PendingChatRequest = {
requestId: string;
text: string;
mode: 'queue' | 'direct';
omitPromptHistory?: boolean;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;

View File

@@ -18,14 +18,40 @@ function mergeConversationRequests(
sessionId: string,
) {
const previousSessionItems = previous.filter((item) => item.sessionId === sessionId);
const previousByRequestId = new Map(previousSessionItems.map((item) => [item.requestId, item] as const));
const incomingRequestIds = new Set(incoming.map((item) => item.requestId));
const mergedIncoming = incoming.map((item) => {
const previousItem = previousByRequestId.get(item.requestId);
if (!previousItem) {
return item;
}
const nextUserText = item.userText.trim() || previousItem.userText.trim();
const nextResponseText = item.responseText.trim() || previousItem.responseText.trim();
const nextStatusMessage = item.statusMessage?.trim() || previousItem.statusMessage?.trim() || null;
return {
...item,
statusMessage: nextStatusMessage,
userMessageId: item.userMessageId ?? previousItem.userMessageId,
userText: nextUserText,
responseMessageId: item.responseMessageId ?? previousItem.responseMessageId,
responseText: nextResponseText,
hasResponse: item.hasResponse || previousItem.hasResponse || nextResponseText.length > 0,
answeredAt: item.answeredAt ?? previousItem.answeredAt,
terminalAt: item.terminalAt ?? previousItem.terminalAt,
};
});
const preservedLocalItems = previousSessionItems.filter((item) => !incomingRequestIds.has(item.requestId));
return [...incoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
return [...mergedIncoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
type UseConversationRoomDataOptions = {
activeSessionId: string;
activeConversationIsDraftOnly?: boolean;
activeConversationHasLocalActivity?: boolean;
oldestLoadedMessageId: number | null;
reloadKey: number;
shouldForceStickToBottomOnNextLoadRef: MutableRefObject<boolean>;
@@ -50,6 +76,8 @@ type UseConversationRoomDataOptions = {
export function useConversationRoomData({
activeSessionId,
activeConversationIsDraftOnly = false,
activeConversationHasLocalActivity = false,
oldestLoadedMessageId,
reloadKey,
shouldForceStickToBottomOnNextLoadRef,
@@ -85,6 +113,18 @@ export function useConversationRoomData({
return;
}
if (activeConversationIsDraftOnly && !activeConversationHasLocalActivity) {
previousSessionIdRef.current = activeSessionId;
setMessages([]);
setRequestItems((previous) => previous.filter((item) => item.sessionId !== activeSessionId));
setConversationLoadingLabel('첫 요청을 보내면 대화가 저장됩니다.');
setIsConversationContentLoading(false);
setIsLoadingOlderMessages(false);
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
return;
}
let isCancelled = false;
const requestedSessionId = activeSessionId;
@@ -195,6 +235,8 @@ export function useConversationRoomData({
};
}, [
activeSessionId,
activeConversationHasLocalActivity,
activeConversationIsDraftOnly,
captureViewportRestoreSnapshot,
messagesRef,
pendingViewportRestoreRef,

View File

@@ -0,0 +1,92 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CLIENT_ID_STORAGE_KEY = void 0;
exports.getClientId = getClientId;
exports.clearClientId = clearClientId;
exports.getOrCreateClientId = getOrCreateClientId;
exports.appendClientIdHeader = appendClientIdHeader;
exports.buildTrackedPageUrl = buildTrackedPageUrl;
exports.CLIENT_ID_STORAGE_KEY = 'work-app.visitor.client-id';
function generateFallbackClientId() {
return "client-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 10));
}
function generateClientId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return generateFallbackClientId();
}
function getClientId() {
var _a, _b;
if (typeof window === 'undefined') {
return '';
}
return (_b = (_a = window.localStorage.getItem(exports.CLIENT_ID_STORAGE_KEY)) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
}
function clearClientId() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(exports.CLIENT_ID_STORAGE_KEY);
}
function getOrCreateClientId() {
var existingClientId = getClientId();
if (existingClientId) {
return existingClientId;
}
if (typeof window === 'undefined') {
return '';
}
var nextClientId = generateClientId();
window.localStorage.setItem(exports.CLIENT_ID_STORAGE_KEY, nextClientId);
return nextClientId;
}
function appendClientIdHeader(headersInit) {
var headers = new Headers(headersInit);
var clientId = getOrCreateClientId();
if (clientId && !headers.has('X-Client-Id')) {
headers.set('X-Client-Id', clientId);
}
return headers;
}
function buildTrackedPageUrl(page) {
if (typeof window === 'undefined') {
return '';
}
var url = new URL(window.location.href);
if (page.topMenu === 'plans') {
url.searchParams.set('topMenu', 'plans');
url.searchParams.set('planFilter', page.section);
url.searchParams.delete('planSection');
}
else {
url.searchParams.set('topMenu', page.topMenu);
url.searchParams.delete('planSection');
url.searchParams.delete('planFilter');
}
if (page.topMenu === 'docs') {
url.searchParams.set('docsSection', page.section);
}
else {
url.searchParams.delete('docsSection');
}
if (page.topMenu === 'apis') {
url.searchParams.set('apiSection', page.section);
}
else {
url.searchParams.delete('apiSection');
}
if (page.topMenu === 'chat') {
url.searchParams.set('chatSection', page.section);
}
else {
url.searchParams.delete('chatSection');
}
if (page.topMenu === 'play') {
url.searchParams.set('playSection', page.section);
}
else {
url.searchParams.delete('playSection');
}
return url.toString();
}

217
src/app/main/errorLogApi.js Normal file
View File

@@ -0,0 +1,217 @@
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchErrorLogs = fetchErrorLogs;
exports.reportClientError = reportClientError;
var clientIdentity_1 = require("./clientIdentity");
var tokenAccess_1 = require("./tokenAccess");
var ErrorLogApiError = /** @class */ (function (_super) {
__extends(ErrorLogApiError, _super);
function ErrorLogApiError(message, status) {
var _this = _super.call(this, message) || this;
_this.name = 'ErrorLogApiError';
_this.status = status;
return _this;
}
return ErrorLogApiError;
}(Error));
function resolveErrorLogApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
var isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var ERROR_LOG_API_BASE_URL = resolveErrorLogApiBaseUrl();
var ERROR_LOG_API_FALLBACK_BASE_URL = !import.meta.env.VITE_WORK_SERVER_URL && ERROR_LOG_API_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
function requestOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, response, text, payload;
var _a, _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 1:
response = _d.sent();
if (!!response.ok) return [3 /*break*/, 3];
return [4 /*yield*/, response.text()];
case 2:
text = _d.sent();
try {
payload = JSON.parse(text);
throw new ErrorLogApiError(payload.message || '에러 로그 요청에 실패했습니다.', response.status);
}
catch (_e) {
throw new ErrorLogApiError(text || '에러 로그 요청에 실패했습니다.', response.status);
}
_d.label = 3;
case 3: return [2 /*return*/, response.json()];
}
});
});
}
function request(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_1, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(ERROR_LOG_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_1 = _a.sent();
shouldRetryWithFallback = ERROR_LOG_API_FALLBACK_BASE_URL &&
ERROR_LOG_API_FALLBACK_BASE_URL !== ERROR_LOG_API_BASE_URL &&
(error_1 instanceof ErrorLogApiError
? error_1.status === 404 || error_1.status === 408 || error_1.status === 502
: error_1 instanceof Error && /404|Failed to fetch|NetworkError/i.test(error_1.message));
if (!shouldRetryWithFallback) {
throw error_1;
}
return [2 /*return*/, requestOnce(ERROR_LOG_API_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function fetchErrorLogs() {
return __awaiter(this, arguments, void 0, function (limit) {
var token, response;
if (limit === void 0) { limit = 50; }
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
token = (0, tokenAccess_1.getRegisteredAccessToken)();
return [4 /*yield*/, request("/error-logs?limit=".concat(limit), {
headers: {
'X-Access-Token': token,
},
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function reportClientError(payload) {
return __awaiter(this, void 0, void 0, function () {
var _a;
var _b, _c, _d, _e, _f, _g, _h;
return __generator(this, function (_j) {
switch (_j.label) {
case 0:
_j.trys.push([0, 2, , 3]);
return [4 /*yield*/, request('/error-logs/report', {
method: 'POST',
body: JSON.stringify({
source: 'client',
sourceLabel: '프론트엔드',
errorType: payload.errorType,
errorName: (_b = payload.errorName) !== null && _b !== void 0 ? _b : null,
errorMessage: payload.errorMessage,
detail: (_c = payload.detail) !== null && _c !== void 0 ? _c : null,
stackTrace: (_d = payload.stackTrace) !== null && _d !== void 0 ? _d : null,
statusCode: (_e = payload.statusCode) !== null && _e !== void 0 ? _e : null,
requestMethod: (_f = payload.requestMethod) !== null && _f !== void 0 ? _f : null,
requestPath: (_g = payload.requestPath) !== null && _g !== void 0 ? _g : null,
context: (_h = payload.context) !== null && _h !== void 0 ? _h : null,
}),
})];
case 1:
_j.sent();
return [3 /*break*/, 3];
case 2:
_a = _j.sent();
return [3 /*break*/, 3];
case 3: return [2 /*return*/];
}
});
});
}

View File

@@ -101,7 +101,15 @@ function parseRoute(pathname: string): {
};
}
if (top === 'chat' && (first === 'live' || first === 'changes' || first === 'errors' || first === 'manage')) {
if (
top === 'chat' &&
(first === 'live' ||
first === 'changes' ||
first === 'resources' ||
first === 'errors' ||
first === 'manage' ||
first === 'manage-defaults')
) {
return {
topMenu: 'chat',
docsMenu: DOCS_DEFAULT_FOLDER,
@@ -112,7 +120,7 @@ function parseRoute(pathname: string): {
};
}
if (top === 'play' && first === 'layout') {
if (top === 'play' && (first === 'layout' || first === 'test' || first === 'cbt')) {
return {
topMenu: 'play',
docsMenu: DOCS_DEFAULT_FOLDER,
@@ -135,7 +143,7 @@ function parseRoute(pathname: string): {
}
return {
topMenu: 'plans',
topMenu: 'chat',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
@@ -169,7 +177,7 @@ function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean, t
return false;
}
return topMenu !== 'docs';
return true;
}
function resolveSidebarOpenKeys(
@@ -202,7 +210,7 @@ function resolveSidebarOpenKeys(
return ['app-log-group'];
}
return chatMenu === 'manage' ? ['chat-manage-group'] : ['codex-live-group'];
return chatMenu === 'manage' || chatMenu === 'manage-defaults' ? ['chat-manage-group'] : ['codex-live-group'];
}
export function MainLayout() {
@@ -328,6 +336,10 @@ export function MainLayout() {
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-left-middle-right',
hotZoneSize: 36,
minDistance: 180,
minViewportDistanceRatio: 0.35,
maxHorizontalDrift: 72,
onTrigger: () => {
openSearch('window');
},
@@ -467,7 +479,7 @@ export function MainLayout() {
/>
)}
<Layout>
<Layout className="app-shell__body">
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
<MainSidebar
activeTopMenu={routeState.topMenu}
@@ -514,7 +526,7 @@ export function MainLayout() {
}}
onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout' | 'test'));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
@@ -525,11 +537,13 @@ export function MainLayout() {
/>
)}
{isSidebarOverlayViewport && !sidebarCollapsed ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet />
</MainContent>
)}
<MainContent
contentExpanded={contentExpanded}
sidebarOverlayActive={isSidebarOverlayViewport && !sidebarCollapsed}
onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}
>
<Outlet />
</MainContent>
</Layout>
</Layout>
</MainLayoutContextProvider>

View File

@@ -207,6 +207,18 @@ export function buildSearchOptions({
},
onSelectWindow,
},
{
id: 'page:chat:resources',
label: 'Codex Live / 리소스 관리',
group: 'Page',
keywords: ['codex live', 'resource', 'resources', 'file', 'files', '리소스', '파일', '파일 시스템'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('resources'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:errors',
label: '앱로그 / 에러 로그',
@@ -232,6 +244,18 @@ export function buildSearchOptions({
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 기본 유형 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '기본 context', '채팅 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage-defaults'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []),

View File

@@ -20,6 +20,7 @@ import {
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
startTransition,
useEffect,
useMemo,
useRef,
@@ -33,12 +34,17 @@ import {
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import {
ChatPreviewBody,
resolveChatPreviewGlyph,
resolveChatPreviewKindLabel,
type ChatPreviewKind,
} from './ChatPreviewBody';
import { triggerResourceDownload } from './downloadUtils';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
@@ -110,6 +116,8 @@ type PreviewFetchError = Error & {
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
@@ -199,6 +207,25 @@ function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
}
}
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
const fileName = buildPreviewFileName(item).toLowerCase();
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
return match?.[1] ?? '';
}
function buildResourceChipMeta(item: Pick<PreviewOption, 'url' | 'label' | 'kind'>) {
const extension = resolvePreviewFileExtension(item);
if (extension) {
return extension.toUpperCase();
}
return resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)
.replace(/\s+preview$/i, '')
.replace(/\s+download$/i, '')
.toUpperCase();
}
function normalizeRankedLinkTitle(value: string) {
return value
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
@@ -561,6 +588,22 @@ function isActivityLogMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
function getMissingRequestMessageText(message: ChatMessage) {
if (!isMissingRequestMessage(message)) {
return '';
}
return message.text.slice(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`.length).trim();
}
function getExecutionFailureMessageText(message: ChatMessage) {
if (!isExecutionFailureMessage(message)) {
return '';
}
return message.text.slice(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`.length).trim();
}
function extractActivityLines(message: ChatMessage) {
return message.text
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
@@ -570,8 +613,20 @@ function extractActivityLines(message: ChatMessage) {
}
function summarizeActivityLines(lines: string[]) {
const latestLine = lines.at(-1) ?? '';
return latestLine;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const summary = lines[index]
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('# 이유:') || line.startsWith('# 진행:') || line.startsWith('# 상태:'));
if (!summary) {
continue;
}
return summary.replace(/^#\s*(||):\s*/u, '').trim();
}
return lines.at(-1) ?? '';
}
function isLikelyCollapsibleMessage(text: string) {
@@ -948,8 +1003,8 @@ type ChatConversationViewProps = {
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
onRemoveComposerAttachment: (attachmentId: string) => void;
onSelectChatType: (value: string) => void;
onSend: () => void;
onSendImmediate: () => void;
onSend: (draftText?: string) => void;
onSendImmediate: (draftText?: string) => void;
onToggleSendWithoutContext: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
@@ -1018,12 +1073,58 @@ 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]);
useEffect(() => {
if (composerDraft === lastReportedDraftRef.current) {
return;
}
const timeoutId = window.setTimeout(() => {
lastReportedDraftRef.current = composerDraft;
startTransition(() => {
onDraftChange(composerDraft);
});
}, 120);
return () => {
window.clearTimeout(timeoutId);
};
}, [composerDraft, onDraftChange]);
const orderedMessages = useMemo(() => {
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
const requestId = activityMessage.clientRequestId?.trim();
if (!requestId) {
return true;
}
const requestState = requestStateMap.get(requestId);
const hasCodexResponse = visibleMessages.some(
(candidate) =>
candidate.clientRequestId?.trim() === requestId &&
candidate.author === 'codex' &&
candidate.text.trim().length > 0 &&
!isPreparingChatReplyText(candidate.text),
);
return !isTerminalRequestStatus(requestState?.status) || !hasCodexResponse;
};
const latestActivityByRequestId = new Map<string, ChatMessage>();
const orphanActivityMessages: ChatMessage[] = [];
const baseMessages = visibleMessages.filter((message) => {
@@ -1038,7 +1139,9 @@ export function ChatConversationView({
return false;
}
latestActivityByRequestId.set(activityKey, message);
if (shouldDisplayActivityMessage(message)) {
latestActivityByRequestId.set(activityKey, message);
}
return false;
});
const insertedActivityRequestIds = new Set<string>();
@@ -1074,19 +1177,9 @@ export function ChatConversationView({
});
return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]);
}, [requestStateMap, visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const isChatTypeReadonly = useMemo(() => {
if (isChatTypeSelectionLocked) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
return Boolean(new URLSearchParams(window.location.search).get('sessionId')?.trim());
}, [isChatTypeSelectionLocked]);
const isChatTypeReadonly = isChatTypeSelectionLocked;
const visiblePreviewItems = useMemo(() => {
if (!showLatestResourceOnly) {
return previewItems;
@@ -1590,8 +1683,20 @@ export function ChatConversationView({
onOpenPreview(item.id);
}}
>
<span title={item.label}>{item.label}</span>
<span>{item.kind}</span>
<span className="app-chat-panel__resource-chip-main">
<span className="app-chat-panel__resource-chip-icon" aria-hidden="true">
{resolveChatPreviewGlyph(item.kind as ChatPreviewKind)}
</span>
<span className="app-chat-panel__resource-chip-label" title={item.label}>
{item.label}
</span>
</span>
<span
className="app-chat-panel__resource-chip-meta"
aria-label={`${resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)} 형식`}
>
{buildResourceChipMeta(item)}
</span>
</button>
))}
</div>
@@ -1638,8 +1743,17 @@ export function ChatConversationView({
const canCollapseMessage = collapsibleMessageIds.includes(message.id);
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const isRecoveredMissingRequest = isMissingRequestMessage(message);
const isRecoveredExecutionFailure = isExecutionFailureMessage(message);
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 renderedText = isRecoveredMissingRequest
? getMissingRequestMessageText(message)
: isRecoveredExecutionFailure
? getExecutionFailureMessageText(message)
: visibleText;
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
@@ -1651,9 +1765,9 @@ export function ChatConversationView({
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const stackClassName = [
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
]
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
]
.filter(Boolean)
.join(' ');
const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
@@ -1663,10 +1777,26 @@ export function ChatConversationView({
return (
<div key={message.id} className={stackClassName}>
{shouldRenderStandalonePreview ? null : (
<article className={`app-chat-message app-chat-message--${message.author}`}>
<article
className={`app-chat-message ${
isRecoveredMissingRequest || isRecoveredExecutionFailure
? 'app-chat-message--system-inline'
: `app-chat-message--${message.author}`
}`}
>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'}</strong>
<strong>
{isRecoveredMissingRequest
? '원문 누락'
: isRecoveredExecutionFailure
? '실행 실패'
: message.author === 'codex'
? 'Codex'
: message.author === 'user'
? 'You'
: 'System'}
</strong>
<span>{formatChatTimestamp(message.timestamp)}</span>
{message.author === 'user' && requestStatusLabel ? (
<span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}>
@@ -1743,13 +1873,13 @@ export function ChatConversationView({
/>
) : null}
</div>
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{visibleText ? renderMessageBody(visibleText) : null}
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={baseMessageBodyClassName}
>
{renderedText ? renderMessageBody(renderedText) : null}
</div>
{message.author === 'user' && requestDetailText ? (
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
@@ -1917,22 +2047,38 @@ export function ChatConversationView({
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
}`}
icon={<DisconnectOutlined />}
aria-label={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
title={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
aria-label={
isSendWithoutContextEnabled
? '다음 1회만 문맥 없이 보냄'
: '다음 전송을 문맥 없이 보내기'
}
title={
isSendWithoutContextEnabled
? '다음 1회만 문맥 없이 보냄'
: '다음 전송을 문맥 없이 보내기'
}
onClick={onToggleSendWithoutContext}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
icon={<ThunderboltOutlined />}
aria-label="즉시 요청"
onClick={onSendImmediate}
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSendImmediate(composerDraft);
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
type="primary"
icon={<SendOutlined />}
aria-label="큐로 보내기"
onClick={onSend}
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSend(composerDraft);
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
</div>
@@ -1987,12 +2133,12 @@ export function ChatConversationView({
<Input.TextArea
ref={composerRef}
value={draft}
value={composerDraft}
autoSize={false}
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
onDraftChange(event.target.value);
setComposerDraft(event.target.value);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
@@ -2012,16 +2158,18 @@ export function ChatConversationView({
event.preventDefault();
event.stopPropagation();
onSend();
lastReportedDraftRef.current = event.currentTarget.value;
onDraftChange(event.currentTarget.value);
onSend(event.currentTarget.value);
}}
/>
<Button
type="text"
size="small"
className={`app-chat-panel__composer-clear${draft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
className={`app-chat-panel__composer-clear${composerDraft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
aria-label="입력창 비우기"
onClick={onClearDraft}
disabled={!draft.trim()}
disabled={!composerDraft.trim()}
>
clear
</Button>

View File

@@ -0,0 +1,191 @@
import { Typography } from 'antd';
import type { ChatPreviewTarget } from './ChatPreviewBody';
const { Text } = Typography;
type TableCellValue = string;
type TabularPreviewModel = {
columns: string[];
rows: TableCellValue[][];
rowCount: number;
sourceLabel: string;
};
type ChatDataTablePreviewProps = {
model: TabularPreviewModel;
};
function stringifyCellValue(value: unknown): TableCellValue {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function normalizeObjectRows(rows: Record<string, unknown>[], sourceLabel: string): TabularPreviewModel | null {
if (!rows.length) {
return null;
}
const columns = Array.from(
rows.reduce((set, row) => {
Object.keys(row).forEach((key) => set.add(key));
return set;
}, new Set<string>()),
);
if (!columns.length) {
return null;
}
return {
columns,
rows: rows.map((row) => columns.map((column) => stringifyCellValue(row[column]))),
rowCount: rows.length,
sourceLabel,
};
}
function resolveJsonRows(value: unknown): Record<string, unknown>[] | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
return value as Record<string, unknown>[];
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const entries = Object.values(value as Record<string, unknown>);
for (const entry of entries) {
const resolved = resolveJsonRows(entry);
if (resolved?.length) {
return resolved;
}
}
}
return null;
}
function parseCsvLine(line: string) {
const cells: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index] ?? '';
const nextChar = line[index + 1] ?? '';
if (char === '"') {
if (quoted && nextChar === '"') {
current += '"';
index += 1;
continue;
}
quoted = !quoted;
continue;
}
if (char === ',' && !quoted) {
cells.push(current.trim());
current = '';
continue;
}
current += char;
}
cells.push(current.trim());
return cells;
}
function parseCsvTable(previewText: string, sourceLabel: string): TabularPreviewModel | null {
const lines = previewText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length < 2) {
return null;
}
const header = parseCsvLine(lines[0] ?? '');
if (!header.length || header.every((column) => !column)) {
return null;
}
const rows = lines.slice(1).map((line) => {
const parsed = parseCsvLine(line);
return header.map((_, index) => parsed[index] ?? '');
});
return {
columns: header,
rows,
rowCount: rows.length,
sourceLabel,
};
}
export function resolveTabularPreviewModel(target: ChatPreviewTarget, previewText: string): TabularPreviewModel | null {
const pathname = target.url.toLowerCase().split('?')[0] ?? '';
if (pathname.endsWith('.json')) {
try {
const parsed = JSON.parse(previewText) as unknown;
const rows = resolveJsonRows(parsed);
return rows ? normalizeObjectRows(rows, target.label) : null;
} catch {
return null;
}
}
if (pathname.endsWith('.csv')) {
return parseCsvTable(previewText, target.label);
}
return null;
}
export function ChatDataTablePreview({ model }: ChatDataTablePreviewProps) {
return (
<div className="app-chat-panel__preview-table">
<div className="app-chat-panel__preview-table-meta">
<Text strong>{model.sourceLabel}</Text>
<Text type="secondary">{`${model.rowCount}개 · 열 ${model.columns.length}`}</Text>
</div>
<div className="app-chat-panel__preview-table-scroll">
<table className="app-chat-panel__preview-table-grid">
<thead>
<tr>
{model.columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{model.rows.map((row, rowIndex) => (
<tr key={`${model.sourceLabel}-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${model.columns[cellIndex] ?? cellIndex}-${rowIndex}`}>{cell || '-'}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer';
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
import { ChatDataTablePreview, resolveTabularPreviewModel } from './ChatDataTablePreview';
import { triggerResourceDownload } from './downloadUtils';
import '../../../components/previewer/PreviewerUI.css';
@@ -359,6 +360,12 @@ export function ChatPreviewBody({
}
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const tabularModel = resolveTabularPreviewModel(target, previewText);
if (tabularModel) {
return <ChatDataTablePreview model={tabularModel} />;
}
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {

View File

@@ -0,0 +1,32 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeChatResourceUrl = normalizeChatResourceUrl;
var CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
var CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
function extractEmbeddedResourcePath(value) {
var normalized = String(value !== null && value !== void 0 ? value : '').trim();
if (!normalized) {
return '';
}
var apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
if (apiMarkerIndex >= 0) {
return normalized.slice(apiMarkerIndex);
}
var publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
if (publicMarkerIndex >= 0) {
return normalized.slice(publicMarkerIndex);
}
return normalized;
}
function normalizeChatResourceUrl(value) {
var normalized = extractEmbeddedResourcePath(value);
if (typeof window === 'undefined') {
return normalized;
}
try {
return new URL(normalized, window.location.href).toString();
}
catch (_a) {
return normalized;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const KST_TIME_ZONE = 'Asia/Seoul';
const chatSessionLastTypeMemory = new Map<string, string>();
@@ -46,18 +48,23 @@ function toConversationSortTime(value: string | null | undefined) {
return Number.isNaN(parsed) ? 0 : parsed;
}
function getConversationLastMessageSortTime(item: ChatConversationSummary) {
const lastMessageTime = toConversationSortTime(item.lastMessageAt);
if (lastMessageTime > 0) {
return lastMessageTime;
}
return Math.max(
toConversationSortTime(item.createdAt),
toConversationSortTime(item.updatedAt),
);
}
export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
return [...items].sort((left, right) => {
const leftTime = Math.max(
toConversationSortTime(left.lastMessageAt),
toConversationSortTime(left.updatedAt),
toConversationSortTime(left.createdAt),
);
const rightTime = Math.max(
toConversationSortTime(right.lastMessageAt),
toConversationSortTime(right.updatedAt),
toConversationSortTime(right.createdAt),
);
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime - leftTime;
@@ -289,18 +296,29 @@ function createLocalMessageId() {
return Date.now() * 1_000 + localMessageSequence;
}
function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') {
function createRecoveredMessageId(
requestId: string,
variant: 'user' | 'codex' | 'activity' | 'missing-request' | 'execution-failure',
) {
const baseId = hashRequestId(requestId) * 10;
if (variant === 'user') {
return -(baseId + 3);
}
if (variant === 'activity') {
if (variant === 'missing-request') {
return -(baseId + 2);
}
return -(baseId + 1);
if (variant === 'activity') {
return -(baseId + 1);
}
if (variant === 'execution-failure') {
return -(baseId + 4);
}
return -(baseId + 5);
}
function hashRequestId(value: string) {
@@ -355,6 +373,170 @@ function isActivityLogMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
export function isMissingRequestMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`);
}
export function isExecutionFailureMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`);
}
function isEmptyCodexExecutionResponse(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
return normalized === 'Codex 실행 결과가 비어 있습니다.';
}
function extractActivityLogFailureReason(lines?: string[] | null) {
const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean);
for (let index = normalizedLines.length - 1; index >= 0; index -= 1) {
const line = normalizedLines[index];
if (!line.startsWith('# 오류:')) {
continue;
}
const raw = line.slice('# 오류:'.length).trim();
if (!raw) {
continue;
}
try {
const parsed = JSON.parse(raw) as { message?: unknown };
const message = typeof parsed.message === 'string' ? parsed.message.trim() : '';
if (message) {
return message;
}
} catch {
return raw;
}
}
return '';
}
function buildExecutionFailureMessage(reason: string) {
const normalizedReason = reason.trim();
if (!normalizedReason) {
return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n실행 중 오류가 발생했습니다.`;
}
const simplifiedReason = normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')
? `세션 리소스 업로드 폴더를 만들 권한이 없어 응답 생성이 중단되었습니다.\n\n원인: ${normalizedReason}`
: `실행 중 오류가 발생했습니다.\n\n원인: ${normalizedReason}`;
return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n${simplifiedReason}`;
}
function buildFailurePreviewText(reason: string) {
const normalizedReason = reason.trim();
if (!normalizedReason) {
return '실행 실패';
}
if (normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')) {
return '실행 실패: 세션 리소스 업로드 폴더 권한 오류';
}
return `실행 실패: ${normalizedReason}`;
}
function enrichFailedRequestsWithActivityLogs(
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
) {
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
return requests.map((request) => {
if (request.status !== 'failed') {
return request;
}
const activityLog = activityLogMap.get(request.requestId.trim());
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
const normalizedStatusMessage = String(request.statusMessage ?? '').trim();
if (!failureReason) {
return request;
}
if (normalizedStatusMessage && normalizedStatusMessage !== '요청 처리 실패') {
return request;
}
return {
...request,
statusMessage: failureReason,
};
});
}
function replaceGenericFailureMessages(
messages: ChatMessage[],
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
): ChatMessage[] {
const requestMap = new Map(requests.map((item) => [item.requestId.trim(), item]));
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
return messages.map((message) => {
const requestId = message.clientRequestId?.trim() ?? '';
if (!requestId || message.author !== 'codex' || !isEmptyCodexExecutionResponse(message.text)) {
return message;
}
const request = requestMap.get(requestId);
if (request?.status !== 'failed') {
return message;
}
const failureReason = extractActivityLogFailureReason(activityLogMap.get(requestId)?.lines);
if (!failureReason) {
return message;
}
return {
...message,
author: 'system' as const,
text: buildExecutionFailureMessage(failureReason),
};
});
}
function resolveConversationFailurePreview(
currentPreview: string,
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
) {
if (!isEmptyCodexExecutionResponse(currentPreview)) {
return currentPreview;
}
const latestFailedRequest = [...requests]
.reverse()
.find((request) => request.status === 'failed' && isEmptyCodexExecutionResponse(String(request.responseText ?? '').trim()));
if (!latestFailedRequest) {
return currentPreview;
}
const activityLog = activityLogs.find((item) => item.requestId.trim() === latestFailedRequest.requestId.trim());
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
if (!failureReason) {
return currentPreview;
}
return buildFailurePreviewText(failureReason);
}
function extractActivityLogLines(text: string) {
return text
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
@@ -934,7 +1116,9 @@ export async function fetchChatConversations() {
}
const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>(
'/conversations?limit=200',
)
.then((response) => {
return sortChatConversationSummaries(
response.items.map((item) => ({
@@ -971,17 +1155,24 @@ export async function fetchChatConversationDetail(
const response = await requestChatApi<ChatConversationDetailResponse>(
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
);
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
const normalizedRequests = enrichFailedRequestsWithActivityLogs(
response.requests.map((item) => normalizeChatConversationRequest(item)),
response.activityLogs,
);
const visibleRequestIds = new Set(
response.messages
.map((message) => message.clientRequestId?.trim() ?? '')
.filter(Boolean),
);
const hydratedMessages = hydrateActivityLogMessages(
response.messages,
replaceGenericFailureMessages(response.messages, normalizedRequests, response.activityLogs),
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) => message.author !== 'system' || isActivityLogMessage(message),
(message) =>
message.author !== 'system' ||
isActivityLogMessage(message) ||
isMissingRequestMessage(message) ||
isExecutionFailureMessage(message),
);
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
@@ -990,6 +1181,11 @@ export async function fetchChatConversationDetail(
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
item: {
...response.item,
lastMessagePreview: resolveConversationFailurePreview(
response.item.lastMessagePreview,
normalizedRequests,
response.activityLogs,
),
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
response.item.sessionId,
response.item.notifyOffline,
@@ -1123,6 +1319,7 @@ export async function createChatConversationRoom(args: {
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -1136,6 +1333,7 @@ export async function createChatConversationRoom(args: {
title: args.title ?? '새 대화',
chatTypeId: args.chatTypeId ?? null,
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
generalSectionName: args.generalSectionName ?? null,
contextLabel: args.contextLabel ?? null,
contextDescription: args.contextDescription ?? null,
notifyOffline,
@@ -1173,6 +1371,7 @@ export async function updateChatConversationRoom(
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean;
@@ -1307,6 +1506,14 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
}
if (isMissingRequestMessage(left) && isMissingRequestMessage(right)) {
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
}
if (isExecutionFailureMessage(left) && isExecutionFailureMessage(right)) {
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
}
return Boolean(
(left.author === 'user' || left.author === 'codex') &&
left.author === right.author &&
@@ -1321,6 +1528,14 @@ function buildComparableChatMessageKey(message: ChatMessage) {
return `activity:${message.clientRequestId}`;
}
if (isMissingRequestMessage(message) && message.clientRequestId) {
return `missing-request:${message.clientRequestId}`;
}
if (isExecutionFailureMessage(message) && message.clientRequestId) {
return `execution-failure:${message.clientRequestId}`;
}
if (message.author === 'user' && message.clientRequestId) {
return `user-request:${message.clientRequestId}`;
}
@@ -1337,6 +1552,123 @@ function getComparableChatMessageTime(message: ChatMessage) {
return Number.isFinite(parsed) ? parsed : 0;
}
function getChatMessageRequestId(message: ChatMessage) {
return message.clientRequestId?.trim() || '';
}
function getChatMessageOrderRank(message: ChatMessage) {
if (message.author === 'user') {
return 0;
}
if (isMissingRequestMessage(message)) {
return 1;
}
if (isExecutionFailureMessage(message)) {
return 2;
}
if (isActivityLogMessage(message)) {
return 3;
}
if (message.author === 'codex') {
return 3;
}
return 4;
}
function sortConversationMessages(messages: ChatMessage[]) {
if (messages.length <= 1) {
return messages;
}
const messageIndexMap = new Map(messages.map((message, index) => [message, index]));
const requestOrder = new Map<
string,
{
time: number;
firstIndex: number;
}
>();
messages.forEach((message, index) => {
const requestId = getChatMessageRequestId(message);
if (!requestId) {
return;
}
const time = getComparableChatMessageTime(message);
const existing = requestOrder.get(requestId);
if (!existing) {
requestOrder.set(requestId, {
time,
firstIndex: index,
});
return;
}
requestOrder.set(requestId, {
time:
existing.time > 0 && time > 0
? Math.min(existing.time, time)
: existing.time > 0
? existing.time
: time,
firstIndex: Math.min(existing.firstIndex, index),
});
});
return [...messages].sort((left, right) => {
const leftRequestId = getChatMessageRequestId(left);
const rightRequestId = getChatMessageRequestId(right);
if (leftRequestId && rightRequestId && leftRequestId === rightRequestId) {
const rankDiff = getChatMessageOrderRank(left) - getChatMessageOrderRank(right);
if (rankDiff !== 0) {
return rankDiff;
}
}
const leftOrder = leftRequestId ? requestOrder.get(leftRequestId) : null;
const rightOrder = rightRequestId ? requestOrder.get(rightRequestId) : null;
const leftTime = leftOrder?.time ?? getComparableChatMessageTime(left);
const rightTime = rightOrder?.time ?? getComparableChatMessageTime(right);
if (leftTime !== rightTime) {
return leftTime - rightTime;
}
const leftIndex = leftOrder?.firstIndex ?? messageIndexMap.get(left) ?? 0;
const rightIndex = rightOrder?.firstIndex ?? messageIndexMap.get(right) ?? 0;
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
if (leftRequestId && rightRequestId && leftRequestId !== rightRequestId) {
const requestDiff = leftRequestId.localeCompare(rightRequestId, 'ko-KR');
if (requestDiff !== 0) {
return requestDiff;
}
}
const messageTimeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
if (messageTimeDiff !== 0) {
return messageTimeDiff;
}
return left.id - right.id;
});
}
function buildRecoveredMessagesFromConversationDetail(
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
@@ -1354,6 +1686,9 @@ function buildRecoveredMessagesFromConversationDetail(
const userText = String(request.userText ?? '').trim();
const responseText = String(request.responseText ?? '').trim();
const activityLog = activityLogMap.get(requestId);
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
const shouldReplaceEmptyFailureResponse =
request.status === 'failed' && isEmptyCodexExecutionResponse(responseText) && Boolean(failureReason);
if (userText) {
nextMessages.push({
@@ -1363,9 +1698,17 @@ function buildRecoveredMessagesFromConversationDetail(
timestamp: request.createdAt || request.updatedAt || '',
clientRequestId: requestId,
});
} else if (responseText || activityLog?.lines.length) {
nextMessages.push({
id: createRecoveredMessageId(requestId, 'missing-request'),
author: 'system',
text: `${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n이 요청은 저장된 원문이 없어 실제 요청 문장을 표시할 수 없습니다.`,
timestamp: request.createdAt || request.updatedAt || '',
clientRequestId: requestId,
});
}
if (responseText) {
if (responseText && !shouldReplaceEmptyFailureResponse) {
nextMessages.push({
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
author: 'codex',
@@ -1375,6 +1718,16 @@ function buildRecoveredMessagesFromConversationDetail(
});
}
if (shouldReplaceEmptyFailureResponse) {
nextMessages.push({
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'execution-failure'),
author: 'system',
text: buildExecutionFailureMessage(failureReason),
timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
clientRequestId: requestId,
});
}
if (activityLog && activityLog.lines.length > 0) {
nextMessages.push({
id: createRecoveredMessageId(requestId, 'activity'),
@@ -1386,15 +1739,7 @@ function buildRecoveredMessagesFromConversationDetail(
}
});
return nextMessages.sort((left, right) => {
const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
if (timeDiff !== 0) {
return timeDiff;
}
return left.id - right.id;
});
return sortConversationMessages(nextMessages);
}
export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
@@ -1459,7 +1804,7 @@ export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: Ch
});
const unmatchedLocalMessages = Array.from(previousBuckets.values()).flat();
const nextMessages = [...mergedServerMessages, ...unmatchedLocalMessages];
const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]);
return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
}

View File

@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.useErrorLogs = exports.useChatConnection = exports.subscribeChatConnection = exports.setSharedChatRuntimeSnapshot = exports.getSharedChatRuntimeSnapshot = exports.updateChatConversationRoom = exports.upsertChatMessage = exports.uploadChatComposerFile = exports.sortChatConversationSummaries = exports.setChatClientSessionId = exports.setStoredChatSessionLastTypeId = exports.resetLastReceivedChatEventId = exports.removeChatRuntimeJob = exports.renameChatConversationRoom = exports.mergeRecoveredChatMessages = exports.markChatConversationResponsesRead = exports.getChatClientSessionId = exports.isPreparingChatReplyText = exports.isMissingRequestMessage = exports.getStoredChatSessionLastTypeId = exports.fetchChatRuntimeSnapshot = exports.fetchChatRuntimeJobDetail = exports.fetchChatConversations = exports.fetchChatConversationDetail = exports.deleteChatConversationRoom = exports.deleteChatConversationRequest = exports.cancelChatRuntimeJob = exports.createLocalMessage = exports.createIntroMessage = exports.createChatMessage = exports.createChatConversationRoom = exports.createActivityLogPlaceholder = exports.resolvePreviewBodyForCopy = exports.copyText = exports.copyPreviewContent = exports.clearStoredChatClientConversationState = exports.buildOfflineReply = exports.ErrorLogViewer = exports.ChatRuntimeDashboard = exports.ChatConversationView = void 0;
var ChatConversationView_1 = require("./ChatConversationView");
Object.defineProperty(exports, "ChatConversationView", { enumerable: true, get: function () { return ChatConversationView_1.ChatConversationView; } });
var ChatRuntimeDashboard_1 = require("./ChatRuntimeDashboard");
Object.defineProperty(exports, "ChatRuntimeDashboard", { enumerable: true, get: function () { return ChatRuntimeDashboard_1.ChatRuntimeDashboard; } });
var ErrorLogViewer_1 = require("./ErrorLogViewer");
Object.defineProperty(exports, "ErrorLogViewer", { enumerable: true, get: function () { return ErrorLogViewer_1.ErrorLogViewer; } });
var chatUtils_1 = require("./chatUtils");
Object.defineProperty(exports, "buildOfflineReply", { enumerable: true, get: function () { return chatUtils_1.buildOfflineReply; } });
Object.defineProperty(exports, "clearStoredChatClientConversationState", { enumerable: true, get: function () { return chatUtils_1.clearStoredChatClientConversationState; } });
Object.defineProperty(exports, "copyPreviewContent", { enumerable: true, get: function () { return chatUtils_1.copyPreviewContent; } });
Object.defineProperty(exports, "copyText", { enumerable: true, get: function () { return chatUtils_1.copyText; } });
Object.defineProperty(exports, "resolvePreviewBodyForCopy", { enumerable: true, get: function () { return chatUtils_1.resolvePreviewBodyForCopy; } });
Object.defineProperty(exports, "createActivityLogPlaceholder", { enumerable: true, get: function () { return chatUtils_1.createActivityLogPlaceholder; } });
Object.defineProperty(exports, "createChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.createChatConversationRoom; } });
Object.defineProperty(exports, "createChatMessage", { enumerable: true, get: function () { return chatUtils_1.createChatMessage; } });
Object.defineProperty(exports, "createIntroMessage", { enumerable: true, get: function () { return chatUtils_1.createIntroMessage; } });
Object.defineProperty(exports, "createLocalMessage", { enumerable: true, get: function () { return chatUtils_1.createLocalMessage; } });
Object.defineProperty(exports, "cancelChatRuntimeJob", { enumerable: true, get: function () { return chatUtils_1.cancelChatRuntimeJob; } });
Object.defineProperty(exports, "deleteChatConversationRequest", { enumerable: true, get: function () { return chatUtils_1.deleteChatConversationRequest; } });
Object.defineProperty(exports, "deleteChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.deleteChatConversationRoom; } });
Object.defineProperty(exports, "fetchChatConversationDetail", { enumerable: true, get: function () { return chatUtils_1.fetchChatConversationDetail; } });
Object.defineProperty(exports, "fetchChatConversations", { enumerable: true, get: function () { return chatUtils_1.fetchChatConversations; } });
Object.defineProperty(exports, "fetchChatRuntimeJobDetail", { enumerable: true, get: function () { return chatUtils_1.fetchChatRuntimeJobDetail; } });
Object.defineProperty(exports, "fetchChatRuntimeSnapshot", { enumerable: true, get: function () { return chatUtils_1.fetchChatRuntimeSnapshot; } });
Object.defineProperty(exports, "getStoredChatSessionLastTypeId", { enumerable: true, get: function () { return chatUtils_1.getStoredChatSessionLastTypeId; } });
Object.defineProperty(exports, "isMissingRequestMessage", { enumerable: true, get: function () { return chatUtils_1.isMissingRequestMessage; } });
Object.defineProperty(exports, "isPreparingChatReplyText", { enumerable: true, get: function () { return chatUtils_1.isPreparingChatReplyText; } });
Object.defineProperty(exports, "getChatClientSessionId", { enumerable: true, get: function () { return chatUtils_1.getChatClientSessionId; } });
Object.defineProperty(exports, "markChatConversationResponsesRead", { enumerable: true, get: function () { return chatUtils_1.markChatConversationResponsesRead; } });
Object.defineProperty(exports, "mergeRecoveredChatMessages", { enumerable: true, get: function () { return chatUtils_1.mergeRecoveredChatMessages; } });
Object.defineProperty(exports, "renameChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.renameChatConversationRoom; } });
Object.defineProperty(exports, "removeChatRuntimeJob", { enumerable: true, get: function () { return chatUtils_1.removeChatRuntimeJob; } });
Object.defineProperty(exports, "resetLastReceivedChatEventId", { enumerable: true, get: function () { return chatUtils_1.resetLastReceivedChatEventId; } });
Object.defineProperty(exports, "setStoredChatSessionLastTypeId", { enumerable: true, get: function () { return chatUtils_1.setStoredChatSessionLastTypeId; } });
Object.defineProperty(exports, "setChatClientSessionId", { enumerable: true, get: function () { return chatUtils_1.setChatClientSessionId; } });
Object.defineProperty(exports, "sortChatConversationSummaries", { enumerable: true, get: function () { return chatUtils_1.sortChatConversationSummaries; } });
Object.defineProperty(exports, "uploadChatComposerFile", { enumerable: true, get: function () { return chatUtils_1.uploadChatComposerFile; } });
Object.defineProperty(exports, "upsertChatMessage", { enumerable: true, get: function () { return chatUtils_1.upsertChatMessage; } });
Object.defineProperty(exports, "updateChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.updateChatConversationRoom; } });
var useChatConnection_1 = require("./useChatConnection");
Object.defineProperty(exports, "getSharedChatRuntimeSnapshot", { enumerable: true, get: function () { return useChatConnection_1.getSharedChatRuntimeSnapshot; } });
Object.defineProperty(exports, "setSharedChatRuntimeSnapshot", { enumerable: true, get: function () { return useChatConnection_1.setSharedChatRuntimeSnapshot; } });
Object.defineProperty(exports, "subscribeChatConnection", { enumerable: true, get: function () { return useChatConnection_1.subscribeChatConnection; } });
Object.defineProperty(exports, "useChatConnection", { enumerable: true, get: function () { return useChatConnection_1.useChatConnection; } });
var useErrorLogs_1 = require("./useErrorLogs");
Object.defineProperty(exports, "useErrorLogs", { enumerable: true, get: function () { return useErrorLogs_1.useErrorLogs; } });

View File

@@ -20,6 +20,7 @@ export {
fetchChatRuntimeJobDetail,
fetchChatRuntimeSnapshot,
getStoredChatSessionLastTypeId,
isMissingRequestMessage,
isPreparingChatReplyText,
getChatClientSessionId,
markChatConversationResponsesRead,

View File

@@ -1,11 +1,32 @@
const AUTO_DETECTED_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s<>)\]]+|\/[A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]+)/g;
const AUTO_DETECTED_PREVIEW_URL_PATTERN =
/(https?:\/\/[^\s<>)\]]+|\/(?:[A-Za-z0-9._~%-][A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]*))/g;
const LOCAL_RESOURCE_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const PREVIEWABLE_FILE_EXTENSION_PATTERN =
/\.(png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|md|markdown|diff|patch|ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml|txt|log|csv|pdf)$/i;
function stripCodeFenceBlocks(text: string) {
return String(text ?? '').replace(/```[\s\S]*?```/g, '');
}
function trimAutoDetectedUrl(value: string) {
return String(value ?? '').trim().replace(/[`\])}>.,;!?]+$/g, '');
}
function isLikelyLocalPreviewUrl(value: string) {
if (LOCAL_RESOURCE_PREFIXES.some((prefix) => value.startsWith(prefix))) {
return true;
}
const pathname = value.split(/[?#]/, 1)[0] ?? '';
return PREVIEWABLE_FILE_EXTENSION_PATTERN.test(pathname);
}
export function extractAutoDetectedPreviewUrls(text: string) {
const normalized = String(text ?? '');
const normalized = stripCodeFenceBlocks(text);
const urls: string[] = [];
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
const value = match[0]?.trim();
const value = trimAutoDetectedUrl(match[0] ?? '');
if (!value) {
continue;
@@ -19,6 +40,10 @@ export function extractAutoDetectedPreviewUrls(text: string) {
continue;
}
if (value.startsWith('/') && !isLikelyLocalPreviewUrl(value)) {
continue;
}
urls.push(value);
}

View File

@@ -16,6 +16,11 @@ function normalizeUrl(value: string) {
return '';
}
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
@@ -23,11 +28,43 @@ function normalizeUrl(value: string) {
return '';
}
function decodeUrlComponentSafely(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function resolveLinkCardUrlAndActionLabel(rawUrl: string, rawActionLabel?: string) {
let resolvedUrl = normalizeText(rawUrl);
let resolvedActionLabel = normalizeText(rawActionLabel);
if (!resolvedActionLabel) {
const decodedUrl = decodeUrlComponentSafely(resolvedUrl);
const dividerIndex = decodedUrl.lastIndexOf('|');
if (dividerIndex > 0 && dividerIndex < decodedUrl.length - 1) {
resolvedUrl = decodedUrl.slice(0, dividerIndex).trim();
resolvedActionLabel = decodedUrl.slice(dividerIndex + 1).trim();
}
}
return {
url: normalizeUrl(resolvedUrl),
actionLabel: resolvedActionLabel || null,
};
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isInternalResourceUrl(url: string) {
return RESOURCE_PATH_PREFIXES.some((prefix) => url.startsWith(prefix));
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
@@ -35,15 +72,11 @@ function isStructuredLinkCardCandidate(url: string) {
return false;
}
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
if (isInternalResourceUrl(normalized)) {
return false;
}
if (/^https?:\/\//i.test(normalized)) {
return !hasKnownFileExtension(normalized);
}
return !hasKnownFileExtension(normalized);
return /^https?:\/\//i.test(normalized) && !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
@@ -88,8 +121,7 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const [rawTitle, rawUrl, rawActionLabel] = segments;
const title = normalizeText(rawTitle);
const url = normalizeUrl(rawUrl);
const actionLabel = normalizeText(rawActionLabel) || null;
const { url, actionLabel } = resolveLinkCardUrlAndActionLabel(rawUrl, rawActionLabel);
if (!title || !url) {
return null;
@@ -154,6 +186,14 @@ export function extractChatMessageParts(text: string) {
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
keptLines.push(line);
continue;
}
const latestPart = parts.at(-1);
if (latestPart && isInternalResourceUrl(latestPart.url)) {
parts.pop();
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
keptLines.push(latestPart.url);
}
}

View File

@@ -14,10 +14,117 @@ export type PreviewItem = {
source: 'message' | 'context';
};
const CHAT_RESOURCE_INTERNAL_SEGMENTS = new Set(['resource', 'uploads', 'source', 'src']);
const CHAT_RESOURCE_HIDDEN_FILE_NAMES = new Set(['.env']);
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
const RESOURCE_STRIP_ALLOWED_KINDS = new Set<PreviewKind>([
'image',
'video',
'markdown',
'code',
'diff',
'document',
'pdf',
'file',
]);
function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
function parsePreviewUrl(url: string) {
try {
return new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
} catch {
return null;
}
}
function extractInternalChatResourcePath(pathname: string) {
const normalizedPathname = String(pathname ?? '').trim();
if (!normalizedPathname) {
return '';
}
if (normalizedPathname.includes('/.codex_chat/')) {
const markerIndex = normalizedPathname.lastIndexOf('/.codex_chat/');
return normalizedPathname.slice(markerIndex + 1);
}
const apiMarkerIndex = normalizedPathname.lastIndexOf(CHAT_API_RESOURCE_MARKER);
if (apiMarkerIndex >= 0) {
return normalizedPathname.slice(apiMarkerIndex + CHAT_API_RESOURCE_MARKER.length).replace(/^\/+/, '');
}
return '';
}
function hasVisibleFileExtension(fileName: string) {
return /\.[a-z0-9]{1,16}$/i.test(fileName);
}
function hasSupportedPreviewFileExtension(fileName: string) {
return /\.(png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|md|markdown|diff|patch|ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml|txt|log|csv|pdf)$/i.test(
fileName,
);
}
function isMarkdownResourceFile(fileName: string) {
return /\.(md|markdown)$/i.test(fileName);
}
function shouldHideInternalChatResource(url: string) {
const parsed = parsePreviewUrl(url);
const pathname = parsed?.pathname ?? '';
const internalResourcePath = extractInternalChatResourcePath(pathname);
const normalizedInternalResourcePath = internalResourcePath.toLowerCase();
if (!normalizedInternalResourcePath.startsWith('.codex_chat/')) {
return false;
}
const segments = internalResourcePath.split('/').filter(Boolean);
const lastSegment = segments.at(-1)?.trim() ?? '';
const normalizedLastSegment = lastSegment.toLowerCase();
const resourceSegmentIndex = segments.findIndex((segment) => segment === 'resource');
const nextSegment = resourceSegmentIndex >= 0 ? segments[resourceSegmentIndex + 1]?.toLowerCase() ?? '' : '';
if (!lastSegment || pathname.endsWith('/')) {
return true;
}
if (CHAT_RESOURCE_INTERNAL_SEGMENTS.has(normalizedLastSegment)) {
return true;
}
if (!hasVisibleFileExtension(lastSegment)) {
return true;
}
if (CHAT_RESOURCE_INTERNAL_SEGMENTS.has(nextSegment) && !hasVisibleFileExtension(lastSegment)) {
return true;
}
if (normalizedLastSegment.startsWith('.')) {
return true;
}
if (CHAT_RESOURCE_HIDDEN_FILE_NAMES.has(normalizedLastSegment)) {
return true;
}
if (resourceSegmentIndex >= 0 && nextSegment === 'src' && !isMarkdownResourceFile(lastSegment)) {
return true;
}
if (!hasSupportedPreviewFileExtension(lastSegment)) {
return true;
}
return false;
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
@@ -118,15 +225,23 @@ export function extractPreviewItems(messages: ChatMessage[]) {
)
.map((part) => part.url);
const matches = [
...extractAutoDetectedPreviewUrls(message.text),
...extractHiddenPreviewUrls(message.text),
...structuredLinkUrls,
...extractAutoDetectedPreviewUrls(message.text),
];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);
const kind = classifyPreviewKind(normalizedUrl);
if (shouldHideInternalChatResource(normalizedUrl)) {
return;
}
if (!RESOURCE_STRIP_ALLOWED_KINDS.has(kind)) {
return;
}
if (seen.has(normalizedUrl)) {
return;
}

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -44,9 +44,11 @@ export type ChatViewContext = {
export type ChatConversationSummary = {
sessionId: string;
clientId: string | null;
isDraftOnly?: boolean;
title: string;
chatTypeId: string | null;
lastChatTypeId: string | null;
generalSectionName: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
@@ -56,7 +58,9 @@ export type ChatConversationSummary = {
currentJobMessage: string | null;
currentQueueSize: number;
currentStatusUpdatedAt: string | null;
lastRequestPreview: string;
lastMessagePreview: string;
lastResponsePreview: string;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;

View File

@@ -0,0 +1,495 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getChatConnectionSnapshot = getChatConnectionSnapshot;
exports.subscribeChatConnection = subscribeChatConnection;
exports.getSharedChatRuntimeSnapshot = getSharedChatRuntimeSnapshot;
exports.setSharedChatRuntimeSnapshot = setSharedChatRuntimeSnapshot;
exports.useChatConnection = useChatConnection;
var react_1 = require("react");
var chatUtils_1 = require("./chatUtils");
var tokenAccess_1 = require("../tokenAccess");
var DISCONNECT_UI_DELAY_MS = 1500;
var PRESENCE_PING_INTERVAL_MS = 20000;
var sharedChatConnection = {
connectionState: 'connecting',
connectionErrorDetail: '',
runtimeSnapshot: null,
socketRef: { current: null },
reconnectTimerId: null,
disconnectUiTimerId: null,
connectTimeoutId: null,
sessionId: '',
currentContext: null,
setMessages: null,
onMessageEvent: undefined,
onJobEvent: undefined,
onRuntimeEvent: undefined,
onRuntimeDetailEvent: undefined,
onActivityEvent: undefined,
lastEventId: 0,
websocketUrl: '',
subscribers: new Set(),
pingSubscriberCount: 0,
consumerCount: 0,
pingIntervalId: null,
visibilityHandlerInstalled: false,
pageShowHandlerInstalled: false,
focusHandlerInstalled: false,
onlineHandlerInstalled: false,
hasConnectedOnce: false,
suppressDisconnectNotification: false,
lastBackgroundAt: null,
};
function emitSharedState() {
sharedChatConnection.subscribers.forEach(function (listener) {
listener();
});
}
function getSnapshot() {
return {
connectionState: sharedChatConnection.connectionState,
connectionErrorDetail: sharedChatConnection.connectionErrorDetail,
runtimeSnapshot: sharedChatConnection.runtimeSnapshot,
};
}
function getChatConnectionSnapshot() {
return getSnapshot();
}
function subscribeChatConnection(listener) {
sharedChatConnection.subscribers.add(listener);
return function () {
sharedChatConnection.subscribers.delete(listener);
};
}
function getSharedChatRuntimeSnapshot() {
return sharedChatConnection.runtimeSnapshot;
}
function setSharedChatRuntimeSnapshot(snapshot) {
if (sharedChatConnection.runtimeSnapshot === snapshot) {
return;
}
sharedChatConnection.runtimeSnapshot = snapshot;
emitSharedState();
}
function setSharedConnectionState(nextState) {
if (sharedChatConnection.connectionState === nextState) {
return;
}
sharedChatConnection.connectionState = nextState;
emitSharedState();
}
function setSharedConnectionError(detail) {
if (sharedChatConnection.connectionErrorDetail === detail) {
return;
}
sharedChatConnection.connectionErrorDetail = detail;
emitSharedState();
}
function clearReconnectTimer() {
if (sharedChatConnection.reconnectTimerId !== null) {
window.clearTimeout(sharedChatConnection.reconnectTimerId);
sharedChatConnection.reconnectTimerId = null;
}
}
function clearDisconnectUiTimer() {
if (sharedChatConnection.disconnectUiTimerId !== null) {
window.clearTimeout(sharedChatConnection.disconnectUiTimerId);
sharedChatConnection.disconnectUiTimerId = null;
}
}
function clearConnectTimeout() {
if (sharedChatConnection.connectTimeoutId !== null) {
window.clearTimeout(sharedChatConnection.connectTimeoutId);
sharedChatConnection.connectTimeoutId = null;
}
}
function sendContextUpdate(context) {
if (context === void 0) { context = sharedChatConnection.currentContext; }
var socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !context) {
return;
}
var liveVisibilityState = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
var livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
socket.send(JSON.stringify({
type: 'context:update',
payload: {
pageId: context.pageId,
pageTitle: context.pageTitle,
topMenu: context.topMenu,
focusedComponentId: context.focusedComponentId,
pageUrl: livePageUrl,
isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: liveVisibilityState,
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
},
}));
}
function sendPresencePing() {
var socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'presence:ping',
payload: {
at: Date.now(),
},
}));
}
function ensureSharedSocket() {
var socket = sharedChatConnection.socketRef.current;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
connectSharedSocket();
}
function sendEventReceived(eventId) {
var socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !Number.isFinite(eventId) || eventId <= 0) {
return;
}
socket.send(JSON.stringify({
type: 'event:received',
payload: {
eventId: eventId,
},
}));
}
function stopPresenceMonitoring() {
if (sharedChatConnection.pingIntervalId !== null) {
window.clearInterval(sharedChatConnection.pingIntervalId);
sharedChatConnection.pingIntervalId = null;
}
if (sharedChatConnection.visibilityHandlerInstalled) {
window.removeEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = false;
}
if (sharedChatConnection.pageShowHandlerInstalled) {
window.removeEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = false;
}
if (sharedChatConnection.focusHandlerInstalled) {
window.removeEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = false;
}
if (sharedChatConnection.onlineHandlerInstalled) {
window.removeEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = false;
}
}
function handleVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
sharedChatConnection.lastBackgroundAt = Date.now();
sendContextUpdate(sharedChatConnection.currentContext);
sendPresencePing();
return;
}
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handlePageShow() {
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handleWindowFocus() {
ensureSharedSocket();
sendPresencePing();
}
function handleWindowOnline() {
ensureSharedSocket();
}
function startPresenceMonitoring() {
if (sharedChatConnection.pingSubscriberCount <= 0 || sharedChatConnection.connectionState !== 'connected') {
stopPresenceMonitoring();
return;
}
sharedChatConnection.lastBackgroundAt = null;
sendPresencePing();
if (sharedChatConnection.pingIntervalId === null) {
sharedChatConnection.pingIntervalId = window.setInterval(function () {
sendPresencePing();
}, PRESENCE_PING_INTERVAL_MS);
}
if (!sharedChatConnection.visibilityHandlerInstalled) {
window.addEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = true;
}
if (!sharedChatConnection.pageShowHandlerInstalled) {
window.addEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = true;
}
if (!sharedChatConnection.focusHandlerInstalled) {
window.addEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = true;
}
if (!sharedChatConnection.onlineHandlerInstalled) {
window.addEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = true;
}
}
function scheduleReconnect() {
if (sharedChatConnection.reconnectTimerId !== null || !sharedChatConnection.sessionId) {
return;
}
sharedChatConnection.reconnectTimerId = window.setTimeout(function () {
sharedChatConnection.reconnectTimerId = null;
connectSharedSocket();
}, chatUtils_1.CHAT_CONNECTION.reconnectDelayMs);
}
function handleSharedDisconnect(message, detail) {
setSharedConnectionError(detail !== null && detail !== void 0 ? detail : '');
clearDisconnectUiTimer();
if (sharedChatConnection.connectionState !== 'connected') {
setSharedConnectionState('disconnected');
}
else {
sharedChatConnection.disconnectUiTimerId = window.setTimeout(function () {
var _a;
sharedChatConnection.disconnectUiTimerId = null;
if (((_a = sharedChatConnection.socketRef.current) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
return;
}
setSharedConnectionState('disconnected');
}, DISCONNECT_UI_DELAY_MS);
}
if (message) {
scheduleReconnect();
}
}
function disconnectSharedSocket() {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
var socket = sharedChatConnection.socketRef.current;
sharedChatConnection.suppressDisconnectNotification = true;
sharedChatConnection.socketRef.current = null;
socket === null || socket === void 0 ? void 0 : socket.close();
}
function releaseSharedConnectionConsumer() {
sharedChatConnection.consumerCount = Math.max(0, sharedChatConnection.consumerCount - 1);
if (sharedChatConnection.consumerCount > 0) {
return;
}
sharedChatConnection.currentContext = null;
sharedChatConnection.setMessages = null;
sharedChatConnection.onMessageEvent = undefined;
sharedChatConnection.onJobEvent = undefined;
sharedChatConnection.onRuntimeEvent = undefined;
sharedChatConnection.onRuntimeDetailEvent = undefined;
setSharedChatRuntimeSnapshot(null);
disconnectSharedSocket();
setSharedConnectionError('');
setSharedConnectionState('disconnected');
}
function connectSharedSocket() {
if (!sharedChatConnection.sessionId || !sharedChatConnection.setMessages) {
return;
}
if (!(0, tokenAccess_1.hasRegisteredAccessTokenAccess)()) {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.');
setSharedConnectionState('disconnected');
return;
}
var currentSocket = sharedChatConnection.socketRef.current;
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
return;
}
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
if (sharedChatConnection.connectionState !== 'connected') {
setSharedConnectionState('connecting');
}
sharedChatConnection.websocketUrl = (0, chatUtils_1.resolveChatWebSocketUrl)(sharedChatConnection.sessionId, sharedChatConnection.lastEventId);
var socket;
try {
socket = new WebSocket(sharedChatConnection.websocketUrl);
}
catch (_a) {
handleSharedDisconnect("\uC6CC\uD06C\uC11C\uBC84 WebSocket \uC8FC\uC18C\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uB300\uC0C1: ".concat(sharedChatConnection.websocketUrl || '/ws/chat', " \uC790\uB3D9\uC73C\uB85C \uB2E4\uC2DC \uC5F0\uACB0\uD569\uB2C8\uB2E4."), 'WebSocket 객체를 생성하지 못했습니다. 대상 주소 형식과 환경변수를 확인해 주세요.');
return;
}
sharedChatConnection.socketRef.current = socket;
sharedChatConnection.suppressDisconnectNotification = false;
var disconnectHandled = false;
var reportDisconnect = function (message, closeEvent) {
if (disconnectHandled) {
return;
}
disconnectHandled = true;
var wasSuppressed = sharedChatConnection.suppressDisconnectNotification;
if (sharedChatConnection.socketRef.current === socket) {
sharedChatConnection.socketRef.current = null;
}
sharedChatConnection.suppressDisconnectNotification = false;
if (wasSuppressed) {
setSharedConnectionError('');
return;
}
if ((closeEvent === null || closeEvent === void 0 ? void 0 : closeEvent.code) === 1008) {
clearReconnectTimer();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.');
setSharedConnectionState('disconnected');
return;
}
if ((closeEvent === null || closeEvent === void 0 ? void 0 : closeEvent.code) === 1000 && !message) {
setSharedConnectionError('');
handleSharedDisconnect();
scheduleReconnect();
return;
}
void (0, chatUtils_1.diagnoseConnectionFailure)(sharedChatConnection.websocketUrl, closeEvent).then(function (detail) {
handleSharedDisconnect(message, detail);
});
};
sharedChatConnection.connectTimeoutId = window.setTimeout(function () {
if (sharedChatConnection.socketRef.current !== socket || socket.readyState === WebSocket.OPEN) {
return;
}
sharedChatConnection.socketRef.current = null;
socket.close();
reportDisconnect("\uC6CC\uD06C\uC11C\uBC84 \uC5F0\uACB0 \uC2DC\uAC04\uC774 \uCD08\uACFC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uB300\uC0C1: ".concat(sharedChatConnection.websocketUrl || '/ws/chat', " \uC790\uB3D9\uC73C\uB85C \uB2E4\uC2DC \uC5F0\uACB0\uD569\uB2C8\uB2E4."));
}, chatUtils_1.CHAT_CONNECTION.connectTimeoutMs);
socket.addEventListener('open', function () {
clearConnectTimeout();
clearDisconnectUiTimer();
sharedChatConnection.hasConnectedOnce = true;
sharedChatConnection.suppressDisconnectNotification = false;
setSharedConnectionState('connected');
setSharedConnectionError('');
sendContextUpdate(sharedChatConnection.currentContext);
startPresenceMonitoring();
});
socket.addEventListener('message', function (event) {
var _a, _b;
var setMessages = sharedChatConnection.setMessages;
if (!setMessages) {
return;
}
void (0, chatUtils_1.handleChatServerEvent)({
eventData: String(event.data),
currentPageUrl: (_b = (_a = sharedChatConnection.currentContext) === null || _a === void 0 ? void 0 : _a.pageUrl) !== null && _b !== void 0 ? _b : '',
expectedSessionId: sharedChatConnection.sessionId,
setMessages: setMessages,
onMessageEvent: sharedChatConnection.onMessageEvent,
onJobEvent: sharedChatConnection.onJobEvent,
onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
onActivityEvent: sharedChatConnection.onActivityEvent,
onEventReceived: function (eventId) {
sharedChatConnection.lastEventId = eventId;
(0, chatUtils_1.persistLastReceivedChatEventId)(sharedChatConnection.sessionId, eventId);
sendEventReceived(eventId);
},
});
try {
var parsedEvent = JSON.parse(String(event.data));
if ((parsedEvent === null || parsedEvent === void 0 ? void 0 : parsedEvent.type) === 'chat:runtime') {
setSharedChatRuntimeSnapshot(parsedEvent.payload);
}
}
catch (_c) {
// ignore malformed payloads here; detailed parsing is already handled downstream
}
});
socket.addEventListener('close', function (event) {
clearConnectTimeout();
stopPresenceMonitoring();
reportDisconnect(event.code === 1000 ? undefined : '워크서버 연결이 끊어졌습니다. 자동으로 다시 연결합니다.', event);
});
socket.addEventListener('error', function () {
clearConnectTimeout();
stopPresenceMonitoring();
reportDisconnect('워크서버 WebSocket 연결에 실패했습니다. 자동으로 다시 연결합니다.');
});
}
function ensureSharedConnection(options) {
var sessionChanged = sharedChatConnection.sessionId !== options.sessionId;
sharedChatConnection.currentContext = options.currentContext;
sharedChatConnection.setMessages = options.setMessages;
sharedChatConnection.onMessageEvent = options.onMessageEvent;
sharedChatConnection.onJobEvent = options.onJobEvent;
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = options.onActivityEvent;
if (sessionChanged) {
sharedChatConnection.sessionId = options.sessionId;
sharedChatConnection.lastEventId = (0, chatUtils_1.getLastReceivedChatEventId)(options.sessionId);
sharedChatConnection.hasConnectedOnce = false;
disconnectSharedSocket();
}
connectSharedSocket();
}
function useChatConnection(_a) {
var sessionId = _a.sessionId, currentContext = _a.currentContext, setMessages = _a.setMessages, onMessageEvent = _a.onMessageEvent, onJobEvent = _a.onJobEvent, onRuntimeEvent = _a.onRuntimeEvent, onRuntimeDetailEvent = _a.onRuntimeDetailEvent, onActivityEvent = _a.onActivityEvent;
var _b = (0, react_1.useState)(function () { return getSnapshot(); }), snapshot = _b[0], setSnapshot = _b[1];
(0, react_1.useEffect)(function () {
sharedChatConnection.consumerCount += 1;
return function () {
releaseSharedConnectionConsumer();
};
}, []);
(0, react_1.useEffect)(function () {
var handleSnapshotChange = function () {
setSnapshot(getSnapshot());
};
var unsubscribe = subscribeChatConnection(handleSnapshotChange);
ensureSharedConnection({
sessionId: sessionId,
currentContext: currentContext,
setMessages: setMessages,
onMessageEvent: onMessageEvent,
onJobEvent: onJobEvent,
onRuntimeEvent: onRuntimeEvent,
onRuntimeDetailEvent: onRuntimeDetailEvent,
onActivityEvent: onActivityEvent,
});
handleSnapshotChange();
return function () {
unsubscribe();
};
}, [sessionId, setMessages]);
(0, react_1.useEffect)(function () {
sharedChatConnection.currentContext = currentContext;
sharedChatConnection.setMessages = setMessages;
sharedChatConnection.onMessageEvent = onMessageEvent;
sharedChatConnection.onJobEvent = onJobEvent;
sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = onActivityEvent;
sendContextUpdate(currentContext);
}, [
currentContext,
onMessageEvent,
onJobEvent,
onRuntimeEvent,
onRuntimeDetailEvent,
onActivityEvent,
setMessages,
]);
(0, react_1.useEffect)(function () {
sharedChatConnection.pingSubscriberCount += 1;
startPresenceMonitoring();
return function () {
sharedChatConnection.pingSubscriberCount = Math.max(0, sharedChatConnection.pingSubscriberCount - 1);
if (sharedChatConnection.pingSubscriberCount === 0) {
stopPresenceMonitoring();
}
};
}, []);
return {
connectionState: snapshot.connectionState,
connectionErrorDetail: snapshot.connectionErrorDetail,
socketRef: sharedChatConnection.socketRef,
};
}

View File

@@ -483,6 +483,8 @@ function connectSharedSocket() {
if (closeEvent?.code === 1000 && !message) {
setSharedConnectionError('');
handleSharedDisconnect();
scheduleReconnect();
return;
}

View File

@@ -0,0 +1,116 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.useErrorLogs = useErrorLogs;
var react_1 = require("react");
var errorLogApi_1 = require("../errorLogApi");
var errorLogUtils_1 = require("./errorLogUtils");
function useErrorLogs(_a) {
var _this = this;
var activeView = _a.activeView, hasAccess = _a.hasAccess;
var _b = (0, react_1.useState)([]), errorLogs = _b[0], setErrorLogs = _b[1];
var _c = (0, react_1.useState)(null), selectedErrorLogId = _c[0], setSelectedErrorLogId = _c[1];
var _d = (0, react_1.useState)(false), isLoadingErrorLogs = _d[0], setIsLoadingErrorLogs = _d[1];
var _e = (0, react_1.useState)(''), errorLogLoadError = _e[0], setErrorLogLoadError = _e[1];
var _f = (0, react_1.useState)(''), activeErrorResourceUrl = _f[0], setActiveErrorResourceUrl = _f[1];
var _g = (0, react_1.useState)(false), isErrorDetailExpanded = _g[0], setIsErrorDetailExpanded = _g[1];
var loadErrorLogs = function () { return __awaiter(_this, void 0, void 0, function () {
var items_1, error_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!hasAccess || isLoadingErrorLogs) {
return [2 /*return*/];
}
setIsLoadingErrorLogs(true);
setErrorLogLoadError('');
_a.label = 1;
case 1:
_a.trys.push([1, 3, 4, 5]);
return [4 /*yield*/, (0, errorLogApi_1.fetchErrorLogs)(50)];
case 2:
items_1 = _a.sent();
setErrorLogs(items_1);
setSelectedErrorLogId(function (current) { var _a, _b; return (_b = current !== null && current !== void 0 ? current : (_a = items_1[0]) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null; });
return [3 /*break*/, 5];
case 3:
error_1 = _a.sent();
setErrorLogLoadError(error_1 instanceof Error ? error_1.message : '에러 로그를 불러오지 못했습니다.');
return [3 /*break*/, 5];
case 4:
setIsLoadingErrorLogs(false);
return [7 /*endfinally*/];
case 5: return [2 /*return*/];
}
});
}); };
(0, react_1.useEffect)(function () {
if (activeView !== 'errors' || !hasAccess) {
return;
}
void loadErrorLogs();
}, [activeView, hasAccess]);
var selectedErrorLog = (0, react_1.useMemo)(function () { var _a, _b; return (_b = (_a = errorLogs.find(function (item) { return item.id === selectedErrorLogId; })) !== null && _a !== void 0 ? _a : errorLogs[0]) !== null && _b !== void 0 ? _b : null; }, [errorLogs, selectedErrorLogId]);
var selectedErrorLogReferenceSummary = (0, react_1.useMemo)(function () { return (selectedErrorLog ? (0, errorLogUtils_1.buildErrorReferenceSummary)(selectedErrorLog) : null); }, [selectedErrorLog]);
var activeErrorResource = (0, react_1.useMemo)(function () {
var _a, _b;
return (_a = selectedErrorLogReferenceSummary === null || selectedErrorLogReferenceSummary === void 0 ? void 0 : selectedErrorLogReferenceSummary.resources.find(function (resource) { return resource.url === activeErrorResourceUrl; })) !== null && _a !== void 0 ? _a : (0, errorLogUtils_1.getDefaultErrorResource)((_b = selectedErrorLogReferenceSummary === null || selectedErrorLogReferenceSummary === void 0 ? void 0 : selectedErrorLogReferenceSummary.resources) !== null && _b !== void 0 ? _b : []);
}, [activeErrorResourceUrl, selectedErrorLogReferenceSummary]);
var errorSourceSummary = (0, react_1.useMemo)(function () { return (0, errorLogUtils_1.buildErrorSourceSummary)(errorLogs); }, [errorLogs]);
(0, react_1.useEffect)(function () {
var _a, _b, _c;
var nextUrl = (_c = (_b = (0, errorLogUtils_1.getDefaultErrorResource)((_a = selectedErrorLogReferenceSummary === null || selectedErrorLogReferenceSummary === void 0 ? void 0 : selectedErrorLogReferenceSummary.resources) !== null && _a !== void 0 ? _a : [])) === null || _b === void 0 ? void 0 : _b.url) !== null && _c !== void 0 ? _c : '';
setActiveErrorResourceUrl(nextUrl);
}, [selectedErrorLog === null || selectedErrorLog === void 0 ? void 0 : selectedErrorLog.id, selectedErrorLogReferenceSummary]);
return {
errorLogs: errorLogs,
selectedErrorLog: selectedErrorLog,
selectedErrorLogId: selectedErrorLogId,
selectedErrorLogReferenceSummary: selectedErrorLogReferenceSummary,
activeErrorResource: activeErrorResource,
errorSourceSummary: errorSourceSummary,
isLoadingErrorLogs: isLoadingErrorLogs,
errorLogLoadError: errorLogLoadError,
activeErrorResourceUrl: activeErrorResourceUrl,
isErrorDetailExpanded: isErrorDetailExpanded,
setSelectedErrorLogId: setSelectedErrorLogId,
setActiveErrorResourceUrl: setActiveErrorResourceUrl,
setIsErrorDetailExpanded: setIsErrorDetailExpanded,
loadErrorLogs: loadErrorLogs,
};
}

View File

@@ -13,7 +13,7 @@ export type MainViewInitialNavigation = {
export function resolveInitialNavigation(): MainViewInitialNavigation {
if (typeof window === 'undefined') {
return {
activeTopMenu: 'plans',
activeTopMenu: 'chat',
selectedPlanMenu: 'all',
selectedPlayMenu: 'layout',
initialSelectedPlanId: null,
@@ -26,7 +26,7 @@ export function resolveInitialNavigation(): MainViewInitialNavigation {
const requestedTopMenu: TopMenuKey =
topMenuParam === 'docs' || topMenuParam === 'apis' || topMenuParam === 'plans' || topMenuParam === 'chat' || topMenuParam === 'play'
? topMenuParam
: 'plans';
: 'chat';
const planFilterParam = params.get('planFilter');
const selectedPlanMenu =
planFilterParam === 'release'
@@ -45,6 +45,10 @@ export function resolveInitialNavigation(): MainViewInitialNavigation {
selectedPlayMenu:
playSectionParam === 'layout'
? 'layout'
: playSectionParam === 'test'
? 'test'
: playSectionParam === 'cbt'
? 'cbt'
: playSectionParam === 'layout-record' && playLayoutIdParam
? resolveSavedLayoutMenuKey(playLayoutIdParam)
: 'layout',

View File

@@ -153,6 +153,18 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
},
{
id: 'page:chat:resources',
label: 'Codex Live / 리소스 관리',
group: 'Page',
keywords: ['codex live', 'resource', 'resources', 'file', 'files', '리소스', '파일', '파일 시스템'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('resources');
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:errors',
label: '앱로그 / 에러 로그',
@@ -178,6 +190,18 @@ export function buildMainViewSearchOptions({
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 기본 유형 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '기본 context', '채팅 관리'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage-defaults');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []),

View File

@@ -0,0 +1,83 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY = exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY = void 0;
exports.buildScopedPwaNotificationTargetId = buildScopedPwaNotificationTargetId;
exports.getSavedNotificationDeviceId = getSavedNotificationDeviceId;
exports.getSavedPwaNotificationToken = getSavedPwaNotificationToken;
exports.setSavedPwaNotificationToken = setSavedPwaNotificationToken;
exports.clearNotificationIdentity = clearNotificationIdentity;
exports.getAutomationNotificationPreferenceTarget = getAutomationNotificationPreferenceTarget;
var clientIdentity_1 = require("./clientIdentity");
exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id';
exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY = 'work-server.notification.pwa-token';
function buildScopedPwaNotificationTargetId(token, clientId) {
return [token.trim(), clientId.trim()].filter(Boolean).join('::client::');
}
function getSavedNotificationDeviceId() {
if (typeof window === 'undefined') {
return '';
}
var clientId = (0, clientIdentity_1.getOrCreateClientId)();
if (clientId) {
window.localStorage.setItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY, clientId);
return clientId;
}
var saved = window.localStorage.getItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY);
if (saved) {
return saved;
}
var generated = "web-".concat(Date.now());
window.localStorage.setItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY, generated);
return generated;
}
function getSavedPwaNotificationToken() {
var _a, _b;
if (typeof window === 'undefined') {
return '';
}
return (_b = (_a = window.localStorage.getItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY)) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
}
function setSavedPwaNotificationToken(token) {
var _a;
if (typeof window === 'undefined') {
return;
}
var normalizedToken = (_a = token === null || token === void 0 ? void 0 : token.trim()) !== null && _a !== void 0 ? _a : '';
if (normalizedToken) {
window.localStorage.setItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY, normalizedToken);
}
else {
window.localStorage.removeItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
}
}
function clearNotificationIdentity() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY);
window.localStorage.removeItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
(0, clientIdentity_1.clearClientId)();
}
function getAutomationNotificationPreferenceTarget() {
var pwaToken = getSavedPwaNotificationToken();
var clientId = getSavedNotificationDeviceId();
if (pwaToken && clientId) {
return {
targetKind: 'ios-token-client',
targetId: buildScopedPwaNotificationTargetId(pwaToken, clientId),
};
}
if (pwaToken) {
return {
targetKind: 'ios-token',
targetId: pwaToken,
};
}
if (!clientId) {
return null;
}
return {
targetKind: 'client',
targetId: clientId,
};
}

View File

@@ -1,3 +1,5 @@
import { ChatDefaultContextManagementPage } from '../ChatDefaultContextManagementPage';
import { ResourceManagementPage } from '../ResourceManagementPage';
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
import { MainChatPanel } from '../MainChatPanel';
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
@@ -10,6 +12,10 @@ export function ChatPage() {
<div className="app-main-panel">
{selectedChatMenu === 'manage' ? (
<ChatTypeManagementPage />
) : selectedChatMenu === 'manage-defaults' ? (
<ChatDefaultContextManagementPage />
) : selectedChatMenu === 'resources' ? (
<ResourceManagementPage />
) : selectedChatMenu === 'changes' ? (
<ChatSourceChangesPage />
) : (

View File

@@ -9,20 +9,22 @@ export function DocsPage() {
const { selectedDocsMenu, selectedDocs } = useMainLayoutContext();
return (
<div className="app-main-panel">
<div className="app-main-panel docs-page">
<Card
title={`Docs / ${getDocsSectionLabel(selectedDocsMenu)}`}
extra={<Text code>{selectedDocs.length} docs</Text>}
className="app-main-card"
className="app-main-card docs-page__card"
bordered={false}
>
<Space direction="vertical" size={16} className="app-main-stack">
{selectedDocs.map((document) => (
<div key={document.id} id={`document-preview-${document.id}`} data-focus-id={`doc:${document.id}`}>
<MarkdownPreviewCard document={document} />
</div>
))}
</Space>
<div className="docs-page__scroll">
<Space direction="vertical" size={16} className="app-main-stack docs-page__stack">
{selectedDocs.map((document) => (
<div key={document.id} id={`document-preview-${document.id}`} data-focus-id={`doc:${document.id}`}>
<MarkdownPreviewCard document={document} />
</div>
))}
</Space>
</div>
</Card>
</div>
);

View File

@@ -1,4 +1,7 @@
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
import { CbtPlayAppView } from '../../../views/play/apps/cbt/CbtPlayAppView';
import { TestPlayAppView } from '../../../views/play/apps/test/TestPlayAppView';
import { FeatureMenuLayoutPage } from '../../../features/layout/feature-menu';
import { MemoLayoutPage } from '../../../features/layout/memo';
import { useMainLayoutContext } from '../layout/MainLayoutContext';
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
@@ -10,13 +13,23 @@ export function PlayPage() {
? savedLayouts.find((layout) => layout.id === selectedSavedLayoutId) ?? null
: null;
const isMemoLayout = selectedSavedLayout?.name === '메모';
const isFeatureMenuLayout = selectedSavedLayout?.name === '기능설명 관리';
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
return (
<div className={panelClassName}>
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedPlayMenu === 'test' ? <TestPlayAppView /> : null}
{selectedPlayMenu === 'cbt' ? <CbtPlayAppView /> : null}
{selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
{selectedSavedLayoutId && !isMemoLayout ? (
{selectedSavedLayoutId && isFeatureMenuLayout ? (
<FeatureMenuLayoutPage
layoutId={selectedSavedLayoutId}
savedLayouts={savedLayouts}
onSavedLayoutsChange={setSavedLayouts}
/>
) : null}
{selectedSavedLayoutId && !isMemoLayout && !isFeatureMenuLayout ? (
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
) : null}
</div>

View File

@@ -0,0 +1,277 @@
import { appendClientIdHeader } from './clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess';
export type ResourceManagerEntryType = 'file' | 'directory';
export type ResourceManagerTreeNode = {
name: string;
path: string;
type: ResourceManagerEntryType;
extension: string | null;
size: number | null;
modifiedAt: string;
previewUrl: string | null;
children?: ResourceManagerTreeNode[];
};
export type ResourceManagerTreeRoot = {
label: string;
rootPath: string;
tree: ResourceManagerTreeNode;
};
export type ResourceManagerDirectoryEntry = {
name: string;
path: string;
type: ResourceManagerEntryType;
extension: string | null;
size: number | null;
modifiedAt: string;
previewUrl: string | null;
};
export type ResourceManagerDirectoryDetail = {
path: string;
items: ResourceManagerDirectoryEntry[];
};
export type ResourceManagerFileDetail = {
name: string;
path: string;
extension: string | null;
size: number;
modifiedAt: string;
mimeType: string;
previewUrl: string;
isTextEditable: boolean;
content: string | null;
};
class ResourceManagerApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ResourceManagerApiError';
this.status = status;
}
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalHost) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const RESOURCE_MANAGER_API_BASE_URL = resolveApiBaseUrl();
const RESOURCE_MANAGER_API_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && RESOURCE_MANAGER_API_BASE_URL === '/api'
? resolveFallbackBaseUrl()
: null;
function appendPreviewAccessToken(previewUrl: string | null) {
if (!previewUrl) {
return null;
}
const token = getRegisteredAccessToken();
if (!token) {
return previewUrl;
}
const separator = previewUrl.includes('?') ? '&' : '?';
return `${previewUrl}${separator}token=${encodeURIComponent(token)}`;
}
function normalizeTreeNode(node: ResourceManagerTreeNode): ResourceManagerTreeNode {
return {
...node,
previewUrl: appendPreviewAccessToken(node.previewUrl),
children: node.children?.map((child) => normalizeTreeNode(child)),
};
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
const token = getRegisteredAccessToken();
if (!isAllowedRegistrationToken(token)) {
throw new ResourceManagerApiError('권한 토큰 등록 후에만 리소스 관리를 사용할 수 있습니다.', 403);
}
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new ResourceManagerApiError(payload.message || '리소스 관리 요청에 실패했습니다.', response.status);
} catch {
throw new ResourceManagerApiError(text || '리소스 관리 요청에 실패했습니다.', response.status);
}
}
return response.json() as Promise<T>;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await requestOnce<T>(RESOURCE_MANAGER_API_BASE_URL, path, init);
} catch (error) {
const shouldRetryWithFallback =
RESOURCE_MANAGER_API_FALLBACK_BASE_URL &&
RESOURCE_MANAGER_API_FALLBACK_BASE_URL !== RESOURCE_MANAGER_API_BASE_URL &&
(error instanceof ResourceManagerApiError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message));
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(RESOURCE_MANAGER_API_FALLBACK_BASE_URL, path, init);
}
}
export async function fetchResourceManagerTree() {
const response = await request<{ ok: boolean; item: ResourceManagerTreeRoot }>('/resource-manager/tree');
return {
...response.item,
tree: normalizeTreeNode(response.item.tree),
};
}
export async function fetchResourceManagerDirectory(targetPath = '') {
const query = new URLSearchParams({ path: targetPath });
const response = await request<{ ok: boolean; item: ResourceManagerDirectoryDetail }>(
`/resource-manager/directory?${query.toString()}`,
);
return {
...response.item,
items: response.item.items.map((item) => ({
...item,
previewUrl: appendPreviewAccessToken(item.previewUrl),
})),
};
}
export async function fetchResourceManagerFile(targetPath: string) {
const query = new URLSearchParams({ path: targetPath });
const response = await request<{ ok: boolean; item: ResourceManagerFileDetail }>(`/resource-manager/file?${query.toString()}`);
return {
...response.item,
previewUrl: appendPreviewAccessToken(response.item.previewUrl) ?? response.item.previewUrl,
};
}
export async function createResourceManagerDirectory(parentPath: string, name: string) {
await request('/resource-manager/directories', {
method: 'POST',
body: JSON.stringify({ parentPath, name }),
});
}
export async function createResourceManagerFile(parentPath: string, name: string, content = '') {
await request('/resource-manager/files', {
method: 'POST',
body: JSON.stringify({ parentPath, name, content }),
});
}
export async function saveResourceManagerFile(targetPath: string, content: string) {
await request('/resource-manager/files/content', {
method: 'PUT',
body: JSON.stringify({ path: targetPath, content }),
});
}
async function readFileAsBase64(file: File) {
const buffer = await file.arrayBuffer();
let binary = '';
const bytes = new Uint8Array(buffer);
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
export async function uploadResourceManagerFile(parentPath: string, file: File) {
const contentBase64 = await readFileAsBase64(file);
await request('/resource-manager/files/upload', {
method: 'POST',
body: JSON.stringify({
parentPath,
fileName: file.name,
contentBase64,
}),
});
}
export async function copyResourceManagerItem(targetPath: string, targetDirectoryPath: string, nextName?: string) {
await request('/resource-manager/items/copy', {
method: 'POST',
body: JSON.stringify({
path: targetPath,
targetDirectoryPath,
nextName: nextName?.trim() || null,
}),
});
}
export async function moveResourceManagerItem(targetPath: string, targetDirectoryPath: string, nextName?: string) {
await request('/resource-manager/items/move', {
method: 'POST',
body: JSON.stringify({
path: targetPath,
targetDirectoryPath,
nextName: nextName?.trim() || null,
}),
});
}
export async function deleteResourceManagerItem(targetPath: string) {
const query = new URLSearchParams({ path: targetPath });
await request(`/resource-manager/items?${query.toString()}`, {
method: 'DELETE',
});
}

View File

@@ -18,8 +18,8 @@ export type PlanSectionKey =
| 'automation-type'
| 'automation-context'
| 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout';
export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults';
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
export const DOCS_DEFAULT_FOLDER = 'worklogs';
@@ -56,6 +56,8 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
export const PLAY_SIDEBAR_LABELS: Record<PlaySectionKey, string> = {
layout: 'Layout Editor',
test: 'Test App',
cbt: 'CBT',
};
export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
@@ -89,7 +91,7 @@ export function resolveSavedLayoutIdFromMenuKey(key: PlaySidebarKey) {
export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
if (selectedPlayMenu === 'layout') {
if (selectedPlayMenu === 'layout' || selectedPlayMenu === 'test' || selectedPlayMenu === 'cbt') {
return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
}
@@ -247,6 +249,7 @@ export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps
children: [
{ key: 'live', label: renderChatUnreadLabel('Codex Live', unreadCount) },
{ key: 'changes', label: '변경 이력' },
{ key: 'resources', label: '리소스 관리' },
],
},
{
@@ -261,7 +264,10 @@ export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps
key: 'chat-manage-group',
icon: <MessageOutlined />,
label: '채팅 관리',
children: [{ key: 'manage', label: '유형 권한 관리' }],
children: [
{ key: 'manage', label: '유형 권한 관리' },
{ key: 'manage-defaults', label: '기본 유형 관리' },
],
},
]
: []),
@@ -288,6 +294,18 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
: [{ key: 'saved-layout-empty', label: '저장된 레이아웃 없음', disabled: true }]),
],
},
{
key: 'play-apps-group',
label: 'Apps',
children: [
{ key: 'test', label: 'Test App' },
{
key: 'play-apps-general-group',
label: '일반',
children: [{ key: 'cbt', label: 'CBT' }],
},
],
},
],
},
];
@@ -298,7 +316,7 @@ export function resolvePlanOpenKeys() {
}
export function resolvePlayOpenKeys() {
return ['play-group', 'play-layout-group'];
return ['play-group', 'play-layout-group', 'play-apps-group', 'play-apps-general-group'];
}
export function resolvePlanQuickFilterMenu(filter: 'working' | 'release-pending-main' | 'automation-failed') {
@@ -364,16 +382,22 @@ export function resolveCurrentPageDescriptor(params: {
}
if (topMenu === 'chat') {
const title =
chatMenu === 'errors'
? '앱로그 / 에러 로그'
: chatMenu === 'changes'
? 'Codex Live / 변경 이력'
: chatMenu === 'resources'
? 'Codex Live / 리소스 관리'
: chatMenu === 'manage'
? '채팅 관리 / 유형 권한 관리'
: chatMenu === 'manage-defaults'
? '채팅 관리 / 기본 유형 관리'
: 'Codex Live / Codex Live';
return {
id: `app-log:${chatMenu}`,
title:
chatMenu === 'errors'
? '앱로그 / 에러 로그'
: chatMenu === 'changes'
? 'Codex Live / 변경 이력'
: chatMenu === 'manage'
? '채팅 관리 / 유형 권한 관리'
: 'Codex Live / Codex Live',
title,
topMenu,
section: chatMenu,
};
@@ -396,7 +420,7 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
return buildPlayPath('layout');
}
return buildPlansPath('all');
return buildChatPath('live');
}
export function createPageWindowId(topMenu: TopMenuKey, section: string) {

View File

@@ -0,0 +1,63 @@
"use strict";
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ALLOWED_REGISTRATION_TOKEN = exports.TOKEN_ACCESS_SYNC_EVENT = exports.TOKEN_ACCESS_STORAGE_KEY = void 0;
exports.isAllowedRegistrationToken = isAllowedRegistrationToken;
exports.getRegisteredAccessToken = getRegisteredAccessToken;
exports.hasRegisteredAccessTokenAccess = hasRegisteredAccessTokenAccess;
exports.setRegisteredAccessToken = setRegisteredAccessToken;
exports.useTokenAccess = useTokenAccess;
var react_1 = require("react");
exports.TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
exports.TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
exports.ALLOWED_REGISTRATION_TOKEN = ((_a = import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN) === null || _a === void 0 ? void 0 : _a.trim()) || 'usr_7f3a9c2d8e1b4a6f';
function normalizeToken(value) {
var _a;
return (_a = value === null || value === void 0 ? void 0 : value.trim()) !== null && _a !== void 0 ? _a : '';
}
function isAllowedRegistrationToken(token) {
return normalizeToken(token) === exports.ALLOWED_REGISTRATION_TOKEN;
}
function getRegisteredAccessToken() {
if (typeof window === 'undefined') {
return '';
}
return normalizeToken(window.localStorage.getItem(exports.TOKEN_ACCESS_STORAGE_KEY));
}
function hasRegisteredAccessTokenAccess() {
return isAllowedRegistrationToken(getRegisteredAccessToken());
}
function setRegisteredAccessToken(token) {
if (typeof window === 'undefined') {
return;
}
var normalizedToken = normalizeToken(token);
if (normalizedToken) {
window.localStorage.setItem(exports.TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
}
else {
window.localStorage.removeItem(exports.TOKEN_ACCESS_STORAGE_KEY);
}
window.dispatchEvent(new CustomEvent(exports.TOKEN_ACCESS_SYNC_EVENT));
}
function useTokenAccess() {
var _a = (0, react_1.useState)(function () { return getRegisteredAccessToken(); }), registeredToken = _a[0], setRegisteredToken = _a[1];
(0, react_1.useEffect)(function () {
if (typeof window === 'undefined') {
return undefined;
}
var syncRegisteredToken = function () {
setRegisteredToken(getRegisteredAccessToken());
};
window.addEventListener('storage', syncRegisteredToken);
window.addEventListener(exports.TOKEN_ACCESS_SYNC_EVENT, syncRegisteredToken);
return function () {
window.removeEventListener('storage', syncRegisteredToken);
window.removeEventListener(exports.TOKEN_ACCESS_SYNC_EVENT, syncRegisteredToken);
};
}, []);
return {
registeredToken: registeredToken,
hasAccess: isAllowedRegistrationToken(registeredToken),
};
}

View File

@@ -59,6 +59,7 @@ export type MainSidebarProps = {
export type MainContentProps = {
contentExpanded: boolean;
sidebarOverlayActive?: boolean;
onToggleContentExpanded: () => void;
children: ReactNode;
};

View File

@@ -0,0 +1,87 @@
const APP_VIEWPORT_HEIGHT_VAR = '--app-viewport-height';
const APP_VIEWPORT_WIDTH_VAR = '--app-viewport-width';
const APP_VISUAL_VIEWPORT_HEIGHT_VAR = '--app-visual-viewport-height';
const APP_VISUAL_VIEWPORT_WIDTH_VAR = '--app-visual-viewport-width';
function roundViewportSize(value: number) {
return Math.round(value * 100) / 100;
}
function getLayoutViewportSize() {
if (typeof window === 'undefined') {
return {
height: 0,
width: 0,
};
}
const documentElement = document.documentElement;
const fallbackHeight = documentElement?.clientHeight ?? 0;
const fallbackWidth = documentElement?.clientWidth ?? 0;
return {
height: roundViewportSize(Math.max(window.innerHeight || 0, fallbackHeight)),
width: roundViewportSize(Math.max(window.innerWidth || 0, fallbackWidth)),
};
}
function getVisualViewportSize() {
if (typeof window === 'undefined') {
return {
height: 0,
width: 0,
};
}
const viewport = window.visualViewport;
return {
height: roundViewportSize(viewport?.height ?? window.innerHeight ?? 0),
width: roundViewportSize(viewport?.width ?? window.innerWidth ?? 0),
};
}
export function applyViewportCssVars() {
if (typeof document === 'undefined') {
return;
}
const layoutViewport = getLayoutViewportSize();
const visualViewport = getVisualViewportSize();
const resolvedHeight = Math.max(layoutViewport.height, visualViewport.height);
document.documentElement.style.setProperty(APP_VIEWPORT_HEIGHT_VAR, `${resolvedHeight}px`);
document.documentElement.style.setProperty(APP_VIEWPORT_WIDTH_VAR, `${layoutViewport.width}px`);
document.documentElement.style.setProperty(APP_VISUAL_VIEWPORT_HEIGHT_VAR, `${visualViewport.height}px`);
document.documentElement.style.setProperty(APP_VISUAL_VIEWPORT_WIDTH_VAR, `${visualViewport.width}px`);
}
export function bindViewportCssVars() {
if (typeof window === 'undefined') {
return () => undefined;
}
let frameId = 0;
const viewport = window.visualViewport;
const sync = () => {
window.cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
applyViewportCssVars();
});
};
sync();
window.addEventListener('resize', sync);
window.addEventListener('orientationchange', sync);
viewport?.addEventListener('resize', sync);
viewport?.addEventListener('scroll', sync);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', sync);
window.removeEventListener('orientationchange', sync);
viewport?.removeEventListener('resize', sync);
viewport?.removeEventListener('scroll', sync);
};
}