chore: update live chat and work server changes
This commit is contained in:
@@ -1,16 +1,28 @@
|
|||||||
services:
|
services:
|
||||||
preview-app:
|
preview-app:
|
||||||
container_name: ai-code-app-preview
|
container_name: ai-code-app-preview
|
||||||
build:
|
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||||
context: .
|
user: "0:0"
|
||||||
dockerfile: Dockerfile.preview
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "${PREVIEW_APP_PORT:-4173}:5173"
|
- "${PREVIEW_APP_PORT:-4173}:5173"
|
||||||
extra_hosts:
|
volumes:
|
||||||
- "host.docker.internal:host-gateway"
|
- ./:/app
|
||||||
|
- ./.docker/preview-app/node_modules:/app/node_modules
|
||||||
|
- ./.docker/preview-app/home:/home/how2ice
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- work-backend
|
||||||
environment:
|
environment:
|
||||||
|
HOME: /home/how2ice
|
||||||
|
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||||
PORT: 5173
|
PORT: 5173
|
||||||
APP_DIST_DIR: /tmp/ai-code-test-app-dist
|
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100}
|
||||||
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://host.docker.internal:3100}
|
|
||||||
VITE_DISABLE_APP_UPDATE: "true"
|
VITE_DISABLE_APP_UPDATE: "true"
|
||||||
|
command: >
|
||||||
|
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173 --strictPort"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
work-backend:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|||||||
cd "$MAIN_PROJECT_ROOT"
|
cd "$MAIN_PROJECT_ROOT"
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if command -v docker >/dev/null 2>&1; then
|
||||||
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then
|
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ npm run server-command:runner
|
|||||||
|
|
||||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
|
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
|
||||||
|
|
||||||
`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형과 현재 화면 문맥을 기준으로 동작하고, 자동화 유형 context를 기본 문맥으로 섞지 않습니다.
|
`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥은 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
|
||||||
|
|
||||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,22 @@ function canViewAllConversations(request: { headers: Record<string, unknown> })
|
|||||||
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
|
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyChatApiNoStoreHeaders(reply: FastifyReply) {
|
||||||
|
reply.header('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate');
|
||||||
|
reply.header('Pragma', 'no-cache');
|
||||||
|
reply.header('Expires', '0');
|
||||||
|
reply.header('Surrogate-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerChatRoutes(app: FastifyInstance) {
|
export async function registerChatRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('onSend', async (request, reply, payload) => {
|
||||||
|
if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) {
|
||||||
|
applyChatApiNoStoreHeaders(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
|
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
|
||||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||||
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const DEFAULT_CHAT_APP_CONFIG = {
|
|||||||
maxContextMessages: 12,
|
maxContextMessages: 12,
|
||||||
maxContextChars: 3200,
|
maxContextChars: 3200,
|
||||||
codexLiveMaxExecutionSeconds: 600,
|
codexLiveMaxExecutionSeconds: 600,
|
||||||
|
codexLiveIdleTimeoutSeconds: 180,
|
||||||
|
receiveRoomNotifications: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ChatPermissionRole = 'guest' | 'token-user';
|
type ChatPermissionRole = 'guest' | 'token-user';
|
||||||
@@ -24,7 +26,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
|||||||
id: 'general-request',
|
id: 'general-request',
|
||||||
name: '일반 요청',
|
name: '일반 요청',
|
||||||
description:
|
description:
|
||||||
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\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에서는 별도 요청이 없는 한 참조하지 마세요.',
|
'## 기본 처리\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'],
|
permissions: ['token-user'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||||
@@ -234,6 +236,8 @@ export type AppConfigSnapshot = {
|
|||||||
maxContextMessages?: number;
|
maxContextMessages?: number;
|
||||||
maxContextChars?: number;
|
maxContextChars?: number;
|
||||||
codexLiveMaxExecutionSeconds?: number;
|
codexLiveMaxExecutionSeconds?: number;
|
||||||
|
codexLiveIdleTimeoutSeconds?: number;
|
||||||
|
receiveRoomNotifications?: boolean;
|
||||||
};
|
};
|
||||||
automation?: {
|
automation?: {
|
||||||
autoRefreshEnabled?: boolean;
|
autoRefreshEnabled?: boolean;
|
||||||
@@ -290,6 +294,16 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
|
|||||||
60,
|
60,
|
||||||
7200,
|
7200,
|
||||||
),
|
),
|
||||||
|
codexLiveIdleTimeoutSeconds: normalizeIntegerInRange(
|
||||||
|
chat.codexLiveIdleTimeoutSeconds,
|
||||||
|
DEFAULT_CHAT_APP_CONFIG.codexLiveIdleTimeoutSeconds,
|
||||||
|
30,
|
||||||
|
3600,
|
||||||
|
),
|
||||||
|
receiveRoomNotifications:
|
||||||
|
typeof chat.receiveRoomNotifications === 'boolean'
|
||||||
|
? chat.receiveRoomNotifications
|
||||||
|
: DEFAULT_CHAT_APP_CONFIG.receiveRoomNotifications,
|
||||||
},
|
},
|
||||||
worklogAutomation:
|
worklogAutomation:
|
||||||
Object.keys(worklogAutomation).length > 0
|
Object.keys(worklogAutomation).length > 0
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildChatConversationRequestPatchFromMessage,
|
buildChatConversationRequestPatchFromMessage,
|
||||||
isVisibleConversationMessage,
|
isVisibleConversationMessage,
|
||||||
mergeChatConversationRequestStatus,
|
mergeChatConversationRequestStatus,
|
||||||
|
resolveNextConversationContextValue,
|
||||||
resolveNextConversationChatTypeId,
|
resolveNextConversationChatTypeId,
|
||||||
shouldClearConversationJobState,
|
shouldClearConversationJobState,
|
||||||
selectChatConversationResponseCandidate,
|
selectChatConversationResponseCandidate,
|
||||||
@@ -27,6 +28,12 @@ test('resolveNextConversationChatTypeId falls back to the stored chat type when
|
|||||||
assert.equal(resolveNextConversationChatTypeId(null, null), null);
|
assert.equal(resolveNextConversationChatTypeId(null, null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveNextConversationContextValue prefers the requested chat type context', () => {
|
||||||
|
assert.equal(resolveNextConversationContextValue('old context', 'new context'), 'new context');
|
||||||
|
assert.equal(resolveNextConversationContextValue('old context', ' '), 'old context');
|
||||||
|
assert.equal(resolveNextConversationContextValue(null, 'new context'), 'new context');
|
||||||
|
});
|
||||||
|
|
||||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildChatConversationRequestPatchFromMessage({
|
buildChatConversationRequestPatchFromMessage({
|
||||||
|
|||||||
@@ -1083,12 +1083,8 @@ export async function updateChatConversationContext(
|
|||||||
client_id: normalizedClientId || current.client_id || null,
|
client_id: normalizedClientId || current.client_id || null,
|
||||||
chat_type_id: nextChatTypeId,
|
chat_type_id: nextChatTypeId,
|
||||||
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
|
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
|
||||||
context_label:
|
context_label: resolveNextConversationContextValue(current.context_label, requestedContextLabel),
|
||||||
currentChatTypeId != null ? current.context_label || null : requestedContextLabel || current.context_label || null,
|
context_description: resolveNextConversationContextValue(current.context_description, requestedContextDescription),
|
||||||
context_description:
|
|
||||||
currentChatTypeId != null
|
|
||||||
? current.context_description || null
|
|
||||||
: requestedContextDescription || current.context_description || null,
|
|
||||||
notify_offline:
|
notify_offline:
|
||||||
normalizedClientId == null && payload.notifyOffline != null
|
normalizedClientId == null && payload.notifyOffline != null
|
||||||
? payload.notifyOffline
|
? payload.notifyOffline
|
||||||
@@ -1109,6 +1105,12 @@ export function resolveNextConversationChatTypeId(currentChatTypeId?: string | n
|
|||||||
return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null;
|
return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveNextConversationContextValue(currentValue?: string | null, requestedValue?: string | null) {
|
||||||
|
const normalizedRequestedValue = String(requestedValue ?? '').trim() || null;
|
||||||
|
const normalizedCurrentValue = String(currentValue ?? '').trim() || null;
|
||||||
|
return normalizedRequestedValue ?? normalizedCurrentValue ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listChatConversations(
|
export async function listChatConversations(
|
||||||
clientId?: string | null,
|
clientId?: string | null,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { env } from '../config/env.js';
|
|||||||
import {
|
import {
|
||||||
collectOfflineNotificationClientIds,
|
collectOfflineNotificationClientIds,
|
||||||
createActivityLogMessage,
|
createActivityLogMessage,
|
||||||
|
buildAgenticCodexPrompt,
|
||||||
extractDiffCodeBlocks,
|
extractDiffCodeBlocks,
|
||||||
extractCodexStreamText,
|
extractCodexStreamText,
|
||||||
fitActivityLogLines,
|
fitActivityLogLines,
|
||||||
@@ -91,6 +92,33 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildAgenticCodexPrompt treats chat type context as required instructions', () => {
|
||||||
|
const prompt = buildAgenticCodexPrompt(
|
||||||
|
{
|
||||||
|
pageId: null,
|
||||||
|
pageTitle: 'Codex Live',
|
||||||
|
topMenu: 'chat',
|
||||||
|
focusedComponentId: null,
|
||||||
|
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||||
|
chatTypeLabel: '모바일 검증',
|
||||||
|
chatTypeDescription: '모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다.',
|
||||||
|
},
|
||||||
|
'화면 확인해줘',
|
||||||
|
'session-a',
|
||||||
|
{
|
||||||
|
recentHistoryLines: ['[user] 이전에는 비로그인 화면으로 봤어'],
|
||||||
|
omittedHistoryCount: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
|
||||||
|
assert.match(prompt, /상위 필수 지시/);
|
||||||
|
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
|
||||||
|
assert.match(prompt, /### 반드시 지킬 context 원문/);
|
||||||
|
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
||||||
|
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
|
||||||
|
});
|
||||||
|
|
||||||
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
|
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
|
||||||
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);
|
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ type ChatSessionState = {
|
|||||||
text: string;
|
text: string;
|
||||||
mode: 'queue' | 'direct';
|
mode: 'queue' | 'direct';
|
||||||
requestedAtMs: number;
|
requestedAtMs: number;
|
||||||
|
context: ChatContext | null;
|
||||||
}>;
|
}>;
|
||||||
activeRequestCount: number;
|
activeRequestCount: number;
|
||||||
pendingQueueReleaseEventId: number | null;
|
pendingQueueReleaseEventId: number | null;
|
||||||
@@ -894,6 +895,16 @@ function summarizeCodexOutput(output: string) {
|
|||||||
return lines.slice(-12).join('\n');
|
return lines.slice(-12).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ChatRuntimeExecutionError extends Error {
|
||||||
|
responseText: string;
|
||||||
|
|
||||||
|
constructor(message: string, responseText = '') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ChatRuntimeExecutionError';
|
||||||
|
this.responseText = responseText.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeCommand(command: string, limit = 180) {
|
function summarizeCommand(command: string, limit = 180) {
|
||||||
const normalized = String(command ?? '').replace(/\s+/g, ' ').trim();
|
const normalized = String(command ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
@@ -1429,7 +1440,41 @@ async function buildRecentChatPromptHistory(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAgenticCodexPrompt(
|
function cloneChatContext(context: ChatContext | null): ChatContext | null {
|
||||||
|
return context ? { ...context } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChatTypeInstructionBlock(context: ChatContext | null) {
|
||||||
|
const chatTypeLabel = context?.chatTypeLabel?.trim() || '';
|
||||||
|
const chatTypeDescription = context?.chatTypeDescription?.trim() || '';
|
||||||
|
const hasSpecificChatType = Boolean(chatTypeLabel && chatTypeLabel !== '일반 요청');
|
||||||
|
const hasContextDescription = Boolean(chatTypeDescription && chatTypeDescription !== '없음');
|
||||||
|
|
||||||
|
if (!hasSpecificChatType && !hasContextDescription) {
|
||||||
|
return [
|
||||||
|
'## 채팅 유형 context 필수 규칙',
|
||||||
|
'- 선택된 채팅 유형 context가 없습니다.',
|
||||||
|
'- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'## 채팅 유형 context 필수 규칙',
|
||||||
|
'- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.',
|
||||||
|
'- 사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.',
|
||||||
|
'- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.',
|
||||||
|
'- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.',
|
||||||
|
'- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.',
|
||||||
|
'',
|
||||||
|
'### 선택된 채팅 유형',
|
||||||
|
`- label: ${chatTypeLabel || '없음'}`,
|
||||||
|
'',
|
||||||
|
'### 반드시 지킬 context 원문',
|
||||||
|
chatTypeDescription || '선택된 채팅 유형 context 원문 없음',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgenticCodexPrompt(
|
||||||
context: ChatContext | null,
|
context: ChatContext | null,
|
||||||
input: string,
|
input: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1460,6 +1505,8 @@ function buildAgenticCodexPrompt(
|
|||||||
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
|
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
|
||||||
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
|
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
|
||||||
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
|
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
|
||||||
|
...buildChatTypeInstructionBlock(context),
|
||||||
|
'',
|
||||||
'응답 규칙:',
|
'응답 규칙:',
|
||||||
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
|
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
|
||||||
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
|
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
|
||||||
@@ -1468,21 +1515,13 @@ function buildAgenticCodexPrompt(
|
|||||||
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
|
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
|
||||||
'- 한국어로 간결하게 답하세요.',
|
'- 한국어로 간결하게 답하세요.',
|
||||||
'',
|
'',
|
||||||
'채팅 유형 문맥(우선 적용):',
|
|
||||||
context?.chatTypeLabel && context.chatTypeLabel.trim() !== '일반 요청'
|
|
||||||
? `- chatTypeLabel: ${context.chatTypeLabel}`
|
|
||||||
: '- chatTypeLabel: 없음',
|
|
||||||
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
|
|
||||||
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
|
|
||||||
'- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
|
||||||
'',
|
|
||||||
'참고 화면 정보:',
|
'참고 화면 정보:',
|
||||||
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
|
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
|
||||||
`- topMenu: ${context?.topMenu ?? '없음'}`,
|
`- topMenu: ${context?.topMenu ?? '없음'}`,
|
||||||
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
|
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
|
||||||
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
|
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
|
||||||
'',
|
'',
|
||||||
'최근 대화 문맥:',
|
'최근 대화 문맥(보조 참조):',
|
||||||
...(recentHistoryLines.length > 0
|
...(recentHistoryLines.length > 0
|
||||||
? [
|
? [
|
||||||
...recentHistoryLines.map((line) => `- ${line}`),
|
...recentHistoryLines.map((line) => `- ${line}`),
|
||||||
@@ -1652,6 +1691,11 @@ async function runAgenticCodexReply(
|
|||||||
Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds)
|
Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds)
|
||||||
? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds)))
|
? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds)))
|
||||||
: null;
|
: null;
|
||||||
|
const codexLiveIdleTimeoutSeconds =
|
||||||
|
typeof appConfig.chat?.codexLiveIdleTimeoutSeconds === 'number' &&
|
||||||
|
Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds)
|
||||||
|
? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds)))
|
||||||
|
: null;
|
||||||
const prompt = buildAgenticCodexPrompt(context, input, sessionId, {
|
const prompt = buildAgenticCodexPrompt(context, input, sessionId, {
|
||||||
recentHistoryLines: recentHistory.items,
|
recentHistoryLines: recentHistory.items,
|
||||||
omittedHistoryCount: recentHistory.omittedCount,
|
omittedHistoryCount: recentHistory.omittedCount,
|
||||||
@@ -1663,6 +1707,23 @@ async function runAgenticCodexReply(
|
|||||||
let lastProgressText = '';
|
let lastProgressText = '';
|
||||||
let completedAgentMessage = '';
|
let completedAgentMessage = '';
|
||||||
let hasIncrementalDelta = false;
|
let hasIncrementalDelta = false;
|
||||||
|
const finalizeReplyOutput = async () => {
|
||||||
|
const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail);
|
||||||
|
const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId);
|
||||||
|
|
||||||
|
if (!rewrittenOutput) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the CLI only produced a final completed event, avoid sending it as one big batch.
|
||||||
|
if (!hasIncrementalDelta && rewrittenOutput) {
|
||||||
|
await streamReplyChunks(rewrittenOutput, onProgress);
|
||||||
|
} else if (rewrittenOutput !== lastProgressText) {
|
||||||
|
onProgress?.(rewrittenOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewrittenOutput;
|
||||||
|
};
|
||||||
const throwIfCancelled = async () => {
|
const throwIfCancelled = async () => {
|
||||||
if (!isCancellationRequested?.()) {
|
if (!isCancellationRequested?.()) {
|
||||||
return;
|
return;
|
||||||
@@ -1680,177 +1741,222 @@ async function runAgenticCodexReply(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>(async (resolve, reject) => {
|
chatRuntimeService.appendLog(
|
||||||
const emitProgress = (nextText: string) => {
|
requestId,
|
||||||
const normalizedProgress = nextText.trim();
|
`실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`,
|
||||||
|
);
|
||||||
|
onActivity?.(
|
||||||
|
`# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!normalizedProgress || normalizedProgress === lastProgressText) {
|
try {
|
||||||
return;
|
await new Promise<void>(async (resolve, reject) => {
|
||||||
}
|
const emitProgress = (nextText: string) => {
|
||||||
|
const normalizedProgress = nextText.trim();
|
||||||
|
|
||||||
lastProgressText = normalizedProgress;
|
if (!normalizedProgress || normalizedProgress === lastProgressText) {
|
||||||
streamedOutput = normalizedProgress;
|
|
||||||
onProgress?.(normalizedProgress);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await throwIfCancelled();
|
|
||||||
const response = await requestCommandRunner('/api/codex-live/execute', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
requestId,
|
|
||||||
sessionId,
|
|
||||||
repoPath,
|
|
||||||
prompt,
|
|
||||||
resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
|
|
||||||
uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'),
|
|
||||||
maxExecutionSeconds: codexLiveMaxExecutionSeconds,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await throwIfCancelled();
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.');
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let remoteErrorMessage = '';
|
|
||||||
|
|
||||||
const handleRunnerLine = (line: string) => {
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = typeof parsed.type === 'string' ? parsed.type : '';
|
lastProgressText = normalizedProgress;
|
||||||
|
streamedOutput = normalizedProgress;
|
||||||
if (eventType === 'started') {
|
onProgress?.(normalizedProgress);
|
||||||
const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null;
|
|
||||||
chatRuntimeService.attachProcess(requestId, pid);
|
|
||||||
chatRuntimeService.appendLog(
|
|
||||||
requestId,
|
|
||||||
pid ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}` : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'activity') {
|
|
||||||
const activityLog = String(parsed.line ?? '').trim();
|
|
||||||
|
|
||||||
if (activityLog) {
|
|
||||||
chatRuntimeService.appendLog(requestId, activityLog);
|
|
||||||
onActivity?.(activityLog);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'delta') {
|
|
||||||
const deltaText = String(parsed.text ?? '');
|
|
||||||
|
|
||||||
if (deltaText) {
|
|
||||||
hasIncrementalDelta = true;
|
|
||||||
emitProgress(`${streamedOutput}${deltaText}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'completed') {
|
|
||||||
completedAgentMessage = String(parsed.text ?? '').trim();
|
|
||||||
if (completedAgentMessage && hasIncrementalDelta) {
|
|
||||||
emitProgress(completedAgentMessage);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'stdout') {
|
|
||||||
const stdoutLine = String(parsed.line ?? '').trim();
|
|
||||||
|
|
||||||
if (stdoutLine) {
|
|
||||||
stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT);
|
|
||||||
chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`);
|
|
||||||
onActivity?.(`[stdout] ${stdoutLine}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'stderr') {
|
|
||||||
const stderrLine = String(parsed.line ?? '').trim();
|
|
||||||
|
|
||||||
if (stderrLine) {
|
|
||||||
stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT);
|
|
||||||
chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`);
|
|
||||||
onActivity?.(`[stderr] ${stderrLine}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'error') {
|
|
||||||
remoteErrorMessage = String(parsed.message ?? '').trim();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
await throwIfCancelled();
|
await throwIfCancelled();
|
||||||
const { value, done } = await reader.read();
|
const response = await requestCommandRunner('/api/codex-live/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
repoPath,
|
||||||
|
prompt,
|
||||||
|
resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
|
||||||
|
uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'),
|
||||||
|
maxExecutionSeconds: codexLiveMaxExecutionSeconds,
|
||||||
|
idleTimeoutSeconds: codexLiveIdleTimeoutSeconds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (done) {
|
if (!response.ok) {
|
||||||
break;
|
reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonLineBuffer += decoder.decode(value, { stream: true });
|
await throwIfCancelled();
|
||||||
const lines = jsonLineBuffer.split('\n');
|
|
||||||
jsonLineBuffer = lines.pop() ?? '';
|
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
if (!response.body) {
|
||||||
const line = rawLine.trim();
|
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!line) {
|
chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.');
|
||||||
continue;
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let remoteErrorMessage = '';
|
||||||
|
|
||||||
|
const handleRunnerLine = (line: string) => {
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRunnerLine(line);
|
const eventType = typeof parsed.type === 'string' ? parsed.type : '';
|
||||||
|
|
||||||
|
if (eventType === 'started') {
|
||||||
|
const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null;
|
||||||
|
const appliedIdleTimeoutSeconds =
|
||||||
|
typeof parsed.configuredIdleTimeoutSeconds === 'number' &&
|
||||||
|
Number.isFinite(parsed.configuredIdleTimeoutSeconds)
|
||||||
|
? Math.round(parsed.configuredIdleTimeoutSeconds)
|
||||||
|
: null;
|
||||||
|
const appliedMaxExecutionSeconds =
|
||||||
|
typeof parsed.configuredMaxExecutionSeconds === 'number' &&
|
||||||
|
Number.isFinite(parsed.configuredMaxExecutionSeconds)
|
||||||
|
? Math.round(parsed.configuredMaxExecutionSeconds)
|
||||||
|
: null;
|
||||||
|
chatRuntimeService.attachProcess(requestId, pid);
|
||||||
|
chatRuntimeService.appendLog(
|
||||||
|
requestId,
|
||||||
|
pid
|
||||||
|
? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}`
|
||||||
|
: '호스트 command-runner에서 Codex 프로세스를 시작했습니다.',
|
||||||
|
);
|
||||||
|
if (appliedMaxExecutionSeconds != null || appliedIdleTimeoutSeconds != null) {
|
||||||
|
const appliedSummary =
|
||||||
|
`command-runner 적용값: 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` +
|
||||||
|
`무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`;
|
||||||
|
chatRuntimeService.appendLog(requestId, appliedSummary);
|
||||||
|
onActivity?.(`# ${appliedSummary}`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(appliedMaxExecutionSeconds != null &&
|
||||||
|
codexLiveMaxExecutionSeconds != null &&
|
||||||
|
appliedMaxExecutionSeconds !== codexLiveMaxExecutionSeconds) ||
|
||||||
|
(appliedIdleTimeoutSeconds != null &&
|
||||||
|
codexLiveIdleTimeoutSeconds != null &&
|
||||||
|
appliedIdleTimeoutSeconds !== codexLiveIdleTimeoutSeconds)
|
||||||
|
) {
|
||||||
|
const mismatchSummary =
|
||||||
|
`설정 불일치 감지: 요청값 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / ` +
|
||||||
|
`무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초, ` +
|
||||||
|
`실제 적용값 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` +
|
||||||
|
`무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`;
|
||||||
|
chatRuntimeService.appendLog(requestId, mismatchSummary);
|
||||||
|
onActivity?.(`# 경고: ${mismatchSummary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'activity') {
|
||||||
|
const activityLog = String(parsed.line ?? '').trim();
|
||||||
|
|
||||||
|
if (activityLog) {
|
||||||
|
chatRuntimeService.appendLog(requestId, activityLog);
|
||||||
|
onActivity?.(activityLog);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'delta') {
|
||||||
|
const deltaText = String(parsed.text ?? '');
|
||||||
|
|
||||||
|
if (deltaText) {
|
||||||
|
hasIncrementalDelta = true;
|
||||||
|
emitProgress(`${streamedOutput}${deltaText}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'completed') {
|
||||||
|
completedAgentMessage = String(parsed.text ?? '').trim();
|
||||||
|
if (completedAgentMessage && hasIncrementalDelta) {
|
||||||
|
emitProgress(completedAgentMessage);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'stdout') {
|
||||||
|
const stdoutLine = String(parsed.line ?? '').trim();
|
||||||
|
|
||||||
|
if (stdoutLine) {
|
||||||
|
stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT);
|
||||||
|
chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`);
|
||||||
|
onActivity?.(`[stdout] ${stdoutLine}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'stderr') {
|
||||||
|
const stderrLine = String(parsed.line ?? '').trim();
|
||||||
|
|
||||||
|
if (stderrLine) {
|
||||||
|
stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT);
|
||||||
|
chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`);
|
||||||
|
onActivity?.(`[stderr] ${stderrLine}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'error') {
|
||||||
|
remoteErrorMessage = String(parsed.message ?? '').trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
await throwIfCancelled();
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonLineBuffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = jsonLineBuffer.split('\n');
|
||||||
|
jsonLineBuffer = lines.pop() ?? '';
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRunnerLine(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const trailingLine = jsonLineBuffer.trim();
|
const trailingLine = jsonLineBuffer.trim();
|
||||||
if (trailingLine) {
|
if (trailingLine) {
|
||||||
handleRunnerLine(trailingLine);
|
handleRunnerLine(trailingLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remoteErrorMessage) {
|
if (remoteErrorMessage) {
|
||||||
reject(new Error(remoteErrorMessage));
|
reject(new Error(remoteErrorMessage));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const failureResponseText = await finalizeReplyOutput();
|
||||||
|
|
||||||
|
if (failureResponseText) {
|
||||||
|
throw new ChatRuntimeExecutionError(error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.', failureResponseText);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail);
|
throw error;
|
||||||
const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId);
|
|
||||||
|
|
||||||
// If the CLI only produced a final completed event, avoid sending it as one big batch.
|
|
||||||
if (!hasIncrementalDelta && rewrittenOutput) {
|
|
||||||
await streamReplyChunks(rewrittenOutput, onProgress);
|
|
||||||
} else if (rewrittenOutput !== lastProgressText) {
|
|
||||||
onProgress?.(rewrittenOutput);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rewrittenOutput;
|
return await finalizeReplyOutput();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTodayAutomationRegistrationCounts() {
|
async function getTodayAutomationRegistrationCounts() {
|
||||||
@@ -3066,6 +3172,15 @@ export class ChatService {
|
|||||||
}),
|
}),
|
||||||
...contextOverride,
|
...contextOverride,
|
||||||
};
|
};
|
||||||
|
void updateChatConversationContext(state.sessionId, {
|
||||||
|
clientId: state.clientId,
|
||||||
|
chatTypeId: state.context.chatTypeId ?? null,
|
||||||
|
lastChatTypeId: state.context.chatTypeId ?? null,
|
||||||
|
contextLabel: state.context.chatTypeLabel ?? null,
|
||||||
|
contextDescription: state.context.chatTypeDescription ?? null,
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
this.logger.error(error, 'failed to persist chat context from message send');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.normalizeSessionExecutionState(state);
|
this.normalizeSessionExecutionState(state);
|
||||||
@@ -3078,6 +3193,7 @@ export class ChatService {
|
|||||||
text: trimmed,
|
text: trimmed,
|
||||||
mode,
|
mode,
|
||||||
requestedAtMs,
|
requestedAtMs,
|
||||||
|
context: cloneChatContext(state.context),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
|
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
|
||||||
@@ -3129,6 +3245,7 @@ export class ChatService {
|
|||||||
text: string;
|
text: string;
|
||||||
mode: 'queue' | 'direct';
|
mode: 'queue' | 'direct';
|
||||||
requestedAtMs: number;
|
requestedAtMs: number;
|
||||||
|
context: ChatContext | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed';
|
let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed';
|
||||||
@@ -3258,7 +3375,7 @@ export class ChatService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const reply = await buildCodexReply(
|
const reply = await buildCodexReply(
|
||||||
session.context ?? null,
|
request.context ?? session.context ?? null,
|
||||||
request.text,
|
request.text,
|
||||||
session.sessionId,
|
session.sessionId,
|
||||||
request.requestId,
|
request.requestId,
|
||||||
@@ -3334,6 +3451,37 @@ export class ChatService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wasCancelled = this.cancelledRequestIds.has(request.requestId);
|
const wasCancelled = this.cancelledRequestIds.has(request.requestId);
|
||||||
terminalStatus = wasCancelled ? 'cancelled' : 'failed';
|
terminalStatus = wasCancelled ? 'cancelled' : 'failed';
|
||||||
|
const failureResponseText =
|
||||||
|
error instanceof ChatRuntimeExecutionError ? error.responseText : '';
|
||||||
|
|
||||||
|
if (failureResponseText) {
|
||||||
|
const failedCodexReplyMessage = {
|
||||||
|
...codexReplyMessage,
|
||||||
|
text: failureResponseText,
|
||||||
|
timestamp: resolveResponseTimestamp(request.requestedAtMs),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sendToSession(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
type: 'chat:message',
|
||||||
|
payload: failedCodexReplyMessage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skipOfflineNotification: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.persistConversationMessage(session, failedCodexReplyMessage);
|
||||||
|
await upsertChatConversationRequest(session.sessionId, {
|
||||||
|
requestId: request.requestId,
|
||||||
|
status: wasCancelled ? 'cancelled' : 'failed',
|
||||||
|
statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
|
||||||
|
responseMessageId: failedCodexReplyMessage.id,
|
||||||
|
responseText: failedCodexReplyMessage.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
chatRuntimeService.appendLog(
|
chatRuntimeService.appendLog(
|
||||||
request.requestId,
|
request.requestId,
|
||||||
wasCancelled
|
wasCancelled
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const apiBaseUrl = process.env.PLAN_API_BASE_URL ?? 'http://127.0.0.1:3100/api';
|
|||||||
const accessToken = process.env.PLAN_ACCESS_TOKEN?.trim() ?? '';
|
const accessToken = process.env.PLAN_ACCESS_TOKEN?.trim() ?? '';
|
||||||
const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null;
|
const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null;
|
||||||
const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex';
|
const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex';
|
||||||
|
const codexModel = process.env.PLAN_CODEX_MODEL?.trim() || process.env.SERVER_COMMAND_RUNNER_CODEX_MODEL?.trim() || 'gpt-5.4';
|
||||||
const localMainMode = process.env.PLAN_LOCAL_MAIN_MODE === 'true';
|
const localMainMode = process.env.PLAN_LOCAL_MAIN_MODE === 'true';
|
||||||
const skipWorkComplete = process.env.PLAN_SKIP_WORK_COMPLETE === 'true';
|
const skipWorkComplete = process.env.PLAN_SKIP_WORK_COMPLETE === 'true';
|
||||||
const gitUserName = process.env.PLAN_GIT_USER_NAME ?? 'how2ice';
|
const gitUserName = process.env.PLAN_GIT_USER_NAME ?? 'how2ice';
|
||||||
@@ -1124,6 +1125,8 @@ async function runCodexForPlan(item) {
|
|||||||
codexBin,
|
codexBin,
|
||||||
[
|
[
|
||||||
'exec',
|
'exec',
|
||||||
|
'--model',
|
||||||
|
codexModel,
|
||||||
'--dangerously-bypass-approvals-and-sandbox',
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
'-C',
|
'-C',
|
||||||
repoPath,
|
repoPath,
|
||||||
|
|||||||
@@ -31,12 +31,16 @@ const runnerLogTrimIntervalMs = Math.max(
|
|||||||
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
||||||
const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max(
|
const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max(
|
||||||
30_000,
|
30_000,
|
||||||
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '90000'),
|
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '180000'),
|
||||||
);
|
);
|
||||||
const CODEX_LIVE_MAX_EXECUTION_MS = Math.max(
|
const CODEX_LIVE_MAX_EXECUTION_MS = Math.max(
|
||||||
CODEX_LIVE_IDLE_TIMEOUT_MS,
|
CODEX_LIVE_IDLE_TIMEOUT_MS,
|
||||||
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`),
|
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`),
|
||||||
);
|
);
|
||||||
|
const CODEX_LIVE_MODEL =
|
||||||
|
process.env.SERVER_COMMAND_RUNNER_CODEX_MODEL?.trim() ||
|
||||||
|
process.env.PLAN_CODEX_MODEL?.trim() ||
|
||||||
|
'gpt-5.4';
|
||||||
const CODEX_HOME_RUNTIME_PATHS = [
|
const CODEX_HOME_RUNTIME_PATHS = [
|
||||||
'auth.json',
|
'auth.json',
|
||||||
'config.toml',
|
'config.toml',
|
||||||
@@ -206,6 +210,25 @@ function sendJsonLine(response, payload) {
|
|||||||
response.write(`${JSON.stringify(payload)}\n`);
|
response.write(`${JSON.stringify(payload)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNumericSeconds(value) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readRequestBody(request, maxBytes = 4 * 1024 * 1024) {
|
function readRequestBody(request, maxBytes = 4 * 1024 * 1024) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -505,7 +528,8 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
|
const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
|
||||||
const uploadDir = path.join(resourceDir, 'uploads');
|
const uploadDir = path.join(resourceDir, 'uploads');
|
||||||
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
|
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
|
||||||
const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds);
|
const configuredIdleTimeoutMs = resolveCodexLiveIdleTimeoutMs(payload?.idleTimeoutSeconds);
|
||||||
|
const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds, configuredIdleTimeoutMs);
|
||||||
|
|
||||||
if (!requestId || !sessionId || !prompt.trim()) {
|
if (!requestId || !sessionId || !prompt.trim()) {
|
||||||
sendJson(response, 400, {
|
sendJson(response, 400, {
|
||||||
@@ -536,7 +560,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
|
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
codexBin,
|
codexBin,
|
||||||
['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
|
['exec', '--model', CODEX_LIVE_MODEL, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
|
||||||
{
|
{
|
||||||
cwd: repoPath,
|
cwd: repoPath,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -557,6 +581,8 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
sendJsonLine(response, {
|
sendJsonLine(response, {
|
||||||
type: 'started',
|
type: 'started',
|
||||||
pid: child.pid ?? null,
|
pid: child.pid ?? null,
|
||||||
|
configuredIdleTimeoutSeconds: Math.round(configuredIdleTimeoutMs / 1000),
|
||||||
|
configuredMaxExecutionSeconds: Math.round(configuredMaxExecutionMs / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
@@ -612,9 +638,9 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
|
|
||||||
idleTimer = setTimeout(() => {
|
idleTimer = setTimeout(() => {
|
||||||
requestTermination(
|
requestTermination(
|
||||||
`Codex Live 실행이 ${Math.round(CODEX_LIVE_IDLE_TIMEOUT_MS / 1000)}초 동안 출력이 없어 중단되었습니다.`,
|
`Codex Live 실행이 ${Math.round(configuredIdleTimeoutMs / 1000)}초 동안 출력이 없어 중단되었습니다.`,
|
||||||
);
|
);
|
||||||
}, CODEX_LIVE_IDLE_TIMEOUT_MS);
|
}, configuredIdleTimeoutMs);
|
||||||
idleTimer.unref?.();
|
idleTimer.unref?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -759,12 +785,24 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
child.stdin?.end(prompt);
|
child.stdin?.end(prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCodexLiveMaxExecutionMs(value) {
|
function resolveCodexLiveMaxExecutionMs(value, minimumMs = CODEX_LIVE_IDLE_TIMEOUT_MS) {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
const normalizedValue = normalizeNumericSeconds(value);
|
||||||
return CODEX_LIVE_MAX_EXECUTION_MS;
|
|
||||||
|
if (normalizedValue == null) {
|
||||||
|
return Math.max(minimumMs, CODEX_LIVE_MAX_EXECUTION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(CODEX_LIVE_IDLE_TIMEOUT_MS, Math.min(7_200_000, Math.max(60_000, Math.round(value * 1000))));
|
return Math.max(minimumMs, Math.min(7_200_000, Math.max(60_000, Math.round(normalizedValue * 1000))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCodexLiveIdleTimeoutMs(value) {
|
||||||
|
const normalizedValue = normalizeNumericSeconds(value);
|
||||||
|
|
||||||
|
if (normalizedValue == null) {
|
||||||
|
return CODEX_LIVE_IDLE_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(3_600_000, Math.max(30_000, Math.round(normalizedValue * 1000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAuthorized(request) {
|
function isAuthorized(request) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApisPage } from './pages/ApisPage';
|
|||||||
import { ChatPage } from './pages/ChatPage';
|
import { ChatPage } from './pages/ChatPage';
|
||||||
import { DocsPage } from './pages/DocsPage';
|
import { DocsPage } from './pages/DocsPage';
|
||||||
import { PlansPage } from './pages/PlansPage';
|
import { PlansPage } from './pages/PlansPage';
|
||||||
|
import { PlayPage } from './pages/PlayPage';
|
||||||
import { buildDocsPath, buildPlansPath } from './routes';
|
import { buildDocsPath, buildPlansPath } from './routes';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -15,8 +16,8 @@ export function AppShell() {
|
|||||||
<Route path="apis/:section" element={<ApisPage />} />
|
<Route path="apis/:section" element={<ApisPage />} />
|
||||||
<Route path="plans/:section" element={<PlansPage />} />
|
<Route path="plans/:section" element={<PlansPage />} />
|
||||||
<Route path="chat/:section" element={<ChatPage />} />
|
<Route path="chat/:section" element={<ChatPage />} />
|
||||||
<Route path="play/layout" element={<Navigate to={buildPlansPath('all')} replace />} />
|
<Route path="play/layout" element={<PlayPage />} />
|
||||||
<Route path="play/layout-record/:layoutId" element={<Navigate to={buildPlansPath('all')} replace />} />
|
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />
|
||||||
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
|
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAppConfig } from './appConfig';
|
||||||
import {
|
import {
|
||||||
createNotificationMessage,
|
createNotificationMessage,
|
||||||
sendClientNotification,
|
sendClientNotification,
|
||||||
@@ -175,6 +176,7 @@ function selectNotificationPollingCandidates<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatNotificationBridgeV2() {
|
export function ChatNotificationBridgeV2() {
|
||||||
|
const appConfig = useAppConfig();
|
||||||
const notifiedFailedJobKeysRef = useRef<string[]>([]);
|
const notifiedFailedJobKeysRef = useRef<string[]>([]);
|
||||||
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
|
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
|
||||||
const lastFailedRequestKeyBySessionRef = useRef<Record<string, string>>({});
|
const lastFailedRequestKeyBySessionRef = useRef<Record<string, string>>({});
|
||||||
@@ -257,5 +259,9 @@ export function ChatNotificationBridgeV2() {
|
|||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!appConfig.chat.receiveRoomNotifications) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,47 @@
|
|||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-options {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-space {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(248, 250, 252, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-option .ant-radio-wrapper {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-option-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__create-conversation-option-description {
|
||||||
|
padding-left: 24px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__conversation-list-body {
|
.app-chat-panel__conversation-list-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -843,12 +884,15 @@
|
|||||||
|
|
||||||
.app-chat-panel__resource-strip-list {
|
.app-chat-panel__resource-strip-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 8px 12px 0;
|
padding: 8px 12px 0;
|
||||||
overflow-x: auto;
|
max-height: min(32vh, 240px);
|
||||||
overflow-y: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,6 +900,24 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__resource-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #0f172a;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__title-input {
|
.app-chat-panel__title-input {
|
||||||
width: min(240px, 48vw);
|
width: min(240px, 48vw);
|
||||||
}
|
}
|
||||||
@@ -1079,6 +1141,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1180px) {
|
||||||
|
.app-chat-panel .app-chat-panel__title-copy .ant-typography,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-header .ant-typography {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-section-title,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-section-count,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-time,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-id,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-status,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-flag,
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-unread-badge,
|
||||||
|
.app-chat-panel .app-chat-panel__history-loader,
|
||||||
|
.app-chat-panel .app-chat-panel__system-status .ant-typography,
|
||||||
|
.app-chat-panel .app-chat-message__header-meta .ant-typography,
|
||||||
|
.app-chat-panel .app-chat-message__status,
|
||||||
|
.app-chat-panel .app-chat-message__request-detail,
|
||||||
|
.app-chat-panel .app-chat-preview-card__kind,
|
||||||
|
.app-chat-panel .app-chat-preview-card__kind.ant-typography,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-queue-order,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-attachment-pending-label,
|
||||||
|
.app-chat-panel .app-chat-panel__resource-chip,
|
||||||
|
.app-chat-panel .app-chat-panel__resource-strip-filter,
|
||||||
|
.app-chat-panel .app-chat-panel__resource-strip-empty.ant-typography,
|
||||||
|
.app-chat-panel .app-chat-panel__busy-overlay span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-title,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-type .ant-select-selector,
|
||||||
|
.app-chat-panel .app-chat-panel__composer .ant-btn,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-actions .ant-typography,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-attachment-name,
|
||||||
|
.app-chat-panel .app-chat-preview-card__label,
|
||||||
|
.app-chat-panel .app-chat-preview-card__label.ant-typography {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel .app-chat-panel__conversation-item-preview,
|
||||||
|
.app-chat-panel .app-chat-message__header-meta,
|
||||||
|
.app-chat-panel .app-chat-message__header-meta strong,
|
||||||
|
.app-chat-panel .app-chat-message__header-meta > span,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-queue-text,
|
||||||
|
.app-chat-panel .app-chat-panel__composer-queue-more,
|
||||||
|
.app-chat-panel .app-chat-panel__preview-modal-close-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel .app-chat-message__body {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel .app-chat-panel__composer .ant-input-textarea textarea,
|
||||||
|
.app-chat-panel .app-chat-panel__composer textarea.ant-input {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel .app-chat-panel__composer-input-shell,
|
||||||
|
.app-chat-panel .app-chat-panel__composer textarea.ant-input {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__conversation-header {
|
.app-chat-panel__conversation-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1213,12 +1341,6 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__system-status--hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel__system-status-dots {
|
.app-chat-panel__system-status-dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1898,8 +2020,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer {
|
.app-chat-panel__composer {
|
||||||
gap: 8px;
|
display: flex;
|
||||||
padding: 10px 12px 12px;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px));
|
||||||
|
padding-left: 10px;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: rgba(248, 250, 252, 0.94);
|
background: rgba(248, 250, 252, 0.94);
|
||||||
@@ -1908,8 +2036,12 @@
|
|||||||
|
|
||||||
.app-chat-panel__composer-input-shell {
|
.app-chat-panel__composer-input-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-queue {
|
.app-chat-panel__composer-queue {
|
||||||
@@ -2003,20 +2135,28 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer .ant-input-textarea {
|
.app-chat-panel__composer .ant-input-textarea,
|
||||||
|
.app-chat-panel__composer textarea.ant-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: block;
|
||||||
|
align-self: stretch;
|
||||||
|
flex: none;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer .ant-input-textarea textarea {
|
.app-chat-panel__composer textarea.ant-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
min-height: 88px;
|
height: clamp(64px, 10dvh, 92px);
|
||||||
padding: 8px 76px 16px 14px;
|
min-height: clamp(64px, 10dvh, 92px);
|
||||||
|
padding: 10px 52px 8px 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea {
|
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
|
||||||
padding-top: 76px;
|
padding-top: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2060,7 +2200,7 @@
|
|||||||
.app-chat-panel__composer-clear.ant-btn {
|
.app-chat-panel__composer-clear.ant-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 10px;
|
top: 10px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
@@ -2074,7 +2214,7 @@
|
|||||||
transition:
|
transition:
|
||||||
opacity 0.16s ease,
|
opacity 0.16s ease,
|
||||||
transform 0.16s ease;
|
transform 0.16s ease;
|
||||||
transform: translateY(4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn {
|
.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn {
|
||||||
@@ -2093,6 +2233,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-attachment-chip {
|
.app-chat-panel__composer-attachment-chip {
|
||||||
@@ -2107,6 +2248,19 @@
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__composer-attachment-chip--pending {
|
||||||
|
border-style: dashed;
|
||||||
|
background: rgba(239, 246, 255, 0.96);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__composer-attachment-chip--failed {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(239, 68, 68, 0.28);
|
||||||
|
background: rgba(254, 242, 242, 0.98);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-attachment-name {
|
.app-chat-panel__composer-attachment-name {
|
||||||
max-width: min(240px, 52vw);
|
max-width: min(240px, 52vw);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2116,6 +2270,14 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__composer-attachment-pending-label {
|
||||||
|
flex: none;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-attachment-remove.ant-btn {
|
.app-chat-panel__composer-attachment-remove.ant-btn {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
@@ -2147,13 +2309,11 @@
|
|||||||
padding-block: 2px;
|
padding-block: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-type-note,
|
|
||||||
.app-chat-panel__composer-actions .ant-typography {
|
.app-chat-panel__composer-actions .ant-typography {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__composer-hint,
|
.app-chat-panel__composer-hint {
|
||||||
.app-chat-panel__composer-type-note {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2186,7 +2346,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow: auto;
|
width: 100%;
|
||||||
|
padding-top: 2px;
|
||||||
|
max-height: min(32vh, 240px);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__resource-strip-filter {
|
.app-chat-panel__resource-strip-filter {
|
||||||
@@ -2211,20 +2375,6 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__resource-strip .app-chat-preview-card {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel__resource-strip .app-chat-preview-card__body {
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel__resource-strip .app-chat-panel__preview-rich,
|
|
||||||
.app-chat-panel__resource-strip .previewer-ui__editor,
|
|
||||||
.app-chat-panel__resource-strip .previewer-ui__editor-body {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel__preview-stage {
|
.app-chat-panel__preview-stage {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -2326,15 +2476,30 @@
|
|||||||
.app-chat-panel__preview-video,
|
.app-chat-panel__preview-video,
|
||||||
.app-chat-panel__preview-frame {
|
.app-chat-panel__preview-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #0f172a;
|
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-image {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
max-height: min(72vh, 640px);
|
||||||
|
margin: 0 auto;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||||
|
linear-gradient(135deg, rgba(226, 232, 240, 0.16), rgba(255, 255, 255, 0));
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-video {
|
||||||
|
height: 100%;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-frame {
|
.app-chat-panel__preview-frame {
|
||||||
|
height: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2373,6 +2538,41 @@
|
|||||||
z-index: 1600;
|
z-index: 1600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal .ant-modal-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
inset-inline-end: 18px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.18);
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
opacity: 0.46;
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal .ant-modal-close:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal-close-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 56px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-modal .ant-modal-content {
|
.app-chat-panel__preview-modal .ant-modal-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2487,11 +2687,79 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal .app-chat-panel__preview-image {
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
background: #fff;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-modal .previewer-ui__editor-body {
|
.app-chat-panel__preview-modal .previewer-ui__editor-body {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .ant-modal-content {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .ant-modal-header,
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-meta,
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-findbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .ant-modal-body,
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-body,
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-stage--modal {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-stage--html-mobile {
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-stage--html-mobile > * {
|
||||||
|
display: flex;
|
||||||
|
justify-content: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-chat-panel__preview-modal .ant-modal-close {
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
inset-inline-end: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-stage--html-mobile > * {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.app-chat-panel__preview-modal-title {
|
.app-chat-panel__preview-modal-title {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -2619,16 +2887,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__messages,
|
.app-chat-panel__messages,
|
||||||
.app-chat-panel__composer,
|
|
||||||
.app-chat-panel__preview-stage,
|
.app-chat-panel__preview-stage,
|
||||||
.app-chat-panel__resource-strip {
|
.app-chat-panel__resource-strip {
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__composer {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__composer textarea.ant-input {
|
||||||
|
height: clamp(56px, 8.5dvh, 72px);
|
||||||
|
min-height: clamp(56px, 8.5dvh, 72px);
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
|
||||||
|
padding-top: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__resource-strip-list {
|
.app-chat-panel__resource-strip-list {
|
||||||
overflow: auto;
|
max-height: min(30vh, 220px);
|
||||||
flex-wrap: nowrap;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3104,11 +3390,19 @@
|
|||||||
.chat-v2__conversation-title {
|
.chat-v2__conversation-title {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-v2__conversation-preview {
|
.chat-v2__conversation-preview {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd';
|
||||||
import type { InputRef } from 'antd';
|
import type { InputRef } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
|
||||||
@@ -33,12 +33,11 @@ import { useRuntimeController } from './chatV2/hooks/useRuntimeController';
|
|||||||
import { useConversationViewController } from './chatV2/hooks/useConversationViewController';
|
import { useConversationViewController } from './chatV2/hooks/useConversationViewController';
|
||||||
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
|
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
|
||||||
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
||||||
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
|
|
||||||
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
||||||
import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls';
|
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
|
||||||
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
|
|
||||||
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
||||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
||||||
|
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
||||||
import { createNotificationMessage } from './notificationApi';
|
import { createNotificationMessage } from './notificationApi';
|
||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import {
|
import {
|
||||||
@@ -80,14 +79,10 @@ type ChatTypeOption = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
type CreateConversationTarget = {
|
||||||
|
|
||||||
type PreviewItem = {
|
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
name: string;
|
||||||
url: string;
|
description: string;
|
||||||
kind: PreviewKind;
|
|
||||||
source: 'message' | 'context';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingChatRequest = {
|
type PendingChatRequest = {
|
||||||
@@ -131,6 +126,7 @@ const CHAT_RESTART_EXCLUSION_PATTERNS = [
|
|||||||
/\bno restart\b/i,
|
/\bno restart\b/i,
|
||||||
/\bwithout restart\b/i,
|
/\bwithout restart\b/i,
|
||||||
] as const;
|
] as const;
|
||||||
|
const CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS = [0, 350, 900, 1800] as const;
|
||||||
|
|
||||||
function isStandaloneDisplayMode() {
|
function isStandaloneDisplayMode() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -159,6 +155,70 @@ function isRestartRequiredResponseText(text: string) {
|
|||||||
return CHAT_RESTART_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalized));
|
return CHAT_RESTART_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVisibleCompletedResponseForRequest(
|
||||||
|
detail: ChatConversationDetailResponse,
|
||||||
|
requestId: string,
|
||||||
|
) {
|
||||||
|
const normalizedRequestId = requestId.trim();
|
||||||
|
|
||||||
|
if (!normalizedRequestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = detail.requests.find((item) => item.requestId === normalizedRequestId) ?? null;
|
||||||
|
|
||||||
|
if (!request || request.status !== 'completed') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseByMessageId =
|
||||||
|
request.responseMessageId != null
|
||||||
|
? detail.messages.find((message) => message.id === request.responseMessageId && message.author === 'codex') ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (responseByMessageId && !isPreparingChatReplyText(responseByMessageId.text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail.messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.author === 'codex' &&
|
||||||
|
message.clientRequestId === normalizedRequestId &&
|
||||||
|
!isPreparingChatReplyText(message.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doesConversationDetailSatisfyTerminalRequest(
|
||||||
|
detail: ChatConversationDetailResponse,
|
||||||
|
expectation?: { requestId: string; status: 'completed' | 'failed' },
|
||||||
|
) {
|
||||||
|
if (!expectation) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRequestId = expectation.requestId.trim();
|
||||||
|
|
||||||
|
if (!normalizedRequestId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = detail.requests.find((item) => item.requestId === normalizedRequestId) ?? null;
|
||||||
|
|
||||||
|
if (!request || request.status !== expectation.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.status === 'failed') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasVisibleCompletedResponseForRequest(detail, normalizedRequestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConversationDefaultTitle(chatType: CreateConversationTarget | null) {
|
||||||
|
return chatType?.name?.trim() || '새 대화';
|
||||||
|
}
|
||||||
|
|
||||||
function buildChatSessionLink(sessionId: string) {
|
function buildChatSessionLink(sessionId: string) {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
@@ -519,10 +579,18 @@ function compareConversationItemsByLatestChat(left: ChatConversationSummary, rig
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getConversationLatestActivityTime(item: ChatConversationSummary) {
|
function getConversationLatestActivityTime(item: ChatConversationSummary) {
|
||||||
const latestTimestamp = item.lastMessageAt || item.createdAt;
|
const timestamps = [item.lastMessageAt, item.updatedAt, item.createdAt];
|
||||||
const parsedTime = latestTimestamp ? new Date(latestTimestamp).getTime() : 0;
|
let latestTime = 0;
|
||||||
|
|
||||||
return Number.isFinite(parsedTime) ? parsedTime : 0;
|
timestamps.forEach((timestamp) => {
|
||||||
|
const parsedTime = timestamp ? new Date(timestamp).getTime() : 0;
|
||||||
|
|
||||||
|
if (Number.isFinite(parsedTime) && parsedTime > latestTime) {
|
||||||
|
latestTime = parsedTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return latestTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestConversationPreviewMessage(messages: ChatMessage[]) {
|
function getLatestConversationPreviewMessage(messages: ChatMessage[]) {
|
||||||
@@ -700,127 +768,6 @@ function clearLegacyChatMessageStorage() {
|
|||||||
window.localStorage.removeItem('main-chat-panel:messages');
|
window.localStorage.removeItem('main-chat-panel:messages');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePreviewUrl(value: string) {
|
|
||||||
return normalizeChatResourceUrl(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPreviewRouteUrl(url: string) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url, window.location.origin);
|
|
||||||
const pathname = parsed.pathname.toLowerCase();
|
|
||||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
|
||||||
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyPreviewKind(url: string): PreviewKind {
|
|
||||||
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
|
||||||
|
|
||||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
|
|
||||||
return 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
|
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\.(md|markdown)$/i.test(pathname)) {
|
|
||||||
return 'markdown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\.(diff|patch)$/i.test(pathname)) {
|
|
||||||
return 'diff';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
|
|
||||||
return 'code';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\.(txt|log|csv)$/i.test(pathname)) {
|
|
||||||
return 'document';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\.pdf$/i.test(pathname)) {
|
|
||||||
return 'pdf';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPreviewRouteUrl(url)) {
|
|
||||||
return 'document';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'file';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreviewLabel(url: string, source: PreviewItem['source']) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1);
|
|
||||||
|
|
||||||
if (lastSegment) {
|
|
||||||
return source === 'context' ? `현재 화면 · ${lastSegment}` : lastSegment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return source === 'context' ? '현재 화면 미리보기' : parsed.hostname;
|
|
||||||
} catch {
|
|
||||||
return source === 'context' ? '현재 화면 미리보기' : url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
|
|
||||||
if (!item || item.kind !== 'code') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
|
||||||
const pathname = parsed.pathname.toLowerCase();
|
|
||||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
|
||||||
} catch {
|
|
||||||
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
|
|
||||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPreviewItems(messages: ChatMessage[]) {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const items: PreviewItem[] = [];
|
|
||||||
const orderedMessages = [...messages].reverse();
|
|
||||||
|
|
||||||
orderedMessages.forEach((message) => {
|
|
||||||
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
|
||||||
|
|
||||||
matches.forEach((matchedUrl) => {
|
|
||||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
|
||||||
const kind = classifyPreviewKind(normalizedUrl);
|
|
||||||
|
|
||||||
if (kind === 'file') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seen.has(normalizedUrl)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(normalizedUrl);
|
|
||||||
items.push({
|
|
||||||
id: `${message.id}-${normalizedUrl}`,
|
|
||||||
label: buildPreviewLabel(normalizedUrl, 'message'),
|
|
||||||
url: normalizedUrl,
|
|
||||||
kind,
|
|
||||||
source: 'message',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return items.slice(0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapSystemStatusMessage(text: string) {
|
function mapSystemStatusMessage(text: string) {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
|
|
||||||
@@ -962,6 +909,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
|
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
|
||||||
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
||||||
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
|
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
|
||||||
|
const [isCreateConversationModalOpen, setIsCreateConversationModalOpen] = useState(false);
|
||||||
|
const [createConversationChatTypeId, setCreateConversationChatTypeId] = useState<string | null>(
|
||||||
|
availableChatTypes[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
const selectedCreateConversationChatType =
|
||||||
|
availableChatTypes.find((item) => item.id === createConversationChatTypeId) ?? availableChatTypes[0] ?? null;
|
||||||
const requestedSessionId = getSessionIdFromSearch(location.search);
|
const requestedSessionId = getSessionIdFromSearch(location.search);
|
||||||
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
|
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
|
||||||
const requestedChatView = getRequestedChatViewFromSearch(location.search);
|
const requestedChatView = getRequestedChatViewFromSearch(location.search);
|
||||||
@@ -1015,6 +968,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const loadOlderMessagesRef = useRef<() => void | Promise<void>>(() => {});
|
const loadOlderMessagesRef = useRef<() => void | Promise<void>>(() => {});
|
||||||
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
|
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||||
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
||||||
|
const shouldForceStickToBottomOnNextLoadRef = useRef(false);
|
||||||
|
const lastConversationForegroundResyncAtRef = useRef(0);
|
||||||
const handledRequestedSessionIdRef = useRef('');
|
const handledRequestedSessionIdRef = useRef('');
|
||||||
const syncedSelectedChatTypeSessionIdRef = useRef<string | null>(null);
|
const syncedSelectedChatTypeSessionIdRef = useRef<string | null>(null);
|
||||||
const isClosingConversationRef = useRef(false);
|
const isClosingConversationRef = useRef(false);
|
||||||
@@ -1081,15 +1036,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
setNotificationToggleSessionId((current) => (current === sessionId ? null : current));
|
setNotificationToggleSessionId((current) => (current === sessionId ? null : current));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleCreateConversation = async () => {
|
const handleCreateConversation = async (chatTypeOverride?: CreateConversationTarget | null) => {
|
||||||
const sessionId = createConversationSessionId();
|
const sessionId = createConversationSessionId();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const nextConversationChatType =
|
const nextConversationChatType =
|
||||||
selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null);
|
chatTypeOverride ?? (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null));
|
||||||
|
const nextConversationTitle = resolveConversationDefaultTitle(nextConversationChatType);
|
||||||
const optimisticItem: ChatConversationSummary = {
|
const optimisticItem: ChatConversationSummary = {
|
||||||
sessionId,
|
sessionId,
|
||||||
clientId: null,
|
clientId: null,
|
||||||
title: '새 대화',
|
title: nextConversationTitle,
|
||||||
chatTypeId: nextConversationChatType?.id ?? null,
|
chatTypeId: nextConversationChatType?.id ?? null,
|
||||||
lastChatTypeId: nextConversationChatType?.id ?? null,
|
lastChatTypeId: nextConversationChatType?.id ?? null,
|
||||||
contextLabel: nextConversationChatType?.name ?? null,
|
contextLabel: nextConversationChatType?.name ?? null,
|
||||||
@@ -1115,7 +1071,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
try {
|
try {
|
||||||
const item = await chatGateway.createConversation({
|
const item = await chatGateway.createConversation({
|
||||||
sessionId,
|
sessionId,
|
||||||
title: '새 대화',
|
title: nextConversationTitle,
|
||||||
chatTypeId: nextConversationChatType?.id ?? null,
|
chatTypeId: nextConversationChatType?.id ?? null,
|
||||||
lastChatTypeId: nextConversationChatType?.id ?? null,
|
lastChatTypeId: nextConversationChatType?.id ?? null,
|
||||||
contextLabel: nextConversationChatType?.name,
|
contextLabel: nextConversationChatType?.name,
|
||||||
@@ -1146,6 +1102,25 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
|
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const openCreateConversationModal = () => {
|
||||||
|
if (availableChatTypes.length === 0) {
|
||||||
|
messageApi.warning('사용 가능한 채팅유형이 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateConversationChatTypeId((current) => current ?? selectedChatType?.id ?? availableChatTypes[0]?.id ?? null);
|
||||||
|
setIsCreateConversationModalOpen(true);
|
||||||
|
};
|
||||||
|
const handleConfirmCreateConversation = async () => {
|
||||||
|
if (!selectedCreateConversationChatType) {
|
||||||
|
messageApi.warning('채팅유형을 먼저 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreateConversationModalOpen(false);
|
||||||
|
setSelectedChatTypeId(selectedCreateConversationChatType.id);
|
||||||
|
await handleCreateConversation(selectedCreateConversationChatType);
|
||||||
|
};
|
||||||
const upsertRequestItem = (request: ChatConversationRequest) => {
|
const upsertRequestItem = (request: ChatConversationRequest) => {
|
||||||
setRequestItems((previous) => {
|
setRequestItems((previous) => {
|
||||||
const existingIndex = previous.findIndex(
|
const existingIndex = previous.findIndex(
|
||||||
@@ -1218,24 +1193,66 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
);
|
);
|
||||||
|
|
||||||
const syncConversationFromServer = useCallback(
|
const syncConversationFromServer = useCallback(
|
||||||
async (sessionId: string) => {
|
async (
|
||||||
|
sessionId: string,
|
||||||
|
options?: {
|
||||||
|
ensureTerminalRequest?: {
|
||||||
|
requestId: string;
|
||||||
|
status: 'completed' | 'failed';
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
if (!normalizedSessionId) {
|
if (!normalizedSessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const activeSessionRequestCount = requestItemsRef.current.filter(
|
||||||
const detail = await chatGateway.getConversationDetail(normalizedSessionId, {
|
(item) => item.sessionId === normalizedSessionId,
|
||||||
limit: normalizedSessionId === activeSessionId ? Math.max(20, messagesRef.current.length || 0) : 20,
|
).length;
|
||||||
});
|
const detailLimit =
|
||||||
syncConversationDetailIntoState(normalizedSessionId, detail);
|
normalizedSessionId === activeSessionId
|
||||||
} catch {
|
? Math.max(20, messagesRef.current.length || 0, activeSessionRequestCount || 0)
|
||||||
// Ignore background resync failures.
|
: Math.max(20, activeSessionRequestCount || 0);
|
||||||
|
|
||||||
|
for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) {
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, delayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await chatGateway.getConversationDetail(normalizedSessionId, {
|
||||||
|
limit: detailLimit,
|
||||||
|
});
|
||||||
|
syncConversationDetailIntoState(normalizedSessionId, detail);
|
||||||
|
|
||||||
|
if (doesConversationDetailSatisfyTerminalRequest(detail, options?.ensureTerminalRequest)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore background resync failures and keep retrying briefly.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, syncConversationDetailIntoState],
|
[activeSessionId, syncConversationDetailIntoState],
|
||||||
);
|
);
|
||||||
|
const resyncConversationEntryState = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now - lastConversationForegroundResyncAtRef.current < 600) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastConversationForegroundResyncAtRef.current = now;
|
||||||
|
void reloadConversationItems();
|
||||||
|
|
||||||
|
if (activeSessionId.trim()) {
|
||||||
|
void syncConversationFromServer(activeSessionId);
|
||||||
|
}
|
||||||
|
}, [activeSessionId, reloadConversationItems, syncConversationFromServer]);
|
||||||
|
|
||||||
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
|
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
|
||||||
const sessionId = eventSessionId.trim() || activeSessionId;
|
const sessionId = eventSessionId.trim() || activeSessionId;
|
||||||
@@ -1312,9 +1329,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.setTimeout(() => {
|
void syncConversationFromServer(sessionId, {
|
||||||
void syncConversationFromServer(sessionId);
|
ensureTerminalRequest: {
|
||||||
}, event.status === 'completed' ? 700 : 250);
|
requestId: event.requestId,
|
||||||
|
status: event.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => {
|
const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => {
|
||||||
const sessionId = eventSessionId.trim() || activeSessionId;
|
const sessionId = eventSessionId.trim() || activeSessionId;
|
||||||
@@ -1426,7 +1446,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
|
|
||||||
const eventConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null;
|
const eventConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null;
|
||||||
|
|
||||||
if (incomingMessage.author === 'codex' && hasMeaningfulCodexResponse && isRestartRequiredResponseText(incomingMessage.text)) {
|
if (
|
||||||
|
appConfig.chat.receiveRoomNotifications &&
|
||||||
|
incomingMessage.author === 'codex' &&
|
||||||
|
hasMeaningfulCodexResponse &&
|
||||||
|
isRestartRequiredResponseText(incomingMessage.text)
|
||||||
|
) {
|
||||||
const restartNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:restart-required`;
|
const restartNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:restart-required`;
|
||||||
|
|
||||||
if (!notifiedRestartRequirementKeysRef.current.includes(restartNotificationKey)) {
|
if (!notifiedRestartRequirementKeysRef.current.includes(restartNotificationKey)) {
|
||||||
@@ -1460,7 +1485,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingMessage.author !== 'codex' || eventConversation?.notifyOffline !== true) {
|
if (
|
||||||
|
!appConfig.chat.receiveRoomNotifications ||
|
||||||
|
incomingMessage.author !== 'codex' ||
|
||||||
|
eventConversation?.notifyOffline !== true
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1637,6 +1666,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
activeSessionId,
|
activeSessionId,
|
||||||
oldestLoadedMessageId,
|
oldestLoadedMessageId,
|
||||||
reloadKey: conversationRoomReloadKey,
|
reloadKey: conversationRoomReloadKey,
|
||||||
|
shouldForceStickToBottomOnNextLoadRef,
|
||||||
connectionState,
|
connectionState,
|
||||||
captureViewportRestoreSnapshot,
|
captureViewportRestoreSnapshot,
|
||||||
sessionMessageCacheRef,
|
sessionMessageCacheRef,
|
||||||
@@ -1709,6 +1739,41 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
);
|
);
|
||||||
const pendingDeleteConversation =
|
const pendingDeleteConversation =
|
||||||
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
|
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingContextConfirm && !pendingDeleteConversation) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnterConfirm = (event: KeyboardEvent) => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const visibleModal = Array.from(document.querySelectorAll<HTMLElement>('.ant-modal-root')).find((element) => {
|
||||||
|
return element.offsetParent !== null;
|
||||||
|
});
|
||||||
|
const shouldIgnoreInteractiveTarget =
|
||||||
|
activeElement instanceof HTMLElement &&
|
||||||
|
visibleModal?.contains(activeElement) &&
|
||||||
|
Boolean(activeElement.closest('button, a, input, textarea, select, [role="button"], [contenteditable="true"]'));
|
||||||
|
|
||||||
|
if (event.key !== 'Enter' || event.isComposing || shouldIgnoreInteractiveTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const okButton = visibleModal?.querySelector<HTMLButtonElement>('.ant-modal-footer .ant-btn-primary');
|
||||||
|
|
||||||
|
if (!okButton || okButton.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
okButton.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEnterConfirm, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleEnterConfirm, true);
|
||||||
|
};
|
||||||
|
}, [pendingContextConfirm, pendingDeleteConversation]);
|
||||||
const {
|
const {
|
||||||
activePreview,
|
activePreview,
|
||||||
isPreviewLoading,
|
isPreviewLoading,
|
||||||
@@ -1721,6 +1786,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
} = useConversationViewController({
|
} = useConversationViewController({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeView,
|
activeView,
|
||||||
|
isMobileViewport,
|
||||||
previewItems,
|
previewItems,
|
||||||
selectedChatTypeId,
|
selectedChatTypeId,
|
||||||
composerRef,
|
composerRef,
|
||||||
@@ -1781,6 +1847,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}, [activePreview, messageApi]);
|
}, [activePreview, messageApi]);
|
||||||
|
|
||||||
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
|
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
|
||||||
|
const isHtmlPreviewFullscreen = isActivePreviewHtml && isPreviewModalOpen;
|
||||||
|
|
||||||
const canSearchActivePreview =
|
const canSearchActivePreview =
|
||||||
Boolean(activePreview) &&
|
Boolean(activePreview) &&
|
||||||
@@ -1823,8 +1890,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsHtmlPreviewMode(false);
|
setIsHtmlPreviewMode(isHtmlPreviewItem(activePreview));
|
||||||
}, [activePreview?.id, isPreviewModalOpen]);
|
}, [activePreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetActivePreviewSearchState();
|
resetActivePreviewSearchState();
|
||||||
@@ -1981,7 +2048,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
<span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span>
|
<span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="app-chat-panel__conversation-item-time">
|
<span className="app-chat-panel__conversation-item-time">
|
||||||
{formatConversationListTimestamp(item.lastMessageAt || item.createdAt)}
|
{formatConversationListTimestamp(item.lastMessageAt || item.updatedAt || item.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="app-chat-panel__conversation-item-id">{item.sessionId}</span>
|
<span className="app-chat-panel__conversation-item-id">{item.sessionId}</span>
|
||||||
@@ -2116,10 +2183,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
setIsMobileConversationView(true);
|
setIsMobileConversationView(true);
|
||||||
}
|
}
|
||||||
setActiveView('chat');
|
setActiveView('chat');
|
||||||
|
setConversationRoomReloadKey((previous) => previous + 1);
|
||||||
|
resyncConversationEntryState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
||||||
|
shouldForceStickToBottomOnNextLoadRef.current = sessionId === activeSessionId;
|
||||||
setIsConversationContentLoading(true);
|
setIsConversationContentLoading(true);
|
||||||
setIsDeferringAuxiliaryChatRequests(true);
|
setIsDeferringAuxiliaryChatRequests(true);
|
||||||
setHasOlderMessages(false);
|
setHasOlderMessages(false);
|
||||||
@@ -2177,6 +2247,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
setIsResourceStripOpen(false);
|
setIsResourceStripOpen(false);
|
||||||
shouldStickToBottomRef.current = true;
|
shouldStickToBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
|
void reloadConversationItems();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2439,6 +2510,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
||||||
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (createConversationChatTypeId && availableChatTypes.some((item) => item.id === createConversationChatTypeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateConversationChatTypeId(selectedChatType?.id ?? availableChatTypes[0]?.id ?? null);
|
||||||
|
}, [availableChatTypes, createConversationChatTypeId, selectedChatType?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) {
|
if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) {
|
||||||
return;
|
return;
|
||||||
@@ -2657,6 +2736,49 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
};
|
};
|
||||||
}, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]);
|
}, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionState !== 'connected' || isDeferringAuxiliaryChatRequests) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRestoreConversationAfterReconnectRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldRestoreConversationAfterReconnectRef.current = false;
|
||||||
|
resyncConversationEntryState();
|
||||||
|
|
||||||
|
if (activeSessionId.trim()) {
|
||||||
|
setConversationRoomReloadKey((previous) => previous + 1);
|
||||||
|
}
|
||||||
|
}, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests, resyncConversationEntryState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
resyncConversationEntryState();
|
||||||
|
};
|
||||||
|
const handlePageShow = () => {
|
||||||
|
resyncConversationEntryState();
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resyncConversationEntryState();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', handleFocus);
|
||||||
|
window.addEventListener('pageshow', handlePageShow);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', handleFocus);
|
||||||
|
window.removeEventListener('pageshow', handlePageShow);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [resyncConversationEntryState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionState !== 'disconnected') {
|
if (connectionState !== 'disconnected') {
|
||||||
return;
|
return;
|
||||||
@@ -3035,8 +3157,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
aria-label="새 대화 생성"
|
aria-label="새 대화 생성"
|
||||||
title="새 대화 생성"
|
title="새 대화 생성"
|
||||||
|
disabled={availableChatTypes.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCreateConversation();
|
openCreateConversationModal();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary">{isConversationListLoading ? '불러오는 중' : `${conversationItems.length}건`}</Text>
|
<Text type="secondary">{isConversationListLoading ? '불러오는 중' : `${conversationItems.length}건`}</Text>
|
||||||
@@ -3143,6 +3266,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
|
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
|
||||||
isResourceStripOpen={isResourceStripOpen}
|
isResourceStripOpen={isResourceStripOpen}
|
||||||
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
|
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
|
||||||
|
isMobileViewport={isMobileViewport}
|
||||||
isChatTypeSelectionLocked={isChatTypeSelectionLocked}
|
isChatTypeSelectionLocked={isChatTypeSelectionLocked}
|
||||||
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
||||||
onViewportScroll={handleViewportScroll}
|
onViewportScroll={handleViewportScroll}
|
||||||
@@ -3240,11 +3364,64 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isCreateConversationModalOpen}
|
||||||
|
title="채팅유형 선택"
|
||||||
|
okText="대화 시작"
|
||||||
|
cancelText="취소"
|
||||||
|
zIndex={1700}
|
||||||
|
okButtonProps={{ disabled: !selectedCreateConversationChatType }}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsCreateConversationModalOpen(false);
|
||||||
|
}}
|
||||||
|
onOk={() => {
|
||||||
|
void handleConfirmCreateConversation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableChatTypes.length > 0 ? (
|
||||||
|
<div className="app-chat-panel__create-conversation-modal">
|
||||||
|
<Text type="secondary">
|
||||||
|
신규 채팅방은 선택한 채팅유형으로 생성되고, 방명도 같은 이름으로 시작합니다.
|
||||||
|
</Text>
|
||||||
|
<Radio.Group
|
||||||
|
className="app-chat-panel__create-conversation-options"
|
||||||
|
value={selectedCreateConversationChatType?.id}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCreateConversationChatTypeId(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={12} className="app-chat-panel__create-conversation-space">
|
||||||
|
{availableChatTypes.map((item) => (
|
||||||
|
<label key={item.id} className="app-chat-panel__create-conversation-option">
|
||||||
|
<Radio value={item.id}>
|
||||||
|
<span className="app-chat-panel__create-conversation-option-label">{item.name}</span>
|
||||||
|
</Radio>
|
||||||
|
{item.description.trim() ? (
|
||||||
|
<Text type="secondary" className="app-chat-panel__create-conversation-option-description">
|
||||||
|
{item.description.split('\n')[0].replace(/^#+\s*/, '')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="사용 가능한 채팅유형이 없습니다."
|
||||||
|
description="채팅유형 관리에서 현재 사용자 권한으로 사용할 수 있는 유형을 먼저 등록하세요."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(pendingContextConfirm)}
|
open={Boolean(pendingContextConfirm)}
|
||||||
title="최근 대화 문맥 일부만 참조됩니다"
|
title="최근 대화 문맥 일부만 참조됩니다"
|
||||||
okText="확인 후 전송"
|
okText="확인 후 전송"
|
||||||
cancelText="취소"
|
cancelText="취소"
|
||||||
|
okButtonProps={{ autoFocus: true }}
|
||||||
|
modalRender={renderModalWithEnterConfirm}
|
||||||
zIndex={1690}
|
zIndex={1690}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setPendingContextConfirm(null);
|
setPendingContextConfirm(null);
|
||||||
@@ -3273,7 +3450,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
<Modal
|
<Modal
|
||||||
open={isPreviewModalOpen && Boolean(activePreview)}
|
open={isPreviewModalOpen && Boolean(activePreview)}
|
||||||
title={
|
title={
|
||||||
activePreview ? (
|
isHtmlPreviewFullscreen
|
||||||
|
? null
|
||||||
|
: activePreview ? (
|
||||||
<div className="app-chat-panel__preview-modal-title">
|
<div className="app-chat-panel__preview-modal-title">
|
||||||
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
@@ -3313,17 +3492,20 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}}
|
}}
|
||||||
width="100vw"
|
width="100vw"
|
||||||
zIndex={1600}
|
zIndex={1600}
|
||||||
className="app-chat-panel__preview-modal"
|
className={`app-chat-panel__preview-modal${isHtmlPreviewFullscreen ? ' app-chat-panel__preview-modal--html-mobile' : ''}`}
|
||||||
|
closeIcon={<span className="app-chat-panel__preview-modal-close-label">닫기</span>}
|
||||||
>
|
>
|
||||||
{activePreview ? (
|
{activePreview ? (
|
||||||
<div className="app-chat-panel__preview-modal-body">
|
<div className="app-chat-panel__preview-modal-body">
|
||||||
<div className="app-chat-panel__preview-modal-meta">
|
{!isHtmlPreviewFullscreen ? (
|
||||||
<Space size={[8, 8]} wrap>
|
<div className="app-chat-panel__preview-modal-meta">
|
||||||
<Tag icon={<PaperClipOutlined />}>{activePreview.kind}</Tag>
|
<Space size={[8, 8]} wrap>
|
||||||
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
|
<Tag icon={<PaperClipOutlined />}>{activePreview.kind}</Tag>
|
||||||
</Space>
|
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
|
||||||
</div>
|
</Space>
|
||||||
{canSearchActivePreview && isPreviewFindOpen ? (
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!isHtmlPreviewFullscreen && canSearchActivePreview && isPreviewFindOpen ? (
|
||||||
<div className="app-chat-panel__preview-modal-findbar">
|
<div className="app-chat-panel__preview-modal-findbar">
|
||||||
<Input
|
<Input
|
||||||
ref={previewFindInputRef}
|
ref={previewFindInputRef}
|
||||||
@@ -3359,7 +3541,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
ref={previewSearchRootRef}
|
ref={previewSearchRootRef}
|
||||||
className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root"
|
className={`app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root${
|
||||||
|
isHtmlPreviewFullscreen ? ' app-chat-panel__preview-stage--html-mobile' : ''
|
||||||
|
}`}
|
||||||
onPointerDownCapture={handlePreviewSearchRootPointerDown}
|
onPointerDownCapture={handlePreviewSearchRootPointerDown}
|
||||||
>
|
>
|
||||||
<ChatPreviewBody
|
<ChatPreviewBody
|
||||||
@@ -3382,7 +3566,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
title="대화방을 삭제할까요?"
|
title="대화방을 삭제할까요?"
|
||||||
okText="삭제"
|
okText="삭제"
|
||||||
cancelText="취소"
|
cancelText="취소"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true, autoFocus: true }}
|
||||||
|
modalRender={renderModalWithEnterConfirm}
|
||||||
zIndex={1700}
|
zIndex={1700}
|
||||||
className="app-chat-panel__delete-confirm-modal"
|
className="app-chat-panel__delete-confirm-modal"
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
type AppConfig,
|
type AppConfig,
|
||||||
type PlanCostTimeUnit,
|
type PlanCostTimeUnit,
|
||||||
} from './appConfig';
|
} from './appConfig';
|
||||||
|
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
||||||
import {
|
import {
|
||||||
fetchWebPushConfig,
|
fetchWebPushConfig,
|
||||||
registerPwaNotificationToken,
|
registerPwaNotificationToken,
|
||||||
@@ -119,7 +120,9 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat'])
|
|||||||
return (
|
return (
|
||||||
left.maxContextMessages === right.maxContextMessages &&
|
left.maxContextMessages === right.maxContextMessages &&
|
||||||
left.maxContextChars === right.maxContextChars &&
|
left.maxContextChars === right.maxContextChars &&
|
||||||
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds
|
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds &&
|
||||||
|
left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds &&
|
||||||
|
left.receiveRoomNotifications === right.receiveRoomNotifications
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +141,14 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
|
|||||||
changedLabels.push('Codex Live 최대 실행 시간');
|
changedLabels.push('Codex Live 최대 실행 시간');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (saved.codexLiveIdleTimeoutSeconds !== draft.codexLiveIdleTimeoutSeconds) {
|
||||||
|
changedLabels.push('Codex Live 무출력 실패 시간');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved.receiveRoomNotifications !== draft.receiveRoomNotifications) {
|
||||||
|
changedLabels.push('채팅방 알림 수신');
|
||||||
|
}
|
||||||
|
|
||||||
return changedLabels;
|
return changedLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,6 +1002,31 @@ export function MainHeader({
|
|||||||
: totalPendingUpdateCount === 1
|
: totalPendingUpdateCount === 1
|
||||||
? '업데이트 1건 존재'
|
? '업데이트 1건 존재'
|
||||||
: '최신 상태';
|
: '최신 상태';
|
||||||
|
const headerTopMenuOptions = hasAccess
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: isMobileViewport ? <span aria-label="Docs"><FileMarkdownOutlined /></span> : 'Docs',
|
||||||
|
value: 'docs',
|
||||||
|
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isMobileViewport ? <span aria-label="작업"><ProfileOutlined /></span> : '작업',
|
||||||
|
value: 'plans',
|
||||||
|
icon: isMobileViewport ? undefined : <ProfileOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isMobileViewport ? <span aria-label="Play"><ApiOutlined /></span> : 'Play',
|
||||||
|
value: 'play',
|
||||||
|
icon: isMobileViewport ? undefined : <ApiOutlined />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: isMobileViewport ? <span aria-label="Docs"><FileMarkdownOutlined /></span> : 'Docs',
|
||||||
|
value: 'docs',
|
||||||
|
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
|
||||||
|
},
|
||||||
|
];
|
||||||
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
||||||
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
|
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
|
||||||
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
|
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
|
||||||
@@ -1713,6 +1749,8 @@ export function MainHeader({
|
|||||||
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
|
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
|
||||||
okText: '빌드 및 재기동',
|
okText: '빌드 및 재기동',
|
||||||
cancelText: '취소',
|
cancelText: '취소',
|
||||||
|
autoFocusButton: 'ok',
|
||||||
|
modalRender: renderModalWithEnterConfirm,
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await handleRestartSingleServer('prod');
|
await handleRestartSingleServer('prod');
|
||||||
@@ -1812,6 +1850,8 @@ export function MainHeader({
|
|||||||
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
||||||
okText: '초기화',
|
okText: '초기화',
|
||||||
cancelText: '취소',
|
cancelText: '취소',
|
||||||
|
autoFocusButton: 'ok',
|
||||||
|
modalRender: renderModalWithEnterConfirm,
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
clearNotificationIdentity();
|
clearNotificationIdentity();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -2144,8 +2184,8 @@ export function MainHeader({
|
|||||||
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
|
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
|
||||||
description={
|
description={
|
||||||
chatSettingsDirty
|
chatSettingsDirty
|
||||||
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초`
|
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}`
|
||||||
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초까지 허용`
|
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2194,6 +2234,26 @@ export function MainHeader({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
checked={appConfigDraft.chat.receiveRoomNotifications}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAppConfigDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
chat: {
|
||||||
|
...current.chat,
|
||||||
|
receiveRoomNotifications: event.target.checked,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
채팅방 알림 수신
|
||||||
|
</Checkbox>
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
꺼두면 채팅방별 벨 설정과 관계없이 Codex Live 채팅방 알림을 이 기기에서 받지 않습니다.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Text strong>Codex Live 최대 실행 시간(초)</Text>
|
<Text strong>Codex Live 최대 실행 시간(초)</Text>
|
||||||
<Paragraph type="secondary">Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다.</Paragraph>
|
<Paragraph type="secondary">Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다.</Paragraph>
|
||||||
@@ -2216,6 +2276,29 @@ export function MainHeader({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text strong>Codex Live 무출력 실패 시간(초)</Text>
|
||||||
|
<Paragraph type="secondary">이 시간 동안 출력이나 활동 로그가 없으면 해당 요청을 실패 처리합니다.</Paragraph>
|
||||||
|
<InputNumber
|
||||||
|
min={30}
|
||||||
|
max={3600}
|
||||||
|
step={10}
|
||||||
|
value={appConfigDraft.chat.codexLiveIdleTimeoutSeconds}
|
||||||
|
onChange={(value) => {
|
||||||
|
setAppConfigDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
chat: {
|
||||||
|
...current.chat,
|
||||||
|
codexLiveIdleTimeoutSeconds:
|
||||||
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? Math.min(3600, Math.max(30, Math.round(value)))
|
||||||
|
: DEFAULT_APP_CONFIG.chat.codexLiveIdleTimeoutSeconds,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2855,17 +2938,11 @@ export function MainHeader({
|
|||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
/>
|
/>
|
||||||
<Segmented
|
<Segmented
|
||||||
|
className="app-header__top-menu"
|
||||||
value={headerTopMenu}
|
value={headerTopMenu}
|
||||||
options={
|
options={headerTopMenuOptions}
|
||||||
hasAccess
|
|
||||||
? [
|
|
||||||
{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> },
|
|
||||||
{ label: '작업', value: 'plans', icon: <ProfileOutlined /> },
|
|
||||||
]
|
|
||||||
: [{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> }]
|
|
||||||
}
|
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
onChangeTopMenu(value as 'docs' | 'plans');
|
onChangeTopMenu(value as 'docs' | 'plans' | 'play');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -11,12 +11,24 @@
|
|||||||
overflow: hidden;
|
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:has(.app-chat-panel) > .ant-layout {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: calc(100dvh - 60px);
|
height: calc(100dvh - 60px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.app-main-panel--play-saved) > .ant-layout {
|
||||||
|
min-height: 0;
|
||||||
|
height: calc(100dvh - 60px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell--docs-api {
|
.app-shell--docs-api {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(22, 93, 255, 0.12), transparent 26%),
|
radial-gradient(circle at top left, rgba(22, 93, 255, 0.12), transparent 26%),
|
||||||
@@ -410,6 +422,15 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__top-menu.ant-segmented {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__top-menu .ant-segmented-item {
|
||||||
|
min-height: 34px;
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-sider.ant-layout-sider {
|
.app-sider.ant-layout-sider {
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
border-right: 1px solid rgba(148, 163, 184, 0.14);
|
border-right: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
@@ -471,6 +492,13 @@
|
|||||||
overflow: hidden;
|
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--expanded.ant-layout-content {
|
.app-main-content--expanded.ant-layout-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -488,12 +516,30 @@
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main-panel--play-saved {
|
||||||
|
height: 100%;
|
||||||
|
min-height: calc(100dvh - 60px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main-panel--play > * {
|
.app-main-panel--play > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main-layout:has(.app-main-panel--play-saved) {
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main-content--expanded.ant-layout-content:has(.app-main-panel--play-saved) {
|
||||||
|
min-height: calc(100dvh - 60px);
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main-panel:has(.app-chat-panel) {
|
.app-main-panel:has(.app-chat-panel) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
@@ -556,6 +602,16 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.app-main-panel--play-saved),
|
||||||
|
.app-shell:has(.app-main-panel--play-saved) > .ant-layout,
|
||||||
|
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
|
||||||
|
.app-main-layout:has(.app-main-panel--play-saved),
|
||||||
|
.app-main-panel--play-saved {
|
||||||
|
height: calc(100dvh - 52px);
|
||||||
|
min-height: calc(100dvh - 52px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
}
|
}
|
||||||
@@ -657,12 +713,13 @@
|
|||||||
|
|
||||||
.app-main-window-layer__body {
|
.app-main-window-layer__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 100%;
|
min-height: 0;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main-window-layer__fallback {
|
.app-main-window-layer__fallback {
|
||||||
@@ -765,6 +822,15 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__top-menu.ant-segmented {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__top-menu .ant-segmented-item {
|
||||||
|
min-height: 34px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__row .ant-btn {
|
.app-header__row .ant-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -811,6 +877,14 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main-panel--play-saved {
|
||||||
|
min-height: calc(100dvh - 52px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main-layout:has(.app-main-panel--play-saved) {
|
||||||
|
min-height: calc(100dvh - 52px);
|
||||||
|
}
|
||||||
|
|
||||||
.app-main-layout:has(.chat-type-management-page) {
|
.app-main-layout:has(.chat-type-management-page) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type AppConfig = {
|
|||||||
maxContextMessages: number;
|
maxContextMessages: number;
|
||||||
maxContextChars: number;
|
maxContextChars: number;
|
||||||
codexLiveMaxExecutionSeconds: number;
|
codexLiveMaxExecutionSeconds: number;
|
||||||
|
codexLiveIdleTimeoutSeconds: number;
|
||||||
|
receiveRoomNotifications: boolean;
|
||||||
};
|
};
|
||||||
automation: {
|
automation: {
|
||||||
autoRefreshEnabled: boolean;
|
autoRefreshEnabled: boolean;
|
||||||
@@ -72,6 +74,8 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
maxContextMessages: 12,
|
maxContextMessages: 12,
|
||||||
maxContextChars: 3200,
|
maxContextChars: 3200,
|
||||||
codexLiveMaxExecutionSeconds: 600,
|
codexLiveMaxExecutionSeconds: 600,
|
||||||
|
codexLiveIdleTimeoutSeconds: 180,
|
||||||
|
receiveRoomNotifications: true,
|
||||||
},
|
},
|
||||||
automation: {
|
automation: {
|
||||||
autoRefreshEnabled: true,
|
autoRefreshEnabled: true,
|
||||||
@@ -253,6 +257,22 @@ function normalizeCodexLiveMaxExecutionSeconds(value: number | undefined, fallba
|
|||||||
return Math.min(7200, Math.max(60, Math.round(value)));
|
return Math.min(7200, Math.max(60, Math.round(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCodexLiveIdleTimeoutSeconds(value: number | undefined, fallback: number) {
|
||||||
|
if (value === undefined || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(3600, Math.max(30, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBooleanValue(value: boolean | undefined, fallback: boolean) {
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
||||||
const chat = raw?.chat;
|
const chat = raw?.chat;
|
||||||
const automation = raw?.automation;
|
const automation = raw?.automation;
|
||||||
@@ -272,6 +292,14 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
|||||||
chat?.codexLiveMaxExecutionSeconds,
|
chat?.codexLiveMaxExecutionSeconds,
|
||||||
DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds,
|
DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds,
|
||||||
),
|
),
|
||||||
|
codexLiveIdleTimeoutSeconds: normalizeCodexLiveIdleTimeoutSeconds(
|
||||||
|
chat?.codexLiveIdleTimeoutSeconds,
|
||||||
|
DEFAULT_APP_CONFIG.chat.codexLiveIdleTimeoutSeconds,
|
||||||
|
),
|
||||||
|
receiveRoomNotifications: normalizeBooleanValue(
|
||||||
|
chat?.receiveRoomNotifications,
|
||||||
|
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
automation: {
|
automation: {
|
||||||
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
|
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
|||||||
id: 'general-request',
|
id: 'general-request',
|
||||||
name: '일반 요청',
|
name: '일반 요청',
|
||||||
description:
|
description:
|
||||||
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\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에서는 별도 요청이 없는 한 참조하지 마세요.',
|
'## 기본 처리\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'],
|
permissions: ['token-user'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,19 @@ import { useCallback } from 'react';
|
|||||||
import { chatGateway } from '../data/chatGateway';
|
import { chatGateway } from '../data/chatGateway';
|
||||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||||
|
|
||||||
|
export type ComposerFilePickResult = {
|
||||||
|
items: {
|
||||||
|
key: string;
|
||||||
|
fileName: string;
|
||||||
|
status: 'uploaded' | 'failed';
|
||||||
|
reason?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildComposerFilePickKey(file: File) {
|
||||||
|
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
||||||
|
}
|
||||||
|
|
||||||
type PendingChatRequest = {
|
type PendingChatRequest = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -107,9 +120,9 @@ export function useConversationComposerController({
|
|||||||
scrollViewportToBottom,
|
scrollViewportToBottom,
|
||||||
}: UseConversationComposerControllerOptions) {
|
}: UseConversationComposerControllerOptions) {
|
||||||
const handleComposerFilesPicked = useCallback(
|
const handleComposerFilesPicked = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]): Promise<ComposerFilePickResult> => {
|
||||||
if (files.length === 0 || isComposerAttachmentUploading) {
|
if (files.length === 0 || isComposerAttachmentUploading) {
|
||||||
return;
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsComposerAttachmentUploading(true);
|
setIsComposerAttachmentUploading(true);
|
||||||
@@ -117,7 +130,7 @@ export function useConversationComposerController({
|
|||||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||||
);
|
);
|
||||||
const uploadedItems: ChatComposerAttachment[] = [];
|
const uploadedItems: ChatComposerAttachment[] = [];
|
||||||
const failedFileNames: string[] = [];
|
const failedItems: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
|
||||||
uploadResults.forEach((result, index) => {
|
uploadResults.forEach((result, index) => {
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
@@ -125,7 +138,12 @@ export function useConversationComposerController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
|
const fileName = files[index]?.name || `파일 ${index + 1}`;
|
||||||
|
const reason =
|
||||||
|
result.reason instanceof Error && result.reason.message.trim()
|
||||||
|
? result.reason.message.trim()
|
||||||
|
: '업로드 실패';
|
||||||
|
failedItems.push({ fileName, reason });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uploadedItems.length > 0) {
|
if (uploadedItems.length > 0) {
|
||||||
@@ -134,14 +152,29 @@ export function useConversationComposerController({
|
|||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failedFileNames.length > 0) {
|
if (failedItems.length > 0) {
|
||||||
setMessages((previous) => [
|
setMessages((previous) => [
|
||||||
...previous.slice(-39),
|
...previous.slice(-39),
|
||||||
createLocalMessage(`파일 업로드에 실패했습니다: ${failedFileNames.join(', ')}`),
|
createLocalMessage(
|
||||||
|
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsComposerAttachmentUploading(false);
|
setIsComposerAttachmentUploading(false);
|
||||||
|
return {
|
||||||
|
items: uploadResults.map((result, index) => ({
|
||||||
|
key: buildComposerFilePickKey(files[index] as File),
|
||||||
|
fileName: files[index]?.name || `파일 ${index + 1}`,
|
||||||
|
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
|
||||||
|
reason:
|
||||||
|
result.status === 'fulfilled'
|
||||||
|
? undefined
|
||||||
|
: result.reason instanceof Error && result.reason.message.trim()
|
||||||
|
? result.reason.message.trim()
|
||||||
|
: '업로드 실패',
|
||||||
|
})),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type UseConversationRoomDataOptions = {
|
|||||||
activeSessionId: string;
|
activeSessionId: string;
|
||||||
oldestLoadedMessageId: number | null;
|
oldestLoadedMessageId: number | null;
|
||||||
reloadKey: number;
|
reloadKey: number;
|
||||||
|
shouldForceStickToBottomOnNextLoadRef: MutableRefObject<boolean>;
|
||||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||||
captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void;
|
captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void;
|
||||||
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
|
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
|
||||||
@@ -51,6 +52,7 @@ export function useConversationRoomData({
|
|||||||
activeSessionId,
|
activeSessionId,
|
||||||
oldestLoadedMessageId,
|
oldestLoadedMessageId,
|
||||||
reloadKey,
|
reloadKey,
|
||||||
|
shouldForceStickToBottomOnNextLoadRef,
|
||||||
connectionState,
|
connectionState,
|
||||||
captureViewportRestoreSnapshot,
|
captureViewportRestoreSnapshot,
|
||||||
sessionMessageCacheRef,
|
sessionMessageCacheRef,
|
||||||
@@ -93,11 +95,13 @@ export function useConversationRoomData({
|
|||||||
|
|
||||||
const loadConversationDetail = async () => {
|
const loadConversationDetail = async () => {
|
||||||
const isSessionChanged = previousSessionIdRef.current !== requestedSessionId;
|
const isSessionChanged = previousSessionIdRef.current !== requestedSessionId;
|
||||||
|
const shouldForceStickToBottom = isSessionChanged || shouldForceStickToBottomOnNextLoadRef.current;
|
||||||
|
|
||||||
previousSessionIdRef.current = requestedSessionId;
|
previousSessionIdRef.current = requestedSessionId;
|
||||||
captureViewportRestoreSnapshot({
|
captureViewportRestoreSnapshot({
|
||||||
forceStickToBottom: isSessionChanged,
|
forceStickToBottom: shouldForceStickToBottom,
|
||||||
});
|
});
|
||||||
|
shouldForceStickToBottomOnNextLoadRef.current = false;
|
||||||
pendingViewportRestoreRef.current = true;
|
pendingViewportRestoreRef.current = true;
|
||||||
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
||||||
const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
|
const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
|
||||||
@@ -196,6 +200,7 @@ export function useConversationRoomData({
|
|||||||
pendingViewportRestoreRef,
|
pendingViewportRestoreRef,
|
||||||
reloadKey,
|
reloadKey,
|
||||||
sessionMessageCacheRef,
|
sessionMessageCacheRef,
|
||||||
|
shouldForceStickToBottomOnNextLoadRef,
|
||||||
setConversationItems,
|
setConversationItems,
|
||||||
setConversationLoadingLabel,
|
setConversationLoadingLabel,
|
||||||
setIsConversationContentLoading,
|
setIsConversationContentLoading,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PreviewItem = {
|
|||||||
type UseConversationViewControllerOptions = {
|
type UseConversationViewControllerOptions = {
|
||||||
activeSessionId: string;
|
activeSessionId: string;
|
||||||
activeView: 'chat' | 'runtime' | 'errors';
|
activeView: 'chat' | 'runtime' | 'errors';
|
||||||
|
isMobileViewport: boolean;
|
||||||
previewItems: PreviewItem[];
|
previewItems: PreviewItem[];
|
||||||
selectedChatTypeId: string | null;
|
selectedChatTypeId: string | null;
|
||||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||||
@@ -28,6 +29,7 @@ export function useConversationViewController({
|
|||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeView,
|
activeView,
|
||||||
composerRef,
|
composerRef,
|
||||||
|
isMobileViewport,
|
||||||
previewItems,
|
previewItems,
|
||||||
selectedChatTypeId,
|
selectedChatTypeId,
|
||||||
setActiveSystemStatus,
|
setActiveSystemStatus,
|
||||||
@@ -99,7 +101,12 @@ export function useConversationViewController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePreview.kind === 'image' || activePreview.kind === 'video' || activePreview.kind === 'pdf') {
|
if (
|
||||||
|
activePreview.kind === 'image' ||
|
||||||
|
activePreview.kind === 'video' ||
|
||||||
|
activePreview.kind === 'pdf' ||
|
||||||
|
activePreview.kind === 'file'
|
||||||
|
) {
|
||||||
setPreviewText('');
|
setPreviewText('');
|
||||||
setPreviewError('');
|
setPreviewError('');
|
||||||
setPreviewContentType('');
|
setPreviewContentType('');
|
||||||
@@ -146,12 +153,12 @@ export function useConversationViewController({
|
|||||||
}, [activePreview, isPreviewModalOpen]);
|
}, [activePreview, isPreviewModalOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView !== 'chat') {
|
if (activeView !== 'chat' || isMobileViewport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
composerRef.current?.focus({ cursor: 'end' });
|
composerRef.current?.focus({ cursor: 'end' });
|
||||||
}, [activeView, composerRef, selectedChatTypeId]);
|
}, [activeView, composerRef, isMobileViewport, selectedChatTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView !== 'chat') {
|
if (activeView !== 'chat') {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CodeOutlined,
|
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
@@ -17,7 +16,7 @@ import {
|
|||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
UpOutlined,
|
UpOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd';
|
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -32,7 +31,8 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { InlineImage } from '../../../components/common/InlineImage';
|
import { InlineImage } from '../../../components/common/InlineImage';
|
||||||
import { CodexDiffBlock } from '../../../components/previewer';
|
import { CodexDiffBlock } from '../../../components/previewer';
|
||||||
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
||||||
|
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||||
@@ -40,7 +40,6 @@ import { copyPreviewContent, copyText } from './chatUtils';
|
|||||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
|
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
|
||||||
|
|
||||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||||
const { Text } = Typography;
|
|
||||||
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||||
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
|
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
|
||||||
timeZone: KST_TIME_ZONE,
|
timeZone: KST_TIME_ZONE,
|
||||||
@@ -81,6 +80,13 @@ type InlinePreviewTarget = {
|
|||||||
kind: InlinePreviewKind;
|
kind: InlinePreviewKind;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingComposerUpload = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
status: 'uploading' | 'uploaded' | 'failed';
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type PreviewFetchError = Error & {
|
type PreviewFetchError = Error & {
|
||||||
status?: number;
|
status?: number;
|
||||||
};
|
};
|
||||||
@@ -171,19 +177,8 @@ function buildPreviewFileName(item: PreviewOption) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
|
function buildComposerFilePickKey(file: File) {
|
||||||
switch (kind) {
|
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
||||||
case 'image':
|
|
||||||
case 'video':
|
|
||||||
case 'markdown':
|
|
||||||
case 'code':
|
|
||||||
case 'diff':
|
|
||||||
case 'document':
|
|
||||||
case 'pdf':
|
|
||||||
return kind;
|
|
||||||
default:
|
|
||||||
return 'file';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||||
@@ -459,7 +454,7 @@ function InlineMessagePreview({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') {
|
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,9 +524,6 @@ function InlineMessagePreview({
|
|||||||
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
|
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
|
||||||
<div className="app-chat-preview-card__header">
|
<div className="app-chat-preview-card__header">
|
||||||
<div className="app-chat-preview-card__meta">
|
<div className="app-chat-preview-card__meta">
|
||||||
<span className="app-chat-preview-card__glyph" aria-hidden="true">
|
|
||||||
{resolveChatPreviewGlyph(target.kind)}
|
|
||||||
</span>
|
|
||||||
<div className="app-chat-preview-card__titles">
|
<div className="app-chat-preview-card__titles">
|
||||||
<span className="app-chat-preview-card__label">{target.label}</span>
|
<span className="app-chat-preview-card__label">{target.label}</span>
|
||||||
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
|
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
|
||||||
@@ -623,9 +615,6 @@ function DiffMessagePreview({
|
|||||||
>
|
>
|
||||||
<div className="app-chat-preview-card__header">
|
<div className="app-chat-preview-card__header">
|
||||||
<div className="app-chat-preview-card__meta">
|
<div className="app-chat-preview-card__meta">
|
||||||
<span className="app-chat-preview-card__glyph" aria-hidden="true">
|
|
||||||
<CodeOutlined />
|
|
||||||
</span>
|
|
||||||
<div className="app-chat-preview-card__titles">
|
<div className="app-chat-preview-card__titles">
|
||||||
<span className="app-chat-preview-card__label">Codex Diff</span>
|
<span className="app-chat-preview-card__label">Codex Diff</span>
|
||||||
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}개`}</span>
|
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}개`}</span>
|
||||||
@@ -707,6 +696,7 @@ type ChatConversationViewProps = {
|
|||||||
previewItems: PreviewOption[];
|
previewItems: PreviewOption[];
|
||||||
isResourceStripOpen: boolean;
|
isResourceStripOpen: boolean;
|
||||||
isComposerDisabled: boolean;
|
isComposerDisabled: boolean;
|
||||||
|
isMobileViewport: boolean;
|
||||||
isChatTypeSelectionLocked: boolean;
|
isChatTypeSelectionLocked: boolean;
|
||||||
isComposerAttachmentUploading: boolean;
|
isComposerAttachmentUploading: boolean;
|
||||||
onViewportScroll: () => void;
|
onViewportScroll: () => void;
|
||||||
@@ -714,7 +704,7 @@ type ChatConversationViewProps = {
|
|||||||
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
||||||
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
|
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
|
||||||
onDraftChange: (value: string) => void;
|
onDraftChange: (value: string) => void;
|
||||||
onPickComposerFiles: (files: File[]) => void | Promise<void>;
|
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
|
||||||
onRemoveComposerAttachment: (attachmentId: string) => void;
|
onRemoveComposerAttachment: (attachmentId: string) => void;
|
||||||
onSelectChatType: (value: string) => void;
|
onSelectChatType: (value: string) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
@@ -753,6 +743,7 @@ export function ChatConversationView({
|
|||||||
previewItems,
|
previewItems,
|
||||||
isResourceStripOpen,
|
isResourceStripOpen,
|
||||||
isComposerDisabled,
|
isComposerDisabled,
|
||||||
|
isMobileViewport,
|
||||||
isChatTypeSelectionLocked,
|
isChatTypeSelectionLocked,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
onViewportScroll,
|
onViewportScroll,
|
||||||
@@ -777,12 +768,12 @@ export function ChatConversationView({
|
|||||||
}: ChatConversationViewProps) {
|
}: ChatConversationViewProps) {
|
||||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||||
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
|
|
||||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||||
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
||||||
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
||||||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||||||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||||||
|
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
||||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||||
@@ -841,11 +832,6 @@ export function ChatConversationView({
|
|||||||
return [...ordered, ...orphanActivityMessages];
|
return [...ordered, ...orphanActivityMessages];
|
||||||
}, [visibleMessages]);
|
}, [visibleMessages]);
|
||||||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||||||
const selectedChatTypeOption = useMemo(
|
|
||||||
() => chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null,
|
|
||||||
[chatTypeOptions, selectedChatTypeId],
|
|
||||||
);
|
|
||||||
const normalizedSelectedChatTypeLabel = selectedChatTypeOption?.label?.trim() ?? '';
|
|
||||||
const isChatTypeReadonly = useMemo(() => {
|
const isChatTypeReadonly = useMemo(() => {
|
||||||
if (isChatTypeSelectionLocked) {
|
if (isChatTypeSelectionLocked) {
|
||||||
return true;
|
return true;
|
||||||
@@ -1064,8 +1050,74 @@ export function ChatConversationView({
|
|||||||
};
|
};
|
||||||
}, [isComposerAttachmentUploading, isConversationLoading]);
|
}, [isComposerAttachmentUploading, isConversationLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingComposerUploads.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedAttachmentNames = new Set(
|
||||||
|
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const resolvedUploads = pendingComposerUploads.filter(
|
||||||
|
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resolvedUploads.length > 0) {
|
||||||
|
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
|
||||||
|
setPendingComposerUploads((current) => current.filter((item) => !resolvedKeys.has(item.key)));
|
||||||
|
}
|
||||||
|
}, [composerAttachments, pendingComposerUploads]);
|
||||||
|
|
||||||
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
||||||
|
|
||||||
|
const syncPendingComposerUploads = async (files: File[]) => {
|
||||||
|
const nextPendingUploads = files.map((file) => ({
|
||||||
|
key: buildComposerFilePickKey(file),
|
||||||
|
name: file.name,
|
||||||
|
status: 'uploading' as const,
|
||||||
|
}));
|
||||||
|
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
||||||
|
|
||||||
|
setPendingComposerUploads((current) => [
|
||||||
|
...current.filter((item) => !pendingKeys.has(item.key)),
|
||||||
|
...nextPendingUploads,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result: ComposerFilePickResult = { items: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = (await onPickComposerFiles(files)) ?? { items: [] };
|
||||||
|
} catch {
|
||||||
|
result = {
|
||||||
|
items: nextPendingUploads.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
fileName: item.name,
|
||||||
|
status: 'failed',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultByKey = new Map<string, ComposerFilePickResult['items'][number]>(
|
||||||
|
result.items.map((item) => [item.key, item]),
|
||||||
|
);
|
||||||
|
|
||||||
|
setPendingComposerUploads((current) =>
|
||||||
|
current.flatMap((item) => {
|
||||||
|
if (!pendingKeys.has(item.key)) {
|
||||||
|
return [item];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = resultByKey.get(item.key);
|
||||||
|
|
||||||
|
if (!matched || matched.status === 'failed') {
|
||||||
|
return [{ ...item, status: 'failed', reason: matched?.reason }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ ...item, status: 'uploaded', reason: undefined }];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files ?? []);
|
const files = Array.from(event.target.files ?? []);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
@@ -1074,7 +1126,7 @@ export function ChatConversationView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onPickComposerFiles(files);
|
void syncPendingComposerUploads(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -1100,9 +1152,69 @@ export function ChatConversationView({
|
|||||||
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
|
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
|
||||||
);
|
);
|
||||||
|
|
||||||
void onPickComposerFiles(uniqueFiles);
|
void syncPendingComposerUploads(uniqueFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dismissPendingComposerUpload = (key: string) => {
|
||||||
|
setPendingComposerUploads((current) => current.filter((item) => item.key !== key));
|
||||||
|
};
|
||||||
|
|
||||||
|
const composerAttachmentStrip =
|
||||||
|
pendingComposerUploads.length > 0 || composerAttachments.length > 0 ? (
|
||||||
|
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
||||||
|
{pendingComposerUploads.map((upload) => (
|
||||||
|
<div
|
||||||
|
key={`pending:${upload.key}`}
|
||||||
|
className={`app-chat-panel__composer-attachment-chip app-chat-panel__composer-attachment-chip--pending${
|
||||||
|
upload.status === 'failed' ? ' app-chat-panel__composer-attachment-chip--failed' : ''
|
||||||
|
}`}
|
||||||
|
title={upload.status === 'failed' ? upload.reason ?? '업로드 실패' : undefined}
|
||||||
|
>
|
||||||
|
<span className="app-chat-panel__composer-attachment-name">{upload.name}</span>
|
||||||
|
<span className="app-chat-panel__composer-attachment-pending-label">
|
||||||
|
{upload.status === 'failed'
|
||||||
|
? upload.reason ?? '업로드 실패'
|
||||||
|
: upload.status === 'uploaded'
|
||||||
|
? '첨부 반영 중'
|
||||||
|
: '업로드 중'}
|
||||||
|
</span>
|
||||||
|
{upload.status === 'failed' ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="app-chat-panel__composer-attachment-remove"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
aria-label={`${upload.name} 업로드 실패 항목 닫기`}
|
||||||
|
onClick={() => {
|
||||||
|
dismissPendingComposerUpload(upload.key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{composerAttachments.map((attachment) => (
|
||||||
|
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
|
||||||
|
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="app-chat-panel__composer-attachment-remove"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
aria-label={`${attachment.name} 첨부 제거`}
|
||||||
|
onClick={() => {
|
||||||
|
onRemoveComposerAttachment(attachment.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
const composerPlaceholder = isComposerDisabled
|
||||||
|
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||||
|
: isMobileViewport
|
||||||
|
? '메시지를 입력하세요.'
|
||||||
|
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||||
|
|
||||||
const renderActivityCard = (message: ChatMessage) => {
|
const renderActivityCard = (message: ChatMessage) => {
|
||||||
const requestId = message.clientRequestId?.trim() || String(message.id);
|
const requestId = message.clientRequestId?.trim() || String(message.id);
|
||||||
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
|
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
|
||||||
@@ -1229,22 +1341,17 @@ export function ChatConversationView({
|
|||||||
</label>
|
</label>
|
||||||
<div className="app-chat-panel__resource-strip-list">
|
<div className="app-chat-panel__resource-strip-list">
|
||||||
{visiblePreviewItems.map((item) => (
|
{visiblePreviewItems.map((item) => (
|
||||||
<InlineMessagePreview
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
target={{
|
type="button"
|
||||||
label: item.label,
|
className="app-chat-panel__resource-chip"
|
||||||
url: item.url,
|
onClick={() => {
|
||||||
kind: normalizePreviewOptionKind(item.kind),
|
onOpenPreview(item.id);
|
||||||
}}
|
}}
|
||||||
isExpanded={expandedResourcePreviewKey === item.id}
|
>
|
||||||
hasModalPreview
|
<span title={item.label}>{item.label}</span>
|
||||||
onOpenModalPreview={() => {
|
<span>{item.kind}</span>
|
||||||
onOpenPreview(item.id, { fullscreen: true });
|
</button>
|
||||||
}}
|
|
||||||
onToggle={() => {
|
|
||||||
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1491,22 +1598,24 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
{activeSystemStatus ? (
|
||||||
<div
|
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||||||
className={`app-chat-panel__system-status${
|
<div
|
||||||
activeSystemStatus ? '' : ' app-chat-panel__system-status--hidden'
|
className={`app-chat-panel__system-status${
|
||||||
}${isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''}`}
|
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
||||||
>
|
}`}
|
||||||
<span>{activeSystemStatus ?? ''}</span>
|
>
|
||||||
{activeSystemStatus && isSystemStatusPending ? (
|
<span>{activeSystemStatus}</span>
|
||||||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
{isSystemStatusPending ? (
|
||||||
<span className="app-chat-panel__system-status-dot" />
|
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||||||
<span className="app-chat-panel__system-status-dot" />
|
<span className="app-chat-panel__system-status-dot" />
|
||||||
<span className="app-chat-panel__system-status-dot" />
|
<span className="app-chat-panel__system-status-dot" />
|
||||||
</div>
|
<span className="app-chat-panel__system-status-dot" />
|
||||||
) : null}
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{showScrollToBottom ? (
|
{showScrollToBottom ? (
|
||||||
<div className="app-chat-panel__scroll-jump">
|
<div className="app-chat-panel__scroll-jump">
|
||||||
@@ -1544,11 +1653,6 @@ export function ChatConversationView({
|
|||||||
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
|
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
|
||||||
onChange={onSelectChatType}
|
onChange={onSelectChatType}
|
||||||
/>
|
/>
|
||||||
{normalizedSelectedChatTypeLabel && normalizedSelectedChatTypeLabel !== '일반 요청' ? (
|
|
||||||
<Text type="secondary" className="app-chat-panel__composer-type-note">
|
|
||||||
현재 채팅유형: {normalizedSelectedChatTypeLabel}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="app-chat-panel__composer-actions">
|
<div className="app-chat-panel__composer-actions">
|
||||||
<div className="app-chat-panel__composer-action-buttons">
|
<div className="app-chat-panel__composer-action-buttons">
|
||||||
@@ -1578,6 +1682,8 @@ export function ChatConversationView({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{composerAttachmentStrip}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`app-chat-panel__composer-input-shell${
|
className={`app-chat-panel__composer-input-shell${
|
||||||
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
||||||
@@ -1616,12 +1722,8 @@ export function ChatConversationView({
|
|||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
value={draft}
|
value={draft}
|
||||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
autoSize={false}
|
||||||
placeholder={
|
placeholder={composerPlaceholder}
|
||||||
isComposerDisabled
|
|
||||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
|
||||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.'
|
|
||||||
}
|
|
||||||
disabled={isComposerDisabled}
|
disabled={isComposerDisabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onDraftChange(event.target.value);
|
onDraftChange(event.target.value);
|
||||||
@@ -1632,7 +1734,8 @@ export function ChatConversationView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.ctrlKey) {
|
const hasSubmitModifier = event.ctrlKey || event.metaKey;
|
||||||
|
if (!hasSubmitModifier) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1661,30 +1764,11 @@ export function ChatConversationView({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
|
accept="image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed"
|
||||||
className="app-chat-panel__composer-file-input"
|
className="app-chat-panel__composer-file-input"
|
||||||
onChange={handleComposerFileChange}
|
onChange={handleComposerFileChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{composerAttachments.length > 0 ? (
|
|
||||||
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
|
||||||
{composerAttachments.map((attachment) => (
|
|
||||||
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
|
|
||||||
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
className="app-chat-panel__composer-attachment-remove"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
aria-label={`${attachment.name} 첨부 제거`}
|
|
||||||
onClick={() => {
|
|
||||||
onRemoveComposerAttachment(attachment.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function resolveChatPreviewGlyph(kind: ChatPreviewKind) {
|
|||||||
return <FileTextOutlined />;
|
return <FileTextOutlined />;
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
return <FilePdfOutlined />;
|
return <FilePdfOutlined />;
|
||||||
|
case 'file':
|
||||||
|
return <DownloadOutlined />;
|
||||||
default:
|
default:
|
||||||
return <LinkOutlined />;
|
return <LinkOutlined />;
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,8 @@ export function resolveChatPreviewKindLabel(kind: ChatPreviewKind) {
|
|||||||
return 'document preview';
|
return 'document preview';
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
return 'pdf preview';
|
return 'pdf preview';
|
||||||
|
case 'file':
|
||||||
|
return 'file download';
|
||||||
default:
|
default:
|
||||||
return 'resource preview';
|
return 'resource preview';
|
||||||
}
|
}
|
||||||
@@ -322,6 +326,28 @@ export function ChatPreviewBody({
|
|||||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target.kind === 'file') {
|
||||||
|
return (
|
||||||
|
<div className="app-chat-panel__preview-file">
|
||||||
|
<Paragraph>
|
||||||
|
브라우저에서 바로 미리보기하지 않는 파일입니다. 아래 버튼으로 새 탭에서 열거나 다운로드할 수 있습니다.
|
||||||
|
</Paragraph>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
aria-label="다운로드"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||||
|
triggerResourceDownload(target.url, fileName);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (target.kind === 'markdown') {
|
if (target.kind === 'markdown') {
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons';
|
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons';
|
||||||
import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd';
|
import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { renderModalWithEnterConfirm } from '../modalKeyboard';
|
||||||
import {
|
import {
|
||||||
cancelChatRuntimeJob,
|
cancelChatRuntimeJob,
|
||||||
fetchChatRuntimeJobDetail,
|
fetchChatRuntimeJobDetail,
|
||||||
@@ -198,7 +199,7 @@ function RecentRuntimeList({
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<UndoOutlined />}
|
icon={<UndoOutlined />}
|
||||||
disabled={item.terminalStatus !== 'completed'}
|
disabled={item.terminalStatus !== 'completed' && item.terminalStatus !== 'failed'}
|
||||||
loading={pendingActionRequestId === item.requestId}
|
loading={pendingActionRequestId === item.requestId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRollbackJob(item.requestId, item.sessionId);
|
onRollbackJob(item.requestId, item.sessionId);
|
||||||
@@ -272,6 +273,8 @@ export function ChatRuntimeDashboard({
|
|||||||
content: options.content,
|
content: options.content,
|
||||||
okText: options.okText,
|
okText: options.okText,
|
||||||
cancelText: options.cancelText ?? '닫기',
|
cancelText: options.cancelText ?? '닫기',
|
||||||
|
autoFocusButton: 'ok',
|
||||||
|
modalRender: renderModalWithEnterConfirm,
|
||||||
onOk: () => resolve(true),
|
onOk: () => resolve(true),
|
||||||
onCancel: () => resolve(false),
|
onCancel: () => resolve(false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,20 +24,16 @@ 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_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
|
||||||
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
|
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
|
||||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||||
|
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||||
const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500;
|
|
||||||
const chatSessionLastTypeMemory = new Map<string, string>();
|
const chatSessionLastTypeMemory = new Map<string, string>();
|
||||||
const chatLastEventIdMemory = new Map<string, number>();
|
const chatLastEventIdMemory = new Map<string, number>();
|
||||||
const chatOfflineNotificationMemory = new Map<string, boolean>();
|
const chatOfflineNotificationMemory = new Map<string, boolean>();
|
||||||
let chatClientSessionIdMemory = '';
|
let chatClientSessionIdMemory = '';
|
||||||
let localMessageSequence = 0;
|
let localMessageSequence = 0;
|
||||||
let cachedChatConversationList: ChatConversationSummary[] | null = null;
|
|
||||||
let cachedChatConversationListAt = 0;
|
|
||||||
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;
|
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;
|
||||||
|
|
||||||
export function invalidateChatConversationListCache() {
|
export function invalidateChatConversationListCache() {
|
||||||
cachedChatConversationList = null;
|
|
||||||
cachedChatConversationListAt = 0;
|
|
||||||
chatConversationListRequestPromise = null;
|
chatConversationListRequestPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,6 +813,16 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
if (!headers.has('Cache-Control')) {
|
||||||
|
headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers.has('Pragma')) {
|
||||||
|
headers.set('Pragma', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -894,16 +900,35 @@ async function readFileAsBase64(file: File) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchChatConversations() {
|
const FALLBACK_UPLOAD_MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
const now = Date.now();
|
zip: 'application/zip',
|
||||||
|
heic: 'image/heic',
|
||||||
|
heif: 'image/heif',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
webp: 'image/webp',
|
||||||
|
gif: 'image/gif',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
function resolveUploadMimeType(file: File) {
|
||||||
cachedChatConversationList &&
|
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||||||
now - cachedChatConversationListAt < CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS
|
const extension = normalizedName.includes('.') ? normalizedName.split('.').pop()?.trim() ?? '' : '';
|
||||||
) {
|
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||||||
return cachedChatConversationList;
|
|
||||||
|
if (normalizedType && normalizedType !== 'application/octet-stream') {
|
||||||
|
return normalizedType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (extension && FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension]) {
|
||||||
|
return FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedType || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChatConversations() {
|
||||||
if (chatConversationListRequestPromise) {
|
if (chatConversationListRequestPromise) {
|
||||||
return chatConversationListRequestPromise;
|
return chatConversationListRequestPromise;
|
||||||
}
|
}
|
||||||
@@ -911,16 +936,12 @@ export async function fetchChatConversations() {
|
|||||||
const clientId = getOrCreateClientId();
|
const clientId = getOrCreateClientId();
|
||||||
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
|
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const items = sortChatConversationSummaries(
|
return sortChatConversationSummaries(
|
||||||
response.items.map((item) => ({
|
response.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
cachedChatConversationList = items;
|
|
||||||
cachedChatConversationListAt = Date.now();
|
|
||||||
return items;
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
chatConversationListRequestPromise = null;
|
chatConversationListRequestPromise = null;
|
||||||
@@ -1026,23 +1047,75 @@ export async function rollbackChatRuntimeJob(requestId: string, sessionId?: stri
|
|||||||
|
|
||||||
export async function uploadChatComposerFile(sessionId: string, file: File) {
|
export async function uploadChatComposerFile(sessionId: string, file: File) {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
const resolvedMimeType = resolveUploadMimeType(file);
|
||||||
|
const reportUploadFailure = async (stage: string, error: Error) => {
|
||||||
|
await reportClientError({
|
||||||
|
errorType: 'chat:composer-upload',
|
||||||
|
errorName: error.name,
|
||||||
|
errorMessage: error.message,
|
||||||
|
requestMethod: 'POST',
|
||||||
|
requestPath: '/api/chat/attachments',
|
||||||
|
context: {
|
||||||
|
stage,
|
||||||
|
sessionId: normalizedSessionId || null,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
fileType: file.type || null,
|
||||||
|
resolvedMimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!normalizedSessionId) {
|
if (!normalizedSessionId) {
|
||||||
throw new Error('채팅 세션이 준비되지 않았습니다.');
|
const uploadError = new Error('채팅 세션이 준비되지 않았습니다.');
|
||||||
|
await reportUploadFailure('validate-session', uploadError);
|
||||||
|
throw uploadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentBase64 = await readFileAsBase64(file);
|
if (file.size <= 0) {
|
||||||
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
|
const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.');
|
||||||
method: 'POST',
|
await reportUploadFailure('validate-file', uploadError);
|
||||||
body: JSON.stringify({
|
throw uploadError;
|
||||||
sessionId: normalizedSessionId,
|
}
|
||||||
fileName: file.name,
|
|
||||||
mimeType: file.type,
|
|
||||||
contentBase64,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.item;
|
if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) {
|
||||||
|
const uploadError = new Error(`첨부 파일은 10MB 이하만 업로드할 수 있습니다. (${file.name})`);
|
||||||
|
await reportUploadFailure('validate-file', uploadError);
|
||||||
|
throw uploadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentBase64 = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
contentBase64 = await readFileAsBase64(file);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error && error.message.trim() ? error.message.trim() : '파일 내용을 읽지 못했습니다.';
|
||||||
|
const uploadError = new Error(`${message} (${file.name})`);
|
||||||
|
uploadError.name = error instanceof Error && error.name ? error.name : 'FileReadError';
|
||||||
|
await reportUploadFailure('read-file', uploadError);
|
||||||
|
throw uploadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: normalizedSessionId,
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: resolvedMimeType,
|
||||||
|
contentBase64,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.item;
|
||||||
|
} catch (error) {
|
||||||
|
const uploadError =
|
||||||
|
error instanceof Error && error.message.trim()
|
||||||
|
? error
|
||||||
|
: new Error(`${file.name} 업로드에 실패했습니다.`);
|
||||||
|
await reportUploadFailure('upload-request', uploadError);
|
||||||
|
throw uploadError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createChatConversationRoom(args: {
|
export async function createChatConversationRoom(args: {
|
||||||
|
|||||||
131
src/app/main/mainChatPanel/previewItems.ts
Normal file
131
src/app/main/mainChatPanel/previewItems.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||||
|
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||||
|
import { extractHiddenPreviewUrls } from './previewMarkers';
|
||||||
|
import type { ChatMessage } from './types';
|
||||||
|
|
||||||
|
export type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
||||||
|
|
||||||
|
export type PreviewItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
kind: PreviewKind;
|
||||||
|
source: 'message' | 'context';
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePreviewUrl(value: string) {
|
||||||
|
return normalizeChatResourceUrl(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewRouteUrl(url: string) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
const pathname = parsed.pathname.toLowerCase();
|
||||||
|
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||||
|
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyPreviewKind(url: string): PreviewKind {
|
||||||
|
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||||||
|
|
||||||
|
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.(md|markdown)$/i.test(pathname)) {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.(diff|patch)$/i.test(pathname)) {
|
||||||
|
return 'diff';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
|
||||||
|
return 'code';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.(txt|log|csv)$/i.test(pathname)) {
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.pdf$/i.test(pathname)) {
|
||||||
|
return 'pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreviewRouteUrl(url)) {
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPreviewLabel(url: string, source: PreviewItem['source']) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1);
|
||||||
|
|
||||||
|
if (lastSegment) {
|
||||||
|
return source === 'context' ? `현재 화면 · ${lastSegment}` : lastSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source === 'context' ? '현재 화면 미리보기' : parsed.hostname;
|
||||||
|
} catch {
|
||||||
|
return source === 'context' ? '현재 화면 미리보기' : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
|
||||||
|
if (!item || item.kind !== 'code') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||||
|
const pathname = parsed.pathname.toLowerCase();
|
||||||
|
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||||
|
} catch {
|
||||||
|
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
|
||||||
|
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPreviewItems(messages: ChatMessage[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: PreviewItem[] = [];
|
||||||
|
const orderedMessages = [...messages].reverse();
|
||||||
|
|
||||||
|
orderedMessages.forEach((message) => {
|
||||||
|
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
||||||
|
|
||||||
|
matches.forEach((matchedUrl) => {
|
||||||
|
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||||
|
const kind = classifyPreviewKind(normalizedUrl);
|
||||||
|
|
||||||
|
if (seen.has(normalizedUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(normalizedUrl);
|
||||||
|
items.push({
|
||||||
|
id: `${message.id}-${normalizedUrl}`,
|
||||||
|
label: buildPreviewLabel(normalizedUrl, 'message'),
|
||||||
|
url: normalizedUrl,
|
||||||
|
kind,
|
||||||
|
source: 'message',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.slice(0, 12);
|
||||||
|
}
|
||||||
37
src/app/main/modalKeyboard.tsx
Normal file
37
src/app/main/modalKeyboard.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
function isInteractiveTarget(target: EventTarget | null) {
|
||||||
|
return target instanceof HTMLElement
|
||||||
|
? Boolean(target.closest('button, a, input, textarea, select, [role="button"], [contenteditable="true"]'))
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderModalWithEnterConfirm(node: React.ReactNode) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const target = event.target;
|
||||||
|
const shouldIgnoreInteractiveTarget =
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
event.currentTarget.contains(target) &&
|
||||||
|
isInteractiveTarget(target);
|
||||||
|
|
||||||
|
if (event.key !== 'Enter' || event.nativeEvent.isComposing || shouldIgnoreInteractiveTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const okButton = event.currentTarget.querySelector<HTMLButtonElement>('.ant-modal-footer .ant-btn-primary');
|
||||||
|
|
||||||
|
if (!okButton || okButton.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
okButton.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import { resolveSavedLayoutIdFromMenuKey } from '../routes';
|
|||||||
export function PlayPage() {
|
export function PlayPage() {
|
||||||
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
|
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
|
||||||
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
||||||
|
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-main-panel app-main-panel--play">
|
<div className={panelClassName}>
|
||||||
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
|
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
|
||||||
{selectedSavedLayoutId ? (
|
{selectedSavedLayoutId ? (
|
||||||
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
|
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ReactNode } from 'react';
|
|||||||
import type { PlanFilterStatus } from '../../features/planBoard';
|
import type { PlanFilterStatus } from '../../features/planBoard';
|
||||||
|
|
||||||
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
|
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
|
||||||
export type HeaderTopMenuKey = 'docs' | 'plans';
|
export type HeaderTopMenuKey = 'docs' | 'plans' | 'play';
|
||||||
export type ApiSectionKey = 'components' | 'widgets';
|
export type ApiSectionKey = 'components' | 'widgets';
|
||||||
export type PlanSectionKey =
|
export type PlanSectionKey =
|
||||||
| PlanFilterStatus
|
| PlanFilterStatus
|
||||||
@@ -385,6 +385,10 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
|
|||||||
return buildDocsPath(currentDocsFolder);
|
return buildDocsPath(currentDocsFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (menu === 'play') {
|
||||||
|
return buildPlayPath('layout');
|
||||||
|
}
|
||||||
|
|
||||||
return buildPlansPath('all');
|
return buildPlansPath('all');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Empty } from 'antd';
|
import { Empty } from 'antd';
|
||||||
import { CodexDiffPreviewer } from '../previewer';
|
import { CodexDiffPreviewer } from '../previewer';
|
||||||
import type { CodexDiffPreviewerFile, CodexDiffPreviewerFileStatus } from '../previewer';
|
import type { CodexDiffPreviewerFile, CodexDiffPreviewerFileStatus } from '../previewer';
|
||||||
|
|
||||||
|
type RawTextModule = () => Promise<string | { default: string }>;
|
||||||
|
|
||||||
const repoTextModules = {
|
const repoTextModules = {
|
||||||
...import.meta.glob('/src/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
|
...import.meta.glob('/src/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
|
||||||
eager: true,
|
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
import: 'default',
|
import: 'default',
|
||||||
}),
|
}),
|
||||||
...import.meta.glob('/docs/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
|
...import.meta.glob('/docs/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
|
||||||
eager: true,
|
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
import: 'default',
|
import: 'default',
|
||||||
}),
|
}),
|
||||||
...import.meta.glob('/scripts/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
|
...import.meta.glob('/scripts/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
|
||||||
eager: true,
|
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
import: 'default',
|
import: 'default',
|
||||||
}),
|
}),
|
||||||
...import.meta.glob('/etc/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
|
...import.meta.glob('/etc/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
|
||||||
eager: true,
|
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
import: 'default',
|
import: 'default',
|
||||||
}),
|
}),
|
||||||
...import.meta.glob('/{README.md,package.json,package-lock.json,docker-compose.yml}', {
|
...import.meta.glob('/{README.md,package.json,package-lock.json,docker-compose.yml}', {
|
||||||
eager: true,
|
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
import: 'default',
|
import: 'default',
|
||||||
}),
|
}),
|
||||||
} as Record<string, string>;
|
} as Record<string, RawTextModule>;
|
||||||
|
|
||||||
const repoImageModules = {
|
const repoImageModules = {
|
||||||
...import.meta.glob('/src/**/*.{png,jpg,jpeg,gif,webp,svg,avif}', {
|
...import.meta.glob('/src/**/*.{png,jpg,jpeg,gif,webp,svg,avif}', {
|
||||||
@@ -183,12 +181,20 @@ function parseRawDiffText(sourceSectionContent: string) {
|
|||||||
return matches.map((match) => match[1].trimEnd()).filter(Boolean).join('\n\n');
|
return matches.map((match) => match[1].trimEnd()).filter(Boolean).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreviewFiles(entries: ParsedChangedFile[]): CodexDiffPreviewerFile[] {
|
function normalizeRawTextModuleValue(value: string | { default: string }) {
|
||||||
|
return typeof value === 'string' ? value : value.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewFiles(
|
||||||
|
entries: ParsedChangedFile[],
|
||||||
|
loadedTextByPath: Record<string, string | undefined>,
|
||||||
|
): CodexDiffPreviewerFile[] {
|
||||||
return entries.map((entry) => {
|
return entries.map((entry) => {
|
||||||
const normalizedPath = `/${entry.path}`;
|
const normalizedPath = `/${entry.path}`;
|
||||||
const isPublicFile = entry.path.startsWith('public/');
|
const isPublicFile = entry.path.startsWith('public/');
|
||||||
const publicAssetUrl = isPublicFile ? `/${entry.path.slice('public/'.length)}` : null;
|
const publicAssetUrl = isPublicFile ? `/${entry.path.slice('public/'.length)}` : null;
|
||||||
const rawContent = repoTextModules[normalizedPath];
|
const canLoadRawContent = Boolean(repoTextModules[normalizedPath]);
|
||||||
|
const rawContent = loadedTextByPath[normalizedPath];
|
||||||
const imageUrl = repoImageModules[normalizedPath] ?? (IMAGE_FILE_PATTERN.test(entry.path) ? publicAssetUrl : null);
|
const imageUrl = repoImageModules[normalizedPath] ?? (IMAGE_FILE_PATTERN.test(entry.path) ? publicAssetUrl : null);
|
||||||
const isBinary = entry.status === 'binary' || BINARY_FILE_PATTERN.test(entry.path);
|
const isBinary = entry.status === 'binary' || BINARY_FILE_PATTERN.test(entry.path);
|
||||||
const isImage = IMAGE_FILE_PATTERN.test(entry.path);
|
const isImage = IMAGE_FILE_PATTERN.test(entry.path);
|
||||||
@@ -211,11 +217,13 @@ function buildPreviewFiles(entries: ParsedChangedFile[]): CodexDiffPreviewerFile
|
|||||||
: isPublicFile
|
: isPublicFile
|
||||||
? 'public 디렉터리 파일은 번들 import 없이 정적 URL로만 제공되어 전체 소스 미리보기를 표시하지 않습니다.'
|
? 'public 디렉터리 파일은 번들 import 없이 정적 URL로만 제공되어 전체 소스 미리보기를 표시하지 않습니다.'
|
||||||
: rawContent ??
|
: rawContent ??
|
||||||
(entry.status === 'deleted'
|
(canLoadRawContent
|
||||||
? '삭제된 파일이라 현재 저장소에서 전체 소스를 불러올 수 없습니다.'
|
? '파일 내용을 불러오는 중입니다.'
|
||||||
: isImage
|
: entry.status === 'deleted'
|
||||||
? '이미지 파일 URL을 현재 저장소에서 찾지 못했습니다.'
|
? '삭제된 파일이라 현재 저장소에서 전체 소스를 불러올 수 없습니다.'
|
||||||
: '현재 저장소에서 파일 내용을 불러올 수 없습니다.'),
|
: isImage
|
||||||
|
? '이미지 파일 URL을 현재 저장소에서 찾지 못했습니다.'
|
||||||
|
: '현재 저장소에서 파일 내용을 불러올 수 없습니다.'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -238,14 +246,61 @@ export function WorklogSourcePreview({
|
|||||||
sourceSectionContent,
|
sourceSectionContent,
|
||||||
filesSectionContent,
|
filesSectionContent,
|
||||||
}: WorklogSourcePreviewProps) {
|
}: WorklogSourcePreviewProps) {
|
||||||
const diffText = parseRawDiffText(sourceSectionContent);
|
const diffText = useMemo(() => parseRawDiffText(sourceSectionContent), [sourceSectionContent]);
|
||||||
const changedFiles = parseChangedFiles(filesSectionContent);
|
const sourcePaths = useMemo(() => {
|
||||||
const sourcePaths = mergeSourceEntries(changedFiles, parseSourcePaths(sourceSectionContent));
|
const changedFiles = parseChangedFiles(filesSectionContent);
|
||||||
const files = buildPreviewFiles(sourcePaths);
|
return mergeSourceEntries(changedFiles, parseSourcePaths(sourceSectionContent));
|
||||||
|
}, [filesSectionContent, sourceSectionContent]);
|
||||||
|
const [loadedTextByPath, setLoadedTextByPath] = useState<Record<string, string | undefined>>({});
|
||||||
|
const files = useMemo(() => buildPreviewFiles(sourcePaths, loadedTextByPath), [loadedTextByPath, sourcePaths]);
|
||||||
const description = diffText
|
const description = diffText
|
||||||
? '변경 파일 기준 전체 소스와 raw diff를 Codex preview 스타일로 전환해 표시합니다.'
|
? '변경 파일 기준 전체 소스와 raw diff를 Codex preview 스타일로 전환해 표시합니다.'
|
||||||
: '변경 파일 기준 전체 소스를 Codex preview 스타일로 표시합니다. raw diff는 작업일지 `## 소스` 섹션의 diff 코드블록을 그대로 사용합니다.';
|
: '변경 파일 기준 전체 소스를 Codex preview 스타일로 표시합니다. raw diff는 작업일지 `## 소스` 섹션의 diff 코드블록을 그대로 사용합니다.';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
const textModuleEntries = sourcePaths
|
||||||
|
.map((entry) => `/${entry.path}`)
|
||||||
|
.filter((path) => repoTextModules[path] && loadedTextByPath[path] === undefined);
|
||||||
|
|
||||||
|
if (!textModuleEntries.length) {
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
textModuleEntries.map(async (path) => {
|
||||||
|
const value = await repoTextModules[path]();
|
||||||
|
return [path, normalizeRawTextModuleValue(value)] as const;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((loadedEntries) => {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadedTextByPath((current) => ({
|
||||||
|
...current,
|
||||||
|
...Object.fromEntries(loadedEntries),
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadedTextByPath((current) => ({
|
||||||
|
...current,
|
||||||
|
...Object.fromEntries(textModuleEntries.map((path) => [path, '현재 저장소에서 파일 내용을 불러올 수 없습니다.'])),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [loadedTextByPath, sourcePaths]);
|
||||||
|
|
||||||
if (!files.length && !diffText) {
|
if (!files.length && !diffText) {
|
||||||
return <Empty description="기록된 소스 미리보기가 없습니다." />;
|
return <Empty description="기록된 소스 미리보기가 없습니다." />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ type PlanNoteResource = {
|
|||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
previewType: 'image' | 'document' | 'link';
|
previewType: 'image' | 'document' | 'link';
|
||||||
|
isProbablyOpenable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
|
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
|
||||||
@@ -242,6 +243,22 @@ function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResourc
|
|||||||
return 'link';
|
return 'link';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProbablyOpenablePlanNoteResource(sourcePath: string) {
|
||||||
|
const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, '');
|
||||||
|
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = getPlanNoteResourceBaseName(normalized).trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!baseName || baseName === 'resource' || baseName === 'uploads' || baseName === '.codex_chat') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseName.includes('.');
|
||||||
|
}
|
||||||
|
|
||||||
function extractPlanNoteResources(note: string) {
|
function extractPlanNoteResources(note: string) {
|
||||||
const normalizedNote = String(note ?? '');
|
const normalizedNote = String(note ?? '');
|
||||||
const lineEntries = normalizedNote
|
const lineEntries = normalizedNote
|
||||||
@@ -283,6 +300,7 @@ function extractPlanNoteResources(note: string) {
|
|||||||
sourcePath: item.sourcePath,
|
sourcePath: item.sourcePath,
|
||||||
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
|
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
|
||||||
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
|
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
|
||||||
|
isProbablyOpenable: isProbablyOpenablePlanNoteResource(item.sourcePath),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,14 +519,36 @@ function ExpandableDetailText({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
|
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
|
||||||
|
const [hideInvalidResources, setHideInvalidResources] = useState(true);
|
||||||
|
const visibleResources = useMemo(
|
||||||
|
() => resources.filter((resource) => !hideInvalidResources || resource.isProbablyOpenable),
|
||||||
|
[hideInvalidResources, resources],
|
||||||
|
);
|
||||||
|
const hiddenResourceCount = resources.length - visibleResources.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-board-page__note-resources">
|
<div className="plan-board-page__note-resources">
|
||||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||||
<Text strong>첨부 리소스</Text>
|
<Space size={8} wrap>
|
||||||
<Text type="secondary">{resources.length}건</Text>
|
<Text strong>첨부 리소스</Text>
|
||||||
|
<Text type="secondary">{visibleResources.length}건</Text>
|
||||||
|
{hiddenResourceCount > 0 ? (
|
||||||
|
<Text type="secondary" className="plan-board-page__note-resource-summary">
|
||||||
|
숨김 {hiddenResourceCount}건
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Checkbox
|
||||||
|
checked={hideInvalidResources}
|
||||||
|
onChange={(event) => {
|
||||||
|
setHideInvalidResources(event.target.checked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
열리지 않는 리소스 제외
|
||||||
|
</Checkbox>
|
||||||
</Flex>
|
</Flex>
|
||||||
<div className="plan-board-page__note-resource-list">
|
<div className="plan-board-page__note-resource-list">
|
||||||
{resources.map((resource) => (
|
{visibleResources.map((resource) => (
|
||||||
<div key={resource.id} className="plan-board-page__note-resource-card">
|
<div key={resource.id} className="plan-board-page__note-resource-card">
|
||||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||||
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
|
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
|
||||||
@@ -549,6 +589,9 @@ function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] })
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{visibleResources.length === 0 ? (
|
||||||
|
<Text type="secondary">표시할 수 있는 리소스가 없습니다.</Text>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ function PlanScheduleDetail({
|
|||||||
onCopyText: (text: string) => Promise<void>;
|
onCopyText: (text: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
<div className="plan-schedule-page__detail">
|
||||||
{selectedItem ? (
|
{selectedItem ? (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
@@ -865,6 +865,6 @@ function PlanScheduleDetail({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
.plan-schedule-page {
|
.plan-schedule-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-schedule-page__overview,
|
.plan-schedule-page__overview,
|
||||||
.plan-schedule-page__list-card,
|
.plan-schedule-page__list-card,
|
||||||
.plan-schedule-page__editor-card {
|
.plan-schedule-page__editor-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -15,9 +20,12 @@
|
|||||||
|
|
||||||
.plan-schedule-page__split {
|
.plan-schedule-page__split {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-schedule-page__split--stacked {
|
.plan-schedule-page__split--stacked {
|
||||||
@@ -27,7 +35,12 @@
|
|||||||
.plan-schedule-page__list-card .ant-card-body,
|
.plan-schedule-page__list-card .ant-card-body,
|
||||||
.plan-schedule-page__editor-card .ant-card-body,
|
.plan-schedule-page__editor-card .ant-card-body,
|
||||||
.plan-schedule-page__detail-card .ant-card-body {
|
.plan-schedule-page__detail-card .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-schedule-page__detail-actions.ant-space {
|
.plan-schedule-page__detail-actions.ant-space {
|
||||||
@@ -55,8 +68,14 @@
|
|||||||
|
|
||||||
.plan-schedule-page__list {
|
.plan-schedule-page__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-schedule-page__list-item {
|
.plan-schedule-page__list-item {
|
||||||
@@ -92,9 +111,22 @@
|
|||||||
.plan-schedule-page__form {
|
.plan-schedule-page__form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-schedule-page__detail {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-schedule-page__form > div {
|
.plan-schedule-page__form > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -146,6 +178,7 @@
|
|||||||
|
|
||||||
.plan-schedule-page__notepad.ant-input {
|
.plan-schedule-page__notepad.ant-input {
|
||||||
padding: 20px 18px;
|
padding: 20px 18px;
|
||||||
|
padding-bottom: 26px;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(22, 93, 255, 0.1);
|
border: 1px solid rgba(22, 93, 255, 0.1);
|
||||||
@@ -160,9 +193,10 @@
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
||||||
0 18px 40px rgba(23, 61, 130, 0.06);
|
0 18px 40px rgba(23, 61, 130, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
resize: vertical;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-schedule-page__notepad.ant-input:focus,
|
.plan-schedule-page__notepad.ant-input:focus,
|
||||||
@@ -175,6 +209,8 @@
|
|||||||
|
|
||||||
.plan-schedule-page__notepad-frame {
|
.plan-schedule-page__notepad-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-schedule-page__notepad-frame .ant-input-textarea,
|
.plan-schedule-page__notepad-frame .ant-input-textarea,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(245, 158, 11, 0.18);
|
border: 1px solid rgba(245, 158, 11, 0.18);
|
||||||
@@ -90,14 +91,20 @@
|
|||||||
|
|
||||||
.text-memo-widget__input.ant-input,
|
.text-memo-widget__input.ant-input,
|
||||||
.text-memo-widget__editor .ant-input {
|
.text-memo-widget__editor .ant-input {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 10px 18px 20px;
|
padding: 10px 18px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
color: #3f3a2f;
|
color: #3f3a2f;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 38px;
|
line-height: 38px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-memo-widget__editor .ant-input::placeholder {
|
.text-memo-widget__editor .ant-input::placeholder {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CheckOutlined, DeleteOutlined, EditOutlined, LeftOutlined, PlusOutlined, RightOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
import { CheckOutlined, DeleteOutlined, EditOutlined, LeftOutlined, PlusOutlined, RightOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||||
import { Button, Empty, Input, Modal, message } from 'antd';
|
import { Button, Empty, Input, Modal, message } from 'antd';
|
||||||
import { forwardRef, useEffect, useMemo, useState } from 'react';
|
import { forwardRef, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { renderModalWithEnterConfirm } from '../../app/main/modalKeyboard';
|
||||||
import { WidgetShell } from '../core';
|
import { WidgetShell } from '../core';
|
||||||
import type { WidgetHandle } from '../core';
|
import type { WidgetHandle } from '../core';
|
||||||
import './TextMemoWidget.css';
|
import './TextMemoWidget.css';
|
||||||
@@ -176,6 +177,8 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
: '삭제한 메모는 다시 복구할 수 없습니다.',
|
: '삭제한 메모는 다시 복구할 수 없습니다.',
|
||||||
okText: '삭제',
|
okText: '삭제',
|
||||||
cancelText: '취소',
|
cancelText: '취소',
|
||||||
|
autoFocusButton: 'ok',
|
||||||
|
modalRender: renderModalWithEnterConfirm,
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
if (isDraftOnly) {
|
if (isDraftOnly) {
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ const VITE_EMPTY_OUT_DIR = processEnv.VITE_EMPTY_OUT_DIR !== 'false';
|
|||||||
const VITE_FILTER_PUBLIC_DIR = processEnv.VITE_FILTER_PUBLIC_DIR === 'true';
|
const VITE_FILTER_PUBLIC_DIR = processEnv.VITE_FILTER_PUBLIC_DIR === 'true';
|
||||||
const VITE_DISABLE_MODULE_PRELOAD = processEnv.VITE_DISABLE_MODULE_PRELOAD === 'true';
|
const VITE_DISABLE_MODULE_PRELOAD = processEnv.VITE_DISABLE_MODULE_PRELOAD === 'true';
|
||||||
const VITE_DISABLE_PWA = processEnv.VITE_DISABLE_PWA === 'true';
|
const VITE_DISABLE_PWA = processEnv.VITE_DISABLE_PWA === 'true';
|
||||||
|
const WORK_SERVER_HTTP_TARGET = processEnv.WORK_SERVER_URL?.trim() || 'http://work-server:3100';
|
||||||
|
const WORK_SERVER_WS_TARGET = (() => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(WORK_SERVER_HTTP_TARGET);
|
||||||
|
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return WORK_SERVER_HTTP_TARGET.replace(/^http/i, 'ws');
|
||||||
|
}
|
||||||
|
})();
|
||||||
const ROOT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
const ROOT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
function shouldIgnoreDevUpdatePath(watchedPath: string) {
|
function shouldIgnoreDevUpdatePath(watchedPath: string) {
|
||||||
@@ -150,16 +160,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://work-server:3100',
|
target: WORK_SERVER_HTTP_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ws/chat': {
|
'/ws/chat': {
|
||||||
target: 'ws://work-server:3100',
|
target: WORK_SERVER_WS_TARGET,
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/.codex_chat': {
|
'/.codex_chat': {
|
||||||
target: 'http://work-server:3100',
|
target: WORK_SERVER_HTTP_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user