6418 lines
211 KiB
TypeScript
6418 lines
211 KiB
TypeScript
import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
import type { IncomingMessage } from 'node:http';
|
|
import type { Socket } from 'node:net';
|
|
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
|
|
import { env } from '../config/env.js';
|
|
import { db } from '../db/client.js';
|
|
import {
|
|
createDefaultChatTypeExecutionPolicy,
|
|
getAppConfigSnapshot,
|
|
getChatContextSettingsConfig,
|
|
getChatTypesConfig,
|
|
type ChatTypeExecutionPolicy,
|
|
} from './app-config-service.js';
|
|
import { BOARD_POSTS_TABLE } from './board-service.js';
|
|
import {
|
|
appendChatConversationMessage,
|
|
appendChatConversationActivityLine,
|
|
getChatConversationRequest,
|
|
type ChatConversationRequestItem,
|
|
type ChatConversationRequestUsageSnapshot,
|
|
getChatConversation,
|
|
listChatConversationOfflineNotificationClientIds,
|
|
listRecoverableChatConversationRequests,
|
|
listChatConversationMessages,
|
|
listChatConversationRequests,
|
|
shouldClearConversationJobState,
|
|
upsertChatConversationRequest,
|
|
updateChatConversationJobState,
|
|
updateChatConversationContext,
|
|
} from './chat-room-service.js';
|
|
import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js';
|
|
import { hasErrorLogViewAccessToken } from './error-log-service.js';
|
|
import {
|
|
getSharedResourceTokenDetailBySharePath,
|
|
validateSharedResourceAccessPinBySharePath,
|
|
} from './shared-resource-token-service.js';
|
|
import { sendNotifications } from './notification-service.js';
|
|
import { createNotificationMessage } from './notification-message-service.js';
|
|
import {
|
|
subscribeNotificationMessageChanges,
|
|
type NotificationMessageChangeEvent,
|
|
} from './notification-message-service.js';
|
|
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
|
import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js';
|
|
import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js';
|
|
import {
|
|
findLatestPlanItem,
|
|
findPlanItemByPreviewUrl,
|
|
findPlanItemByWorkId,
|
|
getPlanItemById,
|
|
listPlanActionHistories,
|
|
listPlanIssueHistories,
|
|
listPlanSourceWorkHistories,
|
|
mapPlanActionRow,
|
|
mapPlanIssueRow,
|
|
mapPlanSourceWorkRow,
|
|
PLAN_TABLE,
|
|
} from './plan-service.js';
|
|
|
|
type ChatAuthor = 'codex' | 'system' | 'user';
|
|
type CodexParticipantRole = 'default' | 'moderator' | 'conversation' | 'reviewer';
|
|
type CodexParticipantTurn = 'standard' | 'opening' | 'discussion' | 'review' | 'closing';
|
|
type ChatRequestMode = 'queue' | 'direct';
|
|
|
|
type ChatMessage = {
|
|
id: number;
|
|
author: ChatAuthor;
|
|
text: string;
|
|
timestamp: string;
|
|
clientRequestId?: string | null;
|
|
parts?: ChatMessagePart[];
|
|
};
|
|
|
|
type ChatContext = {
|
|
pageId: string | null;
|
|
pageTitle: string;
|
|
topMenu: string;
|
|
focusedComponentId: string | null;
|
|
pageUrl: string;
|
|
appOrigin?: string;
|
|
appDomain?: string;
|
|
isStandaloneMode?: boolean;
|
|
pageVisibilityState?: 'visible' | 'hidden';
|
|
pageFocusState?: 'focused' | 'blurred';
|
|
codexModel?: string | null;
|
|
codexParticipants?: Array<{
|
|
id?: string;
|
|
name?: string;
|
|
model?: string;
|
|
prompt?: string;
|
|
chatTypeId?: string | null;
|
|
defaultContextIds?: string[];
|
|
role?: CodexParticipantRole | null;
|
|
}>;
|
|
chatTypeId?: string | null;
|
|
chatTypeLabel?: string;
|
|
chatTypeDescription?: string;
|
|
chatTypeBaseDescription?: string;
|
|
chatTypeExecutionPolicy?: ChatTypeExecutionPolicy;
|
|
defaultContextIds?: string[];
|
|
defaultContexts?: Array<{
|
|
id?: string;
|
|
title?: string;
|
|
content?: string;
|
|
}>;
|
|
customContextTitle?: string | null;
|
|
customContextContent?: string | null;
|
|
};
|
|
|
|
type ChatPromptContextRef = {
|
|
key: 'prompt_parent_question';
|
|
promptTitle: string;
|
|
promptDescription?: string | null;
|
|
parentQuestionText?: string | null;
|
|
};
|
|
|
|
type ChatInboundMessage =
|
|
| {
|
|
type: 'context:update';
|
|
payload: ChatContext;
|
|
}
|
|
| {
|
|
type: 'presence:ping';
|
|
payload?: {
|
|
at?: number;
|
|
};
|
|
}
|
|
| {
|
|
type: 'event:received';
|
|
payload: {
|
|
eventId: number;
|
|
};
|
|
}
|
|
| {
|
|
type: 'message:send';
|
|
payload: {
|
|
sessionId?: string;
|
|
text: string;
|
|
requestId?: string;
|
|
mode?: 'queue' | 'direct';
|
|
requestOrigin?: 'composer' | 'prompt';
|
|
sharedResourceTokenId?: string | null;
|
|
parentRequestId?: string | null;
|
|
promptContextRef?: ChatPromptContextRef | null;
|
|
omitPromptHistory?: boolean;
|
|
codexModel?: string | null;
|
|
codexParticipants?: Array<{
|
|
id?: string;
|
|
name?: string;
|
|
model?: string;
|
|
prompt?: string;
|
|
chatTypeId?: string | null;
|
|
defaultContextIds?: string[];
|
|
role?: CodexParticipantRole | null;
|
|
}>;
|
|
chatTypeId?: string | null;
|
|
chatTypeLabel?: string;
|
|
chatTypeDescription?: string;
|
|
chatTypeBaseDescription?: string;
|
|
chatTypeExecutionPolicy?: ChatTypeExecutionPolicy;
|
|
defaultContextIds?: string[];
|
|
defaultContexts?: Array<{
|
|
id?: string;
|
|
title?: string;
|
|
content?: string;
|
|
}>;
|
|
customContextTitle?: string | null;
|
|
customContextContent?: string | null;
|
|
};
|
|
}
|
|
| {
|
|
type: 'runtime:watch';
|
|
payload: {
|
|
requestId?: string | null;
|
|
};
|
|
};
|
|
|
|
type ChatOutboundPayload =
|
|
| {
|
|
type: 'chat:init';
|
|
payload: {
|
|
messages: ChatMessage[];
|
|
};
|
|
}
|
|
| {
|
|
type: 'chat:message';
|
|
payload: ChatMessage;
|
|
}
|
|
| {
|
|
type: 'chat:message:update';
|
|
payload: ChatMessage;
|
|
}
|
|
| {
|
|
type: 'chat:status';
|
|
payload: {
|
|
connectedAt: string;
|
|
};
|
|
}
|
|
| {
|
|
type: 'chat:job';
|
|
payload: {
|
|
requestId: string;
|
|
status: 'queued' | 'started' | 'completed' | 'failed';
|
|
mode: 'queue' | 'direct';
|
|
queueSize: number;
|
|
message: string;
|
|
};
|
|
}
|
|
| {
|
|
type: 'chat:error';
|
|
payload: {
|
|
message: string;
|
|
};
|
|
}
|
|
| {
|
|
type: 'chat:runtime';
|
|
payload: ChatRuntimeSnapshot;
|
|
}
|
|
| {
|
|
type: 'chat:runtime:detail';
|
|
payload: ChatRuntimeJobDetail;
|
|
}
|
|
| {
|
|
type: 'chat:activity';
|
|
payload: {
|
|
requestId: string;
|
|
line: string;
|
|
lineCount: number;
|
|
lineNo?: number;
|
|
};
|
|
}
|
|
| {
|
|
type: 'chat:request:update';
|
|
payload: ChatConversationRequestItem;
|
|
}
|
|
| {
|
|
type: 'notification:messages-updated';
|
|
payload:
|
|
| {
|
|
action: 'created' | 'updated';
|
|
itemId: number;
|
|
category: string;
|
|
read: boolean;
|
|
}
|
|
| {
|
|
action: 'deleted';
|
|
itemId: number;
|
|
};
|
|
};
|
|
|
|
type ChatOutboundMessage = ChatOutboundPayload & {
|
|
eventId: number;
|
|
sessionId: string;
|
|
};
|
|
|
|
type ChatSessionState = {
|
|
sessionId: string;
|
|
clientId: string | null;
|
|
sockets: Set<WebSocket>;
|
|
lastSeenAt: number;
|
|
isDeleted: boolean;
|
|
context: ChatContext | null;
|
|
queue: Array<{
|
|
requestId: string;
|
|
text: string;
|
|
mode: 'queue' | 'direct';
|
|
requestedAtMs: number;
|
|
requestOrigin?: 'composer' | 'prompt';
|
|
sharedResourceTokenId?: string | null;
|
|
parentRequestId?: string | null;
|
|
omitPromptHistory?: boolean;
|
|
context: ChatContext | null;
|
|
}>;
|
|
activeRequestCount: number;
|
|
pendingQueueReleaseEventId: number | null;
|
|
pendingQueueReleaseTimer: ReturnType<typeof setTimeout> | null;
|
|
nextEventId: number;
|
|
eventHistory: ChatOutboundMessage[];
|
|
messagePersistenceTail: Promise<void>;
|
|
watchedRuntimeRequestId: string | null;
|
|
};
|
|
|
|
type ChatRuntimeController = {
|
|
getJobDetail: (requestId: string) => ReturnType<typeof chatRuntimeService.getJobDetail>;
|
|
cancelJob: (requestId: string) => Promise<boolean>;
|
|
removeQueuedJob: (requestId: string) => Promise<boolean>;
|
|
};
|
|
|
|
type ActiveChatExecution = {
|
|
cancel: () => Promise<boolean> | boolean;
|
|
};
|
|
|
|
let activeRuntimeController: ChatRuntimeController | null = null;
|
|
let activeChatService: ChatService | null = null;
|
|
const activeChatProcessRegistry = new Map<string, ActiveChatExecution>();
|
|
const QUEUE_RELEASE_FALLBACK_DELAY_MS = 1500;
|
|
|
|
export function getChatRuntimeController() {
|
|
return activeRuntimeController;
|
|
}
|
|
|
|
export function getActiveChatService() {
|
|
return activeChatService;
|
|
}
|
|
|
|
type ChatServiceRuntimeSnapshot = {
|
|
activeRequestCount: number;
|
|
queuedRequestCount: number;
|
|
connectedSessionCount: number;
|
|
activeSocketCount: number;
|
|
canAcceptNewRequests: boolean;
|
|
};
|
|
|
|
function getSessionSocketReadyState(session: ChatSessionState) {
|
|
for (const socket of session.sockets) {
|
|
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
|
|
return socket.readyState;
|
|
}
|
|
}
|
|
|
|
for (const socket of session.sockets) {
|
|
return socket.readyState;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function hasOpenSessionSocket(session: ChatSessionState) {
|
|
for (const socket of session.sockets) {
|
|
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const SOCKET_PATH = '/ws/chat';
|
|
const KST_TIME_ZONE = 'Asia/Seoul';
|
|
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
|
const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat';
|
|
const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource';
|
|
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
|
const CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH = 'source/chat-room-reference.md';
|
|
const CHAT_SESSION_REFERENCE_AUTO_START = '<!-- codex-live:auto:start -->';
|
|
const CHAT_SESSION_REFERENCE_AUTO_END = '<!-- codex-live:auto:end -->';
|
|
const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777;
|
|
const SOCKET_READY_STATE_OPEN = 1;
|
|
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
|
const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240;
|
|
const MAX_CHAT_ACTIVITY_MESSAGE_CHARS = 80_000;
|
|
const CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS = 15_000;
|
|
const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12;
|
|
const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200;
|
|
const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400;
|
|
let chatMessageSequence = 0;
|
|
|
|
export function isChatClientActivelyViewing(clientId: string | null | undefined, sessions: Iterable<ChatSessionState>) {
|
|
return evaluateChatClientActiveViewing(clientId, sessions).isActive;
|
|
}
|
|
|
|
function evaluateChatClientActiveViewing(clientId: string | null | undefined, sessions: Iterable<ChatSessionState>, nowMs = Date.now()) {
|
|
const normalizedClientId = clientId?.trim();
|
|
|
|
if (!normalizedClientId) {
|
|
return {
|
|
isActive: false,
|
|
matchedSessions: [],
|
|
};
|
|
}
|
|
|
|
const matchedSessions: Array<{
|
|
sessionId: string;
|
|
lastSeenAt: number;
|
|
presenceAgeMs: number;
|
|
socketReadyState: number | null;
|
|
pageVisibilityState: 'visible' | 'hidden';
|
|
pageFocusState: 'focused' | 'blurred';
|
|
pageOrigin: string | null;
|
|
reason:
|
|
| 'socket-not-open'
|
|
| 'stale-presence'
|
|
| 'backgrounded'
|
|
| 'active-no-page-url'
|
|
| 'active-supported-origin'
|
|
| 'unsupported-origin'
|
|
| 'invalid-page-url';
|
|
}> = [];
|
|
|
|
for (const session of sessions) {
|
|
if (session.clientId?.trim() !== normalizedClientId) {
|
|
continue;
|
|
}
|
|
|
|
const pageVisibilityState = session.context?.pageVisibilityState ?? 'visible';
|
|
const pageFocusState = session.context?.pageFocusState ?? 'focused';
|
|
const pageUrl = session.context?.pageUrl?.trim();
|
|
const presenceAgeMs = Math.max(0, nowMs - session.lastSeenAt);
|
|
const socketReadyState = getSessionSocketReadyState(session);
|
|
|
|
if (!hasOpenSessionSocket(session)) {
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: null,
|
|
reason: 'socket-not-open',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const hasFreshPresence = presenceAgeMs < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS;
|
|
|
|
if (!hasFreshPresence) {
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: null,
|
|
reason: 'stale-presence',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (pageVisibilityState === 'hidden' || pageFocusState === 'blurred') {
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: null,
|
|
reason: 'backgrounded',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!pageUrl) {
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: null,
|
|
reason: 'active-no-page-url',
|
|
});
|
|
return {
|
|
isActive: true,
|
|
matchedSessions,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const resolvedUrl = new URL(pageUrl);
|
|
|
|
if (resolvedUrl.origin === 'https://preview.sm-home.cloud' || resolvedUrl.origin === 'https://test.sm-home.cloud') {
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: resolvedUrl.origin,
|
|
reason: 'active-supported-origin',
|
|
});
|
|
return {
|
|
isActive: true,
|
|
matchedSessions,
|
|
};
|
|
}
|
|
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: resolvedUrl.origin,
|
|
reason: 'unsupported-origin',
|
|
});
|
|
} catch {
|
|
matchedSessions.push({
|
|
sessionId: session.sessionId,
|
|
lastSeenAt: session.lastSeenAt,
|
|
presenceAgeMs,
|
|
socketReadyState,
|
|
pageVisibilityState,
|
|
pageFocusState,
|
|
pageOrigin: null,
|
|
reason: 'invalid-page-url',
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return {
|
|
isActive: false,
|
|
matchedSessions,
|
|
};
|
|
}
|
|
|
|
function logChatClientActiveViewingEvaluation(
|
|
logger: FastifyBaseLogger,
|
|
clientId: string,
|
|
evaluation: ReturnType<typeof evaluateChatClientActiveViewing>,
|
|
) {
|
|
if (evaluation.matchedSessions.length === 0) {
|
|
logger.info(
|
|
{
|
|
clientId,
|
|
active: false,
|
|
reason: 'no-matching-session',
|
|
},
|
|
'chat offline notification presence evaluation',
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const sessionState of evaluation.matchedSessions) {
|
|
logger.info(
|
|
{
|
|
clientId,
|
|
active: evaluation.isActive,
|
|
sessionId: sessionState.sessionId,
|
|
lastSeenAt: sessionState.lastSeenAt,
|
|
presenceAgeMs: sessionState.presenceAgeMs,
|
|
socketReadyState: sessionState.socketReadyState,
|
|
pageVisibilityState: sessionState.pageVisibilityState,
|
|
pageFocusState: sessionState.pageFocusState,
|
|
pageOrigin: sessionState.pageOrigin,
|
|
reason: sessionState.reason,
|
|
},
|
|
'chat offline notification presence evaluation',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function resolveChatContextAppOrigin(context: ChatContext | null | undefined) {
|
|
const directOrigin = context?.appOrigin?.trim();
|
|
|
|
if (directOrigin) {
|
|
try {
|
|
return new URL(directOrigin).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const pageUrl = context?.pageUrl?.trim();
|
|
|
|
if (!pageUrl) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new URL(pageUrl).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resolveChatContextAppDomain(context: ChatContext | null | undefined) {
|
|
const directDomain = context?.appDomain?.trim().toLowerCase();
|
|
|
|
if (directDomain) {
|
|
return directDomain;
|
|
}
|
|
|
|
const appOrigin = resolveChatContextAppOrigin(context);
|
|
|
|
if (!appOrigin) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new URL(appOrigin).hostname.trim().toLowerCase() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) {
|
|
const fallbackUrl = new URL('/chat/live', resolveChatContextAppOrigin(context) ?? 'https://preview.sm-home.cloud');
|
|
fallbackUrl.searchParams.set('topMenu', 'chat');
|
|
fallbackUrl.searchParams.set('sessionId', sessionId);
|
|
|
|
const pageUrl = context?.pageUrl?.trim();
|
|
|
|
if (!pageUrl) {
|
|
return fallbackUrl.toString();
|
|
}
|
|
|
|
try {
|
|
const targetUrl = new URL(pageUrl);
|
|
targetUrl.pathname = '/chat/live';
|
|
targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat');
|
|
targetUrl.searchParams.set('sessionId', sessionId);
|
|
targetUrl.searchParams.delete('chatView');
|
|
targetUrl.searchParams.delete('runtimeRequestId');
|
|
return targetUrl.toString();
|
|
} catch {
|
|
return fallbackUrl.toString();
|
|
}
|
|
}
|
|
|
|
function createChatNotificationPreview(text: string) {
|
|
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
|
}
|
|
|
|
function createChatQuestionAnswerNotificationBody(args: {
|
|
questionText?: string | null;
|
|
answerText?: string | null;
|
|
fallback: string;
|
|
}) {
|
|
const questionPreview = createChatNotificationPreview(args.questionText ?? '');
|
|
const answerPreview = createChatNotificationPreview(args.answerText ?? '');
|
|
|
|
if (questionPreview && answerPreview) {
|
|
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
|
}
|
|
|
|
if (answerPreview) {
|
|
return `답변: ${answerPreview}`;
|
|
}
|
|
|
|
if (questionPreview) {
|
|
return `질문: ${questionPreview}`;
|
|
}
|
|
|
|
return args.fallback;
|
|
}
|
|
|
|
function normalizeStructuredChatMessage(message: ChatMessage): ChatMessage {
|
|
if (message.author === 'user') {
|
|
return message;
|
|
}
|
|
|
|
const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : [];
|
|
const extracted = extractChatMessageParts(message.text);
|
|
const nextParts = existingParts.length > 0 ? existingParts : extracted.parts;
|
|
|
|
if (nextParts.length === 0) {
|
|
return existingParts.length === 0 ? message : { ...message, parts: existingParts };
|
|
}
|
|
|
|
return {
|
|
...message,
|
|
text: extracted.strippedText,
|
|
parts: nextParts,
|
|
};
|
|
}
|
|
|
|
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
|
|
const questionPreview = createChatNotificationPreview(questionText ?? '');
|
|
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
|
|
}
|
|
|
|
function normalizeNotificationDetailText(text?: string | null) {
|
|
const normalized = String(text ?? '').trim();
|
|
return normalized || undefined;
|
|
}
|
|
|
|
export function collectOfflineNotificationClientIds(preferredClientIds?: string[]) {
|
|
const nextClientIds = new Set<string>();
|
|
|
|
for (const candidate of preferredClientIds ?? []) {
|
|
const normalized = String(candidate ?? '').trim();
|
|
|
|
if (normalized) {
|
|
nextClientIds.add(normalized);
|
|
}
|
|
}
|
|
|
|
return [...nextClientIds];
|
|
}
|
|
|
|
export function filterInactiveOfflineNotificationClientIds(
|
|
clientIds: string[],
|
|
sessions: Iterable<ChatSessionState>,
|
|
isActive: (clientId: string, currentSessions: Iterable<ChatSessionState>) => boolean = isChatClientActivelyViewing,
|
|
) {
|
|
return clientIds.filter((clientId) => !isActive(clientId, sessions));
|
|
}
|
|
|
|
export function shouldSendOfflineChatNotification(options: {
|
|
receiveRoomNotifications?: boolean | null;
|
|
conversationNotifyOffline?: boolean | null;
|
|
}) {
|
|
if (options.receiveRoomNotifications === false) {
|
|
return false;
|
|
}
|
|
|
|
return options.conversationNotifyOffline === true;
|
|
}
|
|
|
|
function isPreparingChatReply(text?: string | null) {
|
|
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
return normalized.startsWith('응답을 준비하고 있습니다');
|
|
}
|
|
|
|
function formatTime(date: Date) {
|
|
return new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: KST_TIME_ZONE,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
})
|
|
.format(date)
|
|
.replace(',', '');
|
|
}
|
|
|
|
export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = Date.now()) {
|
|
if (!Number.isFinite(requestedAtMs)) {
|
|
return formatTime(new Date(nowMs));
|
|
}
|
|
|
|
return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000)));
|
|
}
|
|
|
|
function parseRequestedAtMs(value: string) {
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
}
|
|
|
|
function formatKstDate(date = new Date()) {
|
|
return new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: KST_TIME_ZONE,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
function createChatMessageId() {
|
|
chatMessageSequence = (chatMessageSequence + 1) % 1_000;
|
|
return Date.now() * 1_000 + chatMessageSequence;
|
|
}
|
|
|
|
function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage {
|
|
return {
|
|
id: createChatMessageId(),
|
|
author,
|
|
text,
|
|
timestamp: formatTime(new Date()),
|
|
clientRequestId: clientRequestId?.trim() || null,
|
|
parts: Array.isArray(parts) ? parts : [],
|
|
};
|
|
}
|
|
|
|
function createRequestId() {
|
|
return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) {
|
|
const queryToken = url.searchParams.get('accessToken')?.trim();
|
|
const headerToken = Array.isArray(request.headers['x-access-token'])
|
|
? String(request.headers['x-access-token'][0] ?? '').trim()
|
|
: String(request.headers['x-access-token'] ?? '').trim();
|
|
|
|
if (hasErrorLogViewAccessToken(queryToken || headerToken)) {
|
|
return true;
|
|
}
|
|
|
|
const shareToken = url.searchParams.get('shareToken')?.trim() || '';
|
|
const sharePin = url.searchParams.get('sharePin')?.trim() || '';
|
|
const clientId = url.searchParams.get('clientId')?.trim() || '';
|
|
const requestedSessionId = url.searchParams.get('sessionId')?.trim() || '';
|
|
|
|
if (!shareToken) {
|
|
return false;
|
|
}
|
|
|
|
const sharePath = '/chat/share/' + encodeURIComponent(shareToken);
|
|
const sharedTokenDetail = await getSharedResourceTokenDetailBySharePath(sharePath);
|
|
const sharedToken = sharedTokenDetail?.token ?? null;
|
|
const resourceContext = sharedToken?.resourceContext ?? null;
|
|
|
|
if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) {
|
|
return false;
|
|
}
|
|
|
|
if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) {
|
|
return false;
|
|
}
|
|
|
|
if (sharedToken.effectiveExpiresAt) {
|
|
const expiresAtMs = Date.parse(sharedToken.effectiveExpiresAt);
|
|
|
|
if (Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const pinStatus = await validateSharedResourceAccessPinBySharePath(sharePath, sharePin, {
|
|
clientId,
|
|
});
|
|
|
|
if (pinStatus.status !== 'ok' && pinStatus.status !== 'not-configured') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function hashRequestId(value: string) {
|
|
let hash = 0;
|
|
|
|
for (const character of value) {
|
|
hash = (hash * 31 + character.charCodeAt(0)) | 0;
|
|
}
|
|
|
|
return Math.abs(hash) + 1_000_000;
|
|
}
|
|
|
|
export function fitActivityLogLines(lines: string[]) {
|
|
const normalizedLines = lines.map((line) => line.trim()).filter(Boolean);
|
|
|
|
if (normalizedLines.length <= 1) {
|
|
return normalizedLines;
|
|
}
|
|
|
|
let startIndex = Math.max(0, normalizedLines.length - MAX_CHAT_ACTIVITY_MESSAGE_LINES);
|
|
let fittedLines = normalizedLines.slice(startIndex);
|
|
|
|
while (fittedLines.length > 1 && fittedLines.join('\n\n').length > MAX_CHAT_ACTIVITY_MESSAGE_CHARS) {
|
|
startIndex += 1;
|
|
fittedLines = normalizedLines.slice(startIndex);
|
|
}
|
|
|
|
return fittedLines;
|
|
}
|
|
|
|
export function createActivityLogMessage(requestId: string, lines: string[]) {
|
|
const normalizedRequestId = requestId.trim();
|
|
const fittedLines = fitActivityLogLines(lines);
|
|
const messageBody = fittedLines.length > 0 ? fittedLines.join('\n\n') : '활동 로그를 준비하고 있습니다.';
|
|
const messageText = `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${messageBody}`;
|
|
|
|
return createMessage('system', messageText, normalizedRequestId)
|
|
? {
|
|
id: hashRequestId(normalizedRequestId),
|
|
author: 'system' as const,
|
|
text: messageText,
|
|
timestamp: formatTime(new Date()),
|
|
clientRequestId: normalizedRequestId,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
function shouldPersistMessageUpdate(message: ChatMessage) {
|
|
if (message.author === 'codex') {
|
|
return false;
|
|
}
|
|
|
|
return !String(message.text ?? '').startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
|
}
|
|
|
|
function isSocketOpen(socket: WebSocket | null | undefined) {
|
|
return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN);
|
|
}
|
|
|
|
function closeSocketSafely(
|
|
logger: FastifyBaseLogger,
|
|
socket: WebSocket | null | undefined,
|
|
message: string,
|
|
code = 1000,
|
|
reason = 'replaced',
|
|
) {
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
socket.close(code, reason);
|
|
} catch (error) {
|
|
logger.warn(error, message);
|
|
}
|
|
}
|
|
|
|
function sendSocketEnvelope(
|
|
logger: FastifyBaseLogger,
|
|
socket: WebSocket | null | undefined,
|
|
envelope: ChatOutboundMessage,
|
|
message: string,
|
|
) {
|
|
const targetSocket = socket;
|
|
|
|
if (!targetSocket || targetSocket.readyState !== SOCKET_READY_STATE_OPEN) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
targetSocket.send(JSON.stringify(envelope));
|
|
return true;
|
|
} catch (error) {
|
|
logger.warn(error, message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type ParsedPlanContext = {
|
|
planId: number | null;
|
|
workId: string | null;
|
|
previewUrl: string | null;
|
|
};
|
|
|
|
type PlanSnapshot = {
|
|
planId: number;
|
|
workId: string;
|
|
status: string;
|
|
note: string;
|
|
workerStatus: string | null;
|
|
issueTags: string[];
|
|
hasOpenIssues: boolean;
|
|
assignedBranch: string | null;
|
|
lastError: string | null;
|
|
latestActionNote: string | null;
|
|
latestIssue: {
|
|
tag: string;
|
|
message: string;
|
|
resolved: boolean;
|
|
} | null;
|
|
latestSourceWork: {
|
|
summary: string;
|
|
previewUrl: string | null;
|
|
changedFiles: string[];
|
|
} | null;
|
|
recentActionNotes: string[];
|
|
recentWorkSummaries: string[];
|
|
};
|
|
|
|
function truncateText(value: string | null | undefined, limit = 120) {
|
|
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
|
|
}
|
|
|
|
function extractReferencedPlanId(input: string) {
|
|
const planIdMatch = input.match(/(?:planid=|#)(\d{1,9})/i) ?? input.match(/\bplan\s*(\d{1,9})\b/i);
|
|
const parsedPlanId = planIdMatch?.[1] ? Number(planIdMatch[1]) : NaN;
|
|
|
|
return Number.isInteger(parsedPlanId) && parsedPlanId > 0 ? parsedPlanId : null;
|
|
}
|
|
|
|
function extractReferencedWorkId(input: string) {
|
|
const workIdMatch = input.match(/\bworkid\s*[:=]?\s*([^\s,]+)/i);
|
|
return workIdMatch?.[1]?.trim() || null;
|
|
}
|
|
|
|
function extractReferencedPreviewUrl(input: string) {
|
|
const previewUrlMatch = input.match(/https?:\/\/[^\s)]+/i);
|
|
return previewUrlMatch?.[0]?.trim() || null;
|
|
}
|
|
|
|
function buildChangedFilesSummary(changedFiles: string[], limit = 5) {
|
|
if (changedFiles.length === 0) {
|
|
return '변경/신규 파일: 기록 없음';
|
|
}
|
|
|
|
const visibleFiles = changedFiles.slice(0, limit);
|
|
const suffix = changedFiles.length > limit ? ` 외 ${changedFiles.length - limit}개` : '';
|
|
return `변경/신규 파일: ${visibleFiles.join(', ')}${suffix}`;
|
|
}
|
|
|
|
function parsePlanContext(context: ChatContext | null, input: string): ParsedPlanContext {
|
|
const pageUrl = context?.pageUrl ?? '';
|
|
const referencedPlanId = extractReferencedPlanId(input);
|
|
const referencedWorkId = extractReferencedWorkId(input);
|
|
const referencedPreviewUrl = extractReferencedPreviewUrl(input);
|
|
|
|
if (!pageUrl) {
|
|
return {
|
|
planId: referencedPlanId,
|
|
workId: referencedWorkId,
|
|
previewUrl: referencedPreviewUrl,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const url = new URL(pageUrl);
|
|
const planIdText = url.searchParams.get('planId');
|
|
const workIdText = url.searchParams.get('workId');
|
|
const planIdNumber = planIdText ? Number(planIdText) : NaN;
|
|
|
|
return {
|
|
planId: Number.isInteger(planIdNumber) && planIdNumber > 0 ? planIdNumber : referencedPlanId,
|
|
workId: workIdText?.trim() || referencedWorkId,
|
|
previewUrl: referencedPreviewUrl,
|
|
};
|
|
} catch {
|
|
return {
|
|
planId: referencedPlanId,
|
|
workId: referencedWorkId,
|
|
previewUrl: referencedPreviewUrl,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function loadPlanSnapshot(planId: number): Promise<PlanSnapshot | null> {
|
|
const item = await getPlanItemById(planId);
|
|
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
|
|
const [actionRows, issueRows, sourceWorkRows] = await Promise.all([
|
|
listPlanActionHistories(planId),
|
|
listPlanIssueHistories(planId),
|
|
listPlanSourceWorkHistories(planId),
|
|
]);
|
|
const latestAction = actionRows[0] ? mapPlanActionRow(actionRows[0]) : null;
|
|
const latestIssue = issueRows[0] ? mapPlanIssueRow(issueRows[0]) : null;
|
|
const latestSourceWork = sourceWorkRows[0] ? mapPlanSourceWorkRow(sourceWorkRows[0]) : null;
|
|
|
|
return {
|
|
planId,
|
|
workId: String(item.workId ?? '작업ID'),
|
|
status: String(item.status ?? '-'),
|
|
note: String(item.note ?? ''),
|
|
workerStatus: item.workerStatus ? String(item.workerStatus) : null,
|
|
issueTags: Array.isArray(item.issueTags) ? item.issueTags.map((value) => String(value)) : [],
|
|
hasOpenIssues: Boolean(item.hasOpenIssues),
|
|
assignedBranch: item.assignedBranch ? String(item.assignedBranch) : null,
|
|
lastError: item.lastError ? String(item.lastError) : null,
|
|
latestActionNote: latestAction ? String(latestAction.note) : null,
|
|
latestIssue: latestIssue
|
|
? {
|
|
tag: String(latestIssue.issueTag),
|
|
message: String(latestIssue.message),
|
|
resolved: Boolean(latestIssue.resolved),
|
|
}
|
|
: null,
|
|
latestSourceWork: latestSourceWork
|
|
? {
|
|
summary: String(latestSourceWork.summary),
|
|
previewUrl: latestSourceWork.previewUrl ? String(latestSourceWork.previewUrl) : null,
|
|
changedFiles: latestSourceWork.changedFiles,
|
|
}
|
|
: null,
|
|
recentActionNotes: actionRows
|
|
.slice(0, 5)
|
|
.map((row) => truncateText(String(mapPlanActionRow(row).note ?? ''), 140))
|
|
.filter(Boolean),
|
|
recentWorkSummaries: sourceWorkRows
|
|
.slice(0, 5)
|
|
.map((row) => truncateText(String(mapPlanSourceWorkRow(row).summary ?? ''), 140))
|
|
.filter(Boolean),
|
|
};
|
|
}
|
|
|
|
function isWorklogRequest(input: string) {
|
|
const normalized = input.toLowerCase();
|
|
const mentionsWorklog =
|
|
input.includes('워크일지') ||
|
|
input.includes('작업로그') ||
|
|
input.includes('작업 일지') ||
|
|
input.includes('작업일지') ||
|
|
normalized.includes('worklog');
|
|
const asksToWrite =
|
|
input.includes('작성') ||
|
|
input.includes('정리') ||
|
|
input.includes('기록') ||
|
|
input.includes('밀린') ||
|
|
input.includes('써');
|
|
|
|
return mentionsWorklog || (normalized.includes('log') && asksToWrite);
|
|
}
|
|
|
|
function isPlanDetailRequest(input: string) {
|
|
const normalized = input.toLowerCase();
|
|
|
|
return (
|
|
input.includes('작업') ||
|
|
input.includes('계획') ||
|
|
input.includes('이력') ||
|
|
input.includes('이슈') ||
|
|
input.includes('상태') ||
|
|
input.includes('브랜치') ||
|
|
normalized.includes('plan') ||
|
|
normalized.includes('issue') ||
|
|
normalized.includes('status') ||
|
|
normalized.includes('history') ||
|
|
normalized.includes('branch')
|
|
);
|
|
}
|
|
|
|
function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) {
|
|
const pageTitle = context?.pageTitle ?? '현재 화면';
|
|
|
|
if (!snapshot) {
|
|
return `현재 화면: ${pageTitle}\n작업로그 초안을 만들 Plan을 찾지 못했습니다. Plan 상세를 연 뒤 다시 요청해 주세요.`;
|
|
}
|
|
|
|
const today = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Seoul',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date());
|
|
const todayWork = [
|
|
truncateText(snapshot.note, 180),
|
|
...snapshot.recentWorkSummaries,
|
|
snapshot.latestActionNote ? truncateText(snapshot.latestActionNote, 140) : '',
|
|
].filter(Boolean);
|
|
const issues = snapshot.latestIssue
|
|
? [
|
|
`${snapshot.latestIssue.tag} ${snapshot.latestIssue.resolved ? '해결' : '미해결'}: ${truncateText(
|
|
snapshot.latestIssue.message,
|
|
140,
|
|
)}`,
|
|
]
|
|
: [];
|
|
const decisions = [
|
|
snapshot.assignedBranch ? `작업 브랜치: ${snapshot.assignedBranch}` : '',
|
|
snapshot.latestSourceWork?.previewUrl ? `preview 링크: ${snapshot.latestSourceWork.previewUrl}` : '',
|
|
snapshot.status ? `현재 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}` : '',
|
|
].filter(Boolean);
|
|
const details = [...snapshot.recentActionNotes, ...snapshot.recentWorkSummaries].filter(Boolean);
|
|
|
|
return [
|
|
`현재 화면: ${pageTitle}`,
|
|
`기준 Plan: #${snapshot.planId} ${snapshot.workId}`,
|
|
'아래 초안을 작업일지에 바로 붙여 넣으면 됩니다.',
|
|
'',
|
|
`# ${today} 작업일지`,
|
|
'',
|
|
'## 오늘 작업',
|
|
...(todayWork.length > 0 ? todayWork.slice(0, 6).map((item) => `- ${item}`) : ['- Plan 메모와 이력 확인 후 작업 내용을 보강해 주세요.']),
|
|
'',
|
|
'## 이슈 및 해결',
|
|
...(issues.length > 0 ? issues.map((item) => `- ${item}`) : ['- 현재 기록된 이슈 없음']),
|
|
'',
|
|
'## 결정 사항',
|
|
...(decisions.length > 0 ? decisions.map((item) => `- ${item}`) : ['- 별도 결정 사항 없음']),
|
|
'',
|
|
'## 상세 작업 내역',
|
|
...(details.length > 0 ? details.slice(0, 8).map((item) => `- ${item}`) : ['- 최근 조치 이력이 없어 상세 내역 보강이 필요합니다.']),
|
|
].join('\n');
|
|
}
|
|
|
|
function buildRecentPlanHistoryLines(snapshot: PlanSnapshot | null, limit = 2) {
|
|
if (!snapshot) {
|
|
return [];
|
|
}
|
|
|
|
const historyItems = [
|
|
...snapshot.recentActionNotes.map((note) => `[조치] ${note}`),
|
|
...snapshot.recentWorkSummaries.map((summary) => `[소스] ${summary}`),
|
|
]
|
|
.filter(Boolean)
|
|
.slice(0, limit);
|
|
|
|
if (historyItems.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return ['최근 조치 이력:', ...historyItems.map((item, index) => `${index + 1}. ${item}`)];
|
|
}
|
|
|
|
function isDetailRequest(input: string) {
|
|
const normalized = input.toLowerCase();
|
|
|
|
return (
|
|
input.includes('상세') ||
|
|
input.includes('자세히') ||
|
|
input.includes('전체') ||
|
|
normalized.includes('detail') ||
|
|
normalized.includes('details') ||
|
|
normalized.includes('more')
|
|
);
|
|
}
|
|
|
|
export function isAutomationRegistrationCountRequest(input: string) {
|
|
const normalized = input.toLowerCase();
|
|
const mentionsAutomation =
|
|
input.includes('자동화') || normalized.includes('automation') || normalized.includes('plan');
|
|
const mentionsRegistration =
|
|
input.includes('등록') || input.includes('접수') || normalized.includes('register') || normalized.includes('count');
|
|
const mentionsToday =
|
|
input.includes('오늘') || normalized.includes('today');
|
|
const asksCount =
|
|
input.includes('총') ||
|
|
input.includes('건수') ||
|
|
input.includes('몇 건') ||
|
|
input.includes('몇건') ||
|
|
normalized.includes('count') ||
|
|
normalized.includes('total');
|
|
|
|
return mentionsAutomation && mentionsRegistration && (mentionsToday || asksCount);
|
|
}
|
|
|
|
function isAutomationRegistrationDefinitionRequest(input: string) {
|
|
const normalized = input.toLowerCase();
|
|
const mentionsAutomation = input.includes('자동화') || normalized.includes('automation');
|
|
const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register');
|
|
const asksMeaning =
|
|
input.includes('무슨 뜻') ||
|
|
input.includes('뭐야') ||
|
|
input.includes('뜻') ||
|
|
input.includes('기준') ||
|
|
input.includes('뭘 의미') ||
|
|
normalized.includes('meaning') ||
|
|
normalized.includes('define');
|
|
|
|
return mentionsAutomation && mentionsRegistration && asksMeaning;
|
|
}
|
|
|
|
export function shouldUseAgenticCodexReply(input: string) {
|
|
const normalized = input.toLowerCase();
|
|
const trimmed = input.trim();
|
|
|
|
if (isAutomationRegistrationCountRequest(input) || isAutomationRegistrationDefinitionRequest(input)) {
|
|
return false;
|
|
}
|
|
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
|
|
if (trimmed.length <= 3 && !/(db|api|fix|bug|log|sql)/i.test(trimmed)) {
|
|
return false;
|
|
}
|
|
|
|
if (isDetailRequest(input) || isPlanDetailRequest(input) || isWorklogRequest(input)) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
input.includes('수정') ||
|
|
input.includes('변경') ||
|
|
input.includes('구현') ||
|
|
input.includes('추가') ||
|
|
input.includes('삭제') ||
|
|
input.includes('고쳐') ||
|
|
input.includes('만들어') ||
|
|
input.includes('등록해줘') ||
|
|
input.includes('조회') ||
|
|
input.includes('확인') ||
|
|
input.includes('읽어') ||
|
|
input.includes('분석') ||
|
|
input.includes('DB') ||
|
|
input.includes('API') ||
|
|
input.includes('소스') ||
|
|
input.includes('파일') ||
|
|
input.includes('로그') ||
|
|
/(?:\/|\\).+\.[a-z0-9]+/i.test(input) ||
|
|
normalized.includes('fix') ||
|
|
normalized.includes('implement') ||
|
|
normalized.includes('change') ||
|
|
normalized.includes('edit') ||
|
|
normalized.includes('read') ||
|
|
normalized.includes('source') ||
|
|
normalized.includes('file') ||
|
|
normalized.includes('db') ||
|
|
normalized.includes('api') ||
|
|
/\s/.test(trimmed)
|
|
);
|
|
}
|
|
|
|
export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) {
|
|
void context;
|
|
void input;
|
|
return false;
|
|
}
|
|
|
|
function summarizeCodexOutput(output: string) {
|
|
const normalized = String(output ?? '').trim();
|
|
|
|
if (!normalized) {
|
|
return 'Codex 실행 결과가 비어 있습니다.';
|
|
}
|
|
|
|
const lines = normalized
|
|
.split('\n')
|
|
.map((line) => line.trimEnd())
|
|
.filter(Boolean);
|
|
|
|
return lines.slice(-12).join('\n');
|
|
}
|
|
|
|
function parseChatTokenMetricValue(valueText: string, suffixText?: string) {
|
|
const normalizedValue = Number(String(valueText ?? '').replace(/,/g, '').trim());
|
|
|
|
if (!Number.isFinite(normalizedValue)) {
|
|
return null;
|
|
}
|
|
|
|
const suffix = String(suffixText ?? '').trim().toLowerCase();
|
|
const multiplier = suffix === 'k' ? 1_000 : suffix === 'm' ? 1_000_000 : 1;
|
|
return Math.max(0, Math.round(normalizedValue * multiplier));
|
|
}
|
|
|
|
function parseChatRequestTokenUsageMetrics(tokenUsageText: string) {
|
|
const normalizedText = tokenUsageText
|
|
.replace(/^tokens?\s+used\s*:?\s*/iu, '')
|
|
.replace(/\(([^)]+)\)/g, ', $1')
|
|
.trim();
|
|
const metrics = new Map<string, number>();
|
|
|
|
for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) {
|
|
const label = match[3]?.toLowerCase() ?? '';
|
|
const value = parseChatTokenMetricValue(match[1] ?? '', match[2]);
|
|
|
|
if (!label || value === null) {
|
|
continue;
|
|
}
|
|
|
|
metrics.set(label, value);
|
|
}
|
|
|
|
for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) {
|
|
const label = match[1]?.toLowerCase() ?? '';
|
|
const value = parseChatTokenMetricValue(match[2] ?? '', match[3]);
|
|
|
|
if (!label || value === null) {
|
|
continue;
|
|
}
|
|
|
|
metrics.set(label, value);
|
|
}
|
|
|
|
if (metrics.size > 0) {
|
|
return metrics;
|
|
}
|
|
|
|
const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i);
|
|
|
|
if (!fallbackMatch) {
|
|
return null;
|
|
}
|
|
|
|
const fallbackValue = parseChatTokenMetricValue(fallbackMatch[1], fallbackMatch[2]);
|
|
|
|
if (fallbackValue === null) {
|
|
return null;
|
|
}
|
|
|
|
return new Map([['total', fallbackValue]]);
|
|
}
|
|
|
|
function getChatRequestTotalTokenCount(metrics: Map<string, number>) {
|
|
const total = metrics.get('total');
|
|
|
|
if (typeof total === 'number' && Number.isFinite(total)) {
|
|
return total;
|
|
}
|
|
|
|
return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0);
|
|
}
|
|
|
|
function normalizeChatUsageMetricValue(value: unknown) {
|
|
const normalizedValue =
|
|
typeof value === 'string' ? Number(value.replace(/,/g, '').trim()) : Number(value);
|
|
|
|
if (!Number.isFinite(normalizedValue)) {
|
|
return null;
|
|
}
|
|
|
|
return Math.max(0, Math.round(normalizedValue));
|
|
}
|
|
|
|
function createChatUsageSnapshotFromMetrics(metrics: Map<string, number>) {
|
|
const totalTokens = getChatRequestTotalTokenCount(metrics);
|
|
|
|
return {
|
|
tokenTotals: {
|
|
total: metrics.get('total') ?? totalTokens,
|
|
input: metrics.get('input') ?? 0,
|
|
output: metrics.get('output') ?? 0,
|
|
cached: metrics.get('cached') ?? 0,
|
|
reasoning: metrics.get('reasoning') ?? 0,
|
|
},
|
|
totalTokens,
|
|
} satisfies ChatConversationRequestUsageSnapshot;
|
|
}
|
|
|
|
function extractChatRequestUsageSnapshotFromStructuredJson(output: string): ChatConversationRequestUsageSnapshot | null {
|
|
const lines = String(output ?? '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
const line = lines[index] ?? '';
|
|
|
|
if (!line.startsWith('{')) {
|
|
continue;
|
|
}
|
|
|
|
let parsed: Record<string, unknown>;
|
|
|
|
try {
|
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const usage =
|
|
parsed.usage && typeof parsed.usage === 'object'
|
|
? (parsed.usage as Record<string, unknown>)
|
|
: parsed.response && typeof parsed.response === 'object' && parsed.response !== null &&
|
|
'usage' in parsed.response &&
|
|
parsed.response.usage &&
|
|
typeof parsed.response.usage === 'object'
|
|
? (parsed.response.usage as Record<string, unknown>)
|
|
: null;
|
|
|
|
if (!usage) {
|
|
continue;
|
|
}
|
|
|
|
const metrics = new Map<string, number>();
|
|
const input = normalizeChatUsageMetricValue(usage.input_tokens);
|
|
const output = normalizeChatUsageMetricValue(usage.output_tokens);
|
|
const cached = normalizeChatUsageMetricValue(usage.cached_input_tokens);
|
|
const reasoning = normalizeChatUsageMetricValue(usage.reasoning_output_tokens ?? usage.reasoning_tokens);
|
|
const total =
|
|
normalizeChatUsageMetricValue(usage.total_tokens) ??
|
|
normalizeChatUsageMetricValue(usage.totalTokens);
|
|
|
|
if (input !== null) {
|
|
metrics.set('input', input);
|
|
}
|
|
|
|
if (output !== null) {
|
|
metrics.set('output', output);
|
|
}
|
|
|
|
if (cached !== null) {
|
|
metrics.set('cached', cached);
|
|
}
|
|
|
|
if (reasoning !== null) {
|
|
metrics.set('reasoning', reasoning);
|
|
}
|
|
|
|
if (total !== null) {
|
|
metrics.set('total', total);
|
|
}
|
|
|
|
if (metrics.size > 0) {
|
|
return createChatUsageSnapshotFromMetrics(metrics);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractChatRequestTokenUsageText(output: string) {
|
|
const text = String(output ?? '');
|
|
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
|
|
const lines = text.split('\n').map((line) => line.trim());
|
|
const lastMatchedIndex = lines.reduce((foundIndex, line, index) => {
|
|
return /tokens?\s+used/i.test(line) ? index : foundIndex;
|
|
}, -1);
|
|
|
|
if (lastMatchedIndex < 0) {
|
|
return '';
|
|
}
|
|
|
|
const matchedLine = lines[lastMatchedIndex] ?? '';
|
|
const usageLines = [matchedLine];
|
|
|
|
if (!/\d/.test(matchedLine)) {
|
|
for (let index = lastMatchedIndex + 1; index < lines.length && usageLines.length < 5; index += 1) {
|
|
const nextLine = lines[index]?.trim() ?? '';
|
|
|
|
if (!nextLine) {
|
|
if (usageLines.length > 1) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (/^(?:\[plan-progress\]|done:|noop:|board_post:|recovered_commit:|git\s|diff --git|commit\s[0-9a-f]{7,})/i.test(nextLine)) {
|
|
break;
|
|
}
|
|
|
|
if (!/\d/.test(nextLine) && !/(input|output|total|cached|reasoning)/i.test(nextLine)) {
|
|
if (usageLines.length > 1) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
usageLines.push(nextLine);
|
|
}
|
|
}
|
|
|
|
return usageLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function extractChatRequestUsageSnapshot(output: string): ChatConversationRequestUsageSnapshot | null {
|
|
const structuredSnapshot = extractChatRequestUsageSnapshotFromStructuredJson(output);
|
|
|
|
if (structuredSnapshot) {
|
|
return structuredSnapshot;
|
|
}
|
|
|
|
const tokenUsageText = extractChatRequestTokenUsageText(output);
|
|
|
|
if (!tokenUsageText) {
|
|
return null;
|
|
}
|
|
|
|
const metrics = parseChatRequestTokenUsageMetrics(tokenUsageText);
|
|
|
|
if (!metrics) {
|
|
return null;
|
|
}
|
|
|
|
return createChatUsageSnapshotFromMetrics(metrics);
|
|
}
|
|
|
|
type ChatCodexReplyResult = {
|
|
text: string;
|
|
usageSnapshot: ChatConversationRequestUsageSnapshot | null;
|
|
totalTokens: number | null;
|
|
};
|
|
|
|
class ChatRuntimeExecutionError extends Error {
|
|
responseText: string;
|
|
usageSnapshot: ChatConversationRequestUsageSnapshot | null;
|
|
totalTokens: number | null;
|
|
|
|
constructor(
|
|
message: string,
|
|
responseText = '',
|
|
usageSnapshot: ChatConversationRequestUsageSnapshot | null = null,
|
|
totalTokens: number | null = null,
|
|
) {
|
|
super(message);
|
|
this.name = 'ChatRuntimeExecutionError';
|
|
this.responseText = responseText.trim();
|
|
this.usageSnapshot = usageSnapshot;
|
|
this.totalTokens = totalTokens;
|
|
}
|
|
}
|
|
|
|
function summarizeCommand(command: string, limit = 180) {
|
|
const normalized = String(command ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized;
|
|
}
|
|
|
|
function summarizeCommandOutput(output: string, maxLines = 3, maxLength = 220) {
|
|
const lines = String(output ?? '')
|
|
.replace(/\r/g, '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
if (lines.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const joined = lines.slice(0, maxLines).join(' / ');
|
|
return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined;
|
|
}
|
|
|
|
function inferCommandReason(command: string) {
|
|
const normalized = command.toLowerCase();
|
|
|
|
if (normalized.includes('rg ') || normalized.includes('ripgrep')) {
|
|
return '관련 파일이나 텍스트를 빠르게 찾기 위해 검색했습니다.';
|
|
}
|
|
|
|
if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) {
|
|
return '해당 파일 내용을 직접 읽어 문맥을 확인했습니다.';
|
|
}
|
|
|
|
if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) {
|
|
return '디렉터리 구조와 대상 파일 위치를 확인했습니다.';
|
|
}
|
|
|
|
if (normalized.includes('git status')) {
|
|
return '작업 트리 상태와 변경 범위를 점검했습니다.';
|
|
}
|
|
|
|
if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) {
|
|
return '수정 후 타입이나 동작 검증을 진행했습니다.';
|
|
}
|
|
|
|
if (normalized.includes('curl ') || normalized.includes('fetch')) {
|
|
return '실제 응답이나 연결 상태를 확인했습니다.';
|
|
}
|
|
|
|
return '현재 요청을 해결하기 위한 다음 단계를 확인했습니다.';
|
|
}
|
|
|
|
function isIgnorableCodexDiagnosticLine(line: string) {
|
|
const normalized = line.trim();
|
|
|
|
if (!normalized) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
/^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) ||
|
|
normalized.includes('ignoring interface.defaultPrompt') ||
|
|
normalized.includes('failed to open state db') ||
|
|
normalized.includes('state db discrepancy') ||
|
|
normalized.includes('Failed to kill MCP process group') ||
|
|
normalized.includes('Failed to delete shell snapshot') ||
|
|
normalized.includes('failed to unwatch ')
|
|
);
|
|
}
|
|
|
|
function extractCodexActivityLog(parsed: Record<string, unknown>) {
|
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
|
const item = parsed.item && typeof parsed.item === 'object' ? (parsed.item as Record<string, unknown>) : null;
|
|
const itemType = typeof item?.type === 'string' ? item.type : '';
|
|
|
|
if (!item || itemType !== 'command_execution') {
|
|
return '';
|
|
}
|
|
|
|
const command = summarizeCommand(typeof item.command === 'string' ? item.command : '');
|
|
|
|
if (!command) {
|
|
return '';
|
|
}
|
|
|
|
const reason = inferCommandReason(command);
|
|
|
|
if (type === 'item.started') {
|
|
return `# 이유: ${reason}\n$ ${command}`;
|
|
}
|
|
|
|
if (type === 'item.completed') {
|
|
const exitCode =
|
|
typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null;
|
|
const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : '');
|
|
const statusLabel = exitCode === null ? '# 결과: 완료' : exitCode === 0 ? '# 결과: 완료(0)' : `# 결과: 종료(${exitCode})`;
|
|
|
|
return outputSummary ? `${statusLabel}\n# 출력: ${outputSummary}` : statusLabel;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function normalizeCodexReplyOutput(output: string) {
|
|
const normalized = String(output ?? '').trim();
|
|
return normalized || 'Codex 실행 결과가 비어 있습니다.';
|
|
}
|
|
|
|
function collectCodexTextFragments(value: unknown): string[] {
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim();
|
|
return normalized ? [normalized] : [];
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item) => collectCodexTextFragments(item));
|
|
}
|
|
|
|
if (!value || typeof value !== 'object') {
|
|
return [];
|
|
}
|
|
|
|
const record = value as Record<string, unknown>;
|
|
const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message'];
|
|
|
|
for (const key of directTextKeys) {
|
|
const fragments = collectCodexTextFragments(record[key]);
|
|
|
|
if (fragments.length > 0) {
|
|
return fragments;
|
|
}
|
|
}
|
|
|
|
if (typeof record.type === 'string' && record.type.includes('output_text')) {
|
|
const fragments = collectCodexTextFragments(record.text ?? record.delta);
|
|
|
|
if (fragments.length > 0) {
|
|
return fragments;
|
|
}
|
|
}
|
|
|
|
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
|
|
}
|
|
|
|
function extractCompletedAgentMessageText(item: unknown) {
|
|
if (!item || typeof item !== 'object') {
|
|
return '';
|
|
}
|
|
|
|
const record = item as Record<string, unknown>;
|
|
|
|
if (record.type !== 'agent_message') {
|
|
return '';
|
|
}
|
|
|
|
return collectCodexTextFragments(record.text ?? record.content ?? record.message).join('');
|
|
}
|
|
|
|
export function extractCodexStreamText(parsed: Record<string, unknown>) {
|
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
|
|
|
if (type === 'item.completed') {
|
|
const completedText = extractCompletedAgentMessageText(parsed.item);
|
|
return {
|
|
type,
|
|
completedText,
|
|
deltaText: '',
|
|
};
|
|
}
|
|
|
|
if (type === 'item.delta' || type === 'response.output_text.delta') {
|
|
const deltaText =
|
|
type === 'response.output_text.delta'
|
|
? collectCodexTextFragments(parsed.delta ?? parsed.text).join('')
|
|
: collectCodexTextFragments(parsed.delta).join('');
|
|
return {
|
|
type,
|
|
completedText: '',
|
|
deltaText,
|
|
};
|
|
}
|
|
|
|
if (type === 'response.completed') {
|
|
const completedText = collectCodexTextFragments(parsed.response).join('');
|
|
return {
|
|
type,
|
|
completedText,
|
|
deltaText: '',
|
|
};
|
|
}
|
|
|
|
return {
|
|
type,
|
|
completedText: '',
|
|
deltaText: '',
|
|
};
|
|
}
|
|
|
|
export function parseStructuredCodexStdoutLine(line: string) {
|
|
const normalizedLine = String(line ?? '').trim();
|
|
|
|
if (!normalizedLine) {
|
|
return {
|
|
activityLog: '',
|
|
completedText: '',
|
|
deltaText: '',
|
|
usageSnapshot: null,
|
|
shouldKeepRaw: false,
|
|
};
|
|
}
|
|
|
|
let parsed: Record<string, unknown>;
|
|
|
|
try {
|
|
parsed = JSON.parse(normalizedLine) as Record<string, unknown>;
|
|
} catch {
|
|
return {
|
|
activityLog: '',
|
|
completedText: '',
|
|
deltaText: '',
|
|
usageSnapshot: null,
|
|
shouldKeepRaw: true,
|
|
};
|
|
}
|
|
|
|
const activityLog = extractCodexActivityLog(parsed);
|
|
const streamText = extractCodexStreamText(parsed);
|
|
const usageSnapshot = extractChatRequestUsageSnapshotFromStructuredJson(normalizedLine);
|
|
const shouldKeepRaw = !activityLog && !streamText.completedText && !streamText.deltaText;
|
|
|
|
return {
|
|
activityLog,
|
|
completedText: streamText.completedText,
|
|
deltaText: streamText.deltaText,
|
|
usageSnapshot,
|
|
shouldKeepRaw,
|
|
};
|
|
}
|
|
|
|
async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) {
|
|
const normalized = normalizeCodexReplyOutput(text);
|
|
|
|
if (!onProgress) {
|
|
return normalized;
|
|
}
|
|
|
|
for (let index = chunkSize; index < normalized.length; index += chunkSize) {
|
|
onProgress(normalized.slice(0, index));
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, delayMs);
|
|
});
|
|
}
|
|
|
|
onProgress(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function stripTrailingLineInfo(value: string) {
|
|
return value.replace(/:\d+(?::\d+)?$/, '');
|
|
}
|
|
|
|
function trimPathCandidate(value: string) {
|
|
return value.replace(/^[("'`\[]+/, '').replace(/[)\]"'`,.;:]+$/, '');
|
|
}
|
|
|
|
function encodeUrlPathSegments(value: string) {
|
|
return value
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join('/');
|
|
}
|
|
|
|
const CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN =
|
|
/^(?:src|public|docs|etc|scripts|\.github)\/[^\n\s)\]"'`,]+$/;
|
|
const CHAT_RESOURCE_ROOT_FILE_PATTERN =
|
|
/^(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)$/;
|
|
|
|
function isLikelyRepoRelativeChatResourcePath(candidate: string) {
|
|
const normalized = candidate.replace(/^\/+/, '');
|
|
return (
|
|
CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN.test(normalized) ||
|
|
CHAT_RESOURCE_ROOT_FILE_PATTERN.test(normalized)
|
|
);
|
|
}
|
|
|
|
function buildChatResourcePublicUrl(relativePath: string) {
|
|
const cleaned = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
|
|
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${encodeUrlPathSegments(cleaned)}`;
|
|
}
|
|
|
|
function normalizeEmbeddedChatResourceUrls(text: string) {
|
|
return String(text ?? '')
|
|
.replace(/https?:\/(api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/gi, '/$1')
|
|
.replace(
|
|
/(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g,
|
|
(_match, resourcePath) => resourcePath,
|
|
);
|
|
}
|
|
|
|
function toPosixPath(value: string) {
|
|
return value.split(path.sep).join('/');
|
|
}
|
|
|
|
function normalizeExistingChatPublicUrl(candidate: string) {
|
|
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()))
|
|
.replace(/^\/+/, '')
|
|
.replace(/^public\//, '');
|
|
|
|
if (!cleaned.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
|
|
return null;
|
|
}
|
|
|
|
return buildChatResourcePublicUrl(cleaned);
|
|
}
|
|
|
|
export function extractDiffCodeBlocks(output: string) {
|
|
const matches = Array.from(String(output ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g));
|
|
|
|
return matches
|
|
.map((match) => (typeof match[1] === 'string' ? match[1].trim() : ''))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function protectDiffCodeBlocks(output: string) {
|
|
const blocks: string[] = [];
|
|
const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => {
|
|
const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`;
|
|
blocks.push(match);
|
|
return token;
|
|
});
|
|
|
|
return { text, blocks };
|
|
}
|
|
|
|
function restoreDiffCodeBlocks(output: string, blocks: string[]) {
|
|
return blocks.reduce(
|
|
(current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block),
|
|
String(output ?? ''),
|
|
);
|
|
}
|
|
|
|
async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
|
|
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
|
|
|
|
if (!cleaned) {
|
|
return null;
|
|
}
|
|
|
|
let sourcePath: string | null = null;
|
|
|
|
if (cleaned.startsWith(repoPath)) {
|
|
sourcePath = cleaned;
|
|
} else if (cleaned.startsWith('/')) {
|
|
if (isLikelyRepoRelativeChatResourcePath(cleaned)) {
|
|
sourcePath = path.resolve(repoPath, cleaned.replace(/^\/+/, ''));
|
|
} else {
|
|
sourcePath = cleaned;
|
|
}
|
|
} else {
|
|
sourcePath = path.resolve(repoPath, cleaned);
|
|
}
|
|
|
|
const normalizedRepoPath = path.resolve(repoPath);
|
|
const normalizedSourcePath = path.resolve(sourcePath);
|
|
const relativePath = path.relative(normalizedRepoPath, normalizedSourcePath);
|
|
|
|
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const sourceStat = await stat(normalizedSourcePath);
|
|
|
|
if (!sourceStat.isFile()) {
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
absolutePath: normalizedSourcePath,
|
|
relativePath: toPosixPath(relativePath),
|
|
};
|
|
}
|
|
|
|
export function isChatResourcePathCandidate(candidate: string) {
|
|
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
|
|
|
|
if (!cleaned) {
|
|
return false;
|
|
}
|
|
|
|
if (cleaned.startsWith(`/${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
|
|
return true;
|
|
}
|
|
|
|
return isLikelyRepoRelativeChatResourcePath(cleaned) || /^\/(?:workspace|root|home|Users|tmp)\//.test(cleaned);
|
|
}
|
|
|
|
export async function stageChatResourceFile(repoPath: string, sessionId: string, candidate: string) {
|
|
const existingPublicUrl = normalizeExistingChatPublicUrl(candidate);
|
|
|
|
if (existingPublicUrl) {
|
|
return existingPublicUrl;
|
|
}
|
|
|
|
const resolvedSource = await resolveChatResourceSourcePath(repoPath, candidate);
|
|
|
|
if (!resolvedSource) {
|
|
return null;
|
|
}
|
|
|
|
await ensureChatSessionResourceDirectories(repoPath, sessionId);
|
|
const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`;
|
|
const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath);
|
|
await mkdir(path.dirname(targetAbsolutePath), { recursive: true });
|
|
await cp(resolvedSource.absolutePath, targetAbsolutePath, { force: true });
|
|
|
|
return buildChatResourcePublicUrl(targetRelativePath);
|
|
}
|
|
|
|
async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, output: string) {
|
|
const diffBlocks = extractDiffCodeBlocks(output);
|
|
|
|
if (diffBlocks.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const urls: string[] = [];
|
|
await ensureChatSessionResourceDirectories(repoPath, sessionId);
|
|
|
|
for (const [index, diffText] of diffBlocks.entries()) {
|
|
const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`;
|
|
const targetRelativePath =
|
|
`${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/_generated/${fileName}`;
|
|
const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath);
|
|
await mkdir(path.dirname(targetAbsolutePath), { recursive: true });
|
|
await writeFile(targetAbsolutePath, `${diffText}\n`, 'utf8');
|
|
urls.push(buildChatResourcePublicUrl(targetRelativePath));
|
|
}
|
|
|
|
return urls;
|
|
}
|
|
|
|
async function ensureWorldWritableDirectory(absolutePath: string) {
|
|
await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE });
|
|
await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {});
|
|
}
|
|
|
|
export async function ensureChatSessionResourceDirectories(repoPath: string, sessionId: string) {
|
|
const sessionRoot = path.join(repoPath, 'public', CHAT_PUBLIC_RESOURCE_DIR, sessionId);
|
|
const resourceRoot = path.join(sessionRoot, CHAT_PUBLIC_RESOURCE_SUBDIR);
|
|
const sourceRoot = path.join(resourceRoot, 'source');
|
|
const uploadRoot = path.join(resourceRoot, 'uploads');
|
|
|
|
await ensureWorldWritableDirectory(sessionRoot);
|
|
await ensureWorldWritableDirectory(resourceRoot);
|
|
await ensureWorldWritableDirectory(sourceRoot);
|
|
await ensureWorldWritableDirectory(uploadRoot);
|
|
|
|
return {
|
|
sessionRoot,
|
|
resourceRoot,
|
|
sourceRoot,
|
|
uploadRoot,
|
|
};
|
|
}
|
|
|
|
function appendDiffResourceLinks(output: string, diffUrls: string[]) {
|
|
if (diffUrls.length === 0) {
|
|
return output;
|
|
}
|
|
|
|
const uniqueUrls = diffUrls.filter((url, index) => diffUrls.indexOf(url) === index && !output.includes(url));
|
|
|
|
if (uniqueUrls.length === 0) {
|
|
return output;
|
|
}
|
|
|
|
const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n');
|
|
return `${output}\n\n${hiddenPreviewTags}`;
|
|
}
|
|
|
|
function isPreviewEligibleChatResourceUrl(url: string) {
|
|
const pathname = url.split('?')[0]?.toLowerCase() ?? '';
|
|
return /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|pdf|html?|diff)$/i.test(pathname);
|
|
}
|
|
|
|
function resolveChatResourcePublicUrlToAbsolutePath(repoPath: string, url: string) {
|
|
const normalizedUrl = normalizeEmbeddedChatResourceUrls(url).trim();
|
|
const relativeUrl = normalizedUrl.replace(/^\/+/, '');
|
|
const routePrefix = `${CHAT_API_RESOURCE_ROUTE_PREFIX.replace(/^\/+/, '')}/`;
|
|
|
|
if (!relativeUrl.startsWith(routePrefix)) {
|
|
return null;
|
|
}
|
|
|
|
const encodedResourcePath = relativeUrl.slice(routePrefix.length);
|
|
|
|
try {
|
|
const decodedPath = encodedResourcePath
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.map((segment) => decodeURIComponent(segment))
|
|
.join('/');
|
|
|
|
if (!decodedPath.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
|
|
return null;
|
|
}
|
|
|
|
return path.resolve(repoPath, 'public', decodedPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function doesChatResourceUrlExist(repoPath: string, url: string) {
|
|
const absolutePath = resolveChatResourcePublicUrlToAbsolutePath(repoPath, url);
|
|
|
|
if (!absolutePath) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const fileStat = await stat(absolutePath);
|
|
return fileStat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function sanitizeChatResourcePresentation(output: string, repoPath: string) {
|
|
let sanitized = normalizeEmbeddedChatResourceUrls(output);
|
|
const previewMatches = Array.from(sanitized.matchAll(/\[\[preview:([^\]\n]+)\]\]/gi));
|
|
const previewReplacementMap = new Map<string, string>();
|
|
|
|
for (const match of previewMatches) {
|
|
const marker = match[0] ?? '';
|
|
const rawUrl = match[1]?.trim() ?? '';
|
|
|
|
if (!marker || previewReplacementMap.has(marker)) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedUrl = normalizeEmbeddedChatResourceUrls(rawUrl);
|
|
const exists = await doesChatResourceUrlExist(repoPath, normalizedUrl);
|
|
const keepMarker = exists && isPreviewEligibleChatResourceUrl(normalizedUrl);
|
|
previewReplacementMap.set(marker, keepMarker ? `[[preview:${normalizedUrl}]]` : '');
|
|
}
|
|
|
|
for (const [source, target] of previewReplacementMap.entries()) {
|
|
sanitized = sanitized.replaceAll(source, target);
|
|
}
|
|
|
|
return sanitized.replace(/\n{3,}/g, '\n\n').trim();
|
|
}
|
|
|
|
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
|
|
const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output);
|
|
const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
|
|
const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
|
|
const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
|
|
const candidatePattern = new RegExp(
|
|
`${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
|
|
'g',
|
|
);
|
|
const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)];
|
|
let rewrittenOutput = outputWithoutDiffBlocks;
|
|
if (matches.length > 0) {
|
|
const replacementMap = new Map<string, string>();
|
|
|
|
for (const match of matches) {
|
|
const rawCandidate = match[0]?.trim();
|
|
|
|
if (!rawCandidate || replacementMap.has(rawCandidate)) {
|
|
continue;
|
|
}
|
|
|
|
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
|
|
|
|
if (stagedUrl) {
|
|
replacementMap.set(rawCandidate, stagedUrl);
|
|
}
|
|
}
|
|
|
|
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
|
|
|
|
for (const [sourcePath, publicUrl] of replacements) {
|
|
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
|
|
}
|
|
}
|
|
|
|
rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath);
|
|
rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);
|
|
|
|
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
|
|
return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath);
|
|
}
|
|
|
|
function normalizeChatPromptHistoryText(text: string) {
|
|
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function normalizePromptHistoryMessageLimit(value: number | undefined) {
|
|
if (value === undefined || !Number.isFinite(value)) {
|
|
return CHAT_PROMPT_HISTORY_MAX_MESSAGES;
|
|
}
|
|
|
|
return Math.min(50, Math.max(1, Math.round(value)));
|
|
}
|
|
|
|
function normalizePromptHistoryCharLimit(value: number | undefined) {
|
|
if (value === undefined || !Number.isFinite(value)) {
|
|
return CHAT_PROMPT_HISTORY_MAX_CHARS;
|
|
}
|
|
|
|
return Math.min(20_000, Math.max(500, Math.round(value)));
|
|
}
|
|
|
|
async function buildRecentChatPromptHistory(
|
|
sessionId: string,
|
|
requestId: string,
|
|
limits?: {
|
|
maxMessages?: number;
|
|
maxChars?: number;
|
|
disabled?: boolean;
|
|
},
|
|
) {
|
|
if (limits?.disabled) {
|
|
return {
|
|
items: [],
|
|
omittedCount: 0,
|
|
};
|
|
}
|
|
|
|
const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages);
|
|
const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars);
|
|
const messages = await listChatConversationMessages(sessionId, { limit: 80 });
|
|
const relevantMessages = messages.filter((message: (typeof messages)[number]) => {
|
|
if (message.clientRequestId?.trim() === requestId.trim()) {
|
|
return false;
|
|
}
|
|
|
|
if (message.author !== 'user' && message.author !== 'codex') {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(normalizeChatPromptHistoryText(message.text));
|
|
});
|
|
|
|
const selectedMessages: typeof relevantMessages = [];
|
|
let totalChars = 0;
|
|
|
|
for (let index = relevantMessages.length - 1; index >= 0; index -= 1) {
|
|
const message = relevantMessages[index];
|
|
const text = normalizeChatPromptHistoryText(message.text);
|
|
const nextChars = text.length;
|
|
|
|
if (
|
|
selectedMessages.length >= maxMessages ||
|
|
(selectedMessages.length > 0 && totalChars + nextChars > maxChars)
|
|
) {
|
|
break;
|
|
}
|
|
|
|
selectedMessages.unshift(message);
|
|
totalChars += nextChars;
|
|
}
|
|
|
|
return {
|
|
items: selectedMessages.map(
|
|
(message: (typeof selectedMessages)[number]) => `[${message.author}] ${normalizeChatPromptHistoryText(message.text)}`,
|
|
),
|
|
omittedCount: Math.max(0, relevantMessages.length - selectedMessages.length),
|
|
};
|
|
}
|
|
|
|
function cloneChatContext(context: ChatContext | null): ChatContext | null {
|
|
return context ? { ...context } : null;
|
|
}
|
|
|
|
function resolvePromptChatTypeLabel(context: ChatContext | null) {
|
|
return context?.chatTypeLabel?.trim() || '';
|
|
}
|
|
|
|
function normalizeChatContextIdList(ids?: string[] | null) {
|
|
if (!Array.isArray(ids)) {
|
|
return [];
|
|
}
|
|
|
|
return Array.from(
|
|
new Set(
|
|
ids
|
|
.map((id) => id?.trim() || '')
|
|
.filter(Boolean),
|
|
),
|
|
);
|
|
}
|
|
|
|
function normalizeChatContextEntries(
|
|
entries: ChatContext['defaultContexts'],
|
|
): Array<{ id: string; title: string; content: string }> {
|
|
if (!Array.isArray(entries)) {
|
|
return [];
|
|
}
|
|
|
|
return entries
|
|
.map((entry) => ({
|
|
id: entry?.id?.trim() || '',
|
|
title: entry?.title?.trim() || '',
|
|
content: entry?.content?.trim() || '',
|
|
}))
|
|
.filter((entry) => entry.content);
|
|
}
|
|
|
|
function normalizePromptContextText(value: string | null | undefined, maxLength = 400) {
|
|
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
if (normalized.length <= maxLength) {
|
|
return normalized;
|
|
}
|
|
|
|
return `${normalized.slice(0, maxLength).trimEnd()}...`;
|
|
}
|
|
|
|
function normalizeChatPromptContextRef(
|
|
value: ChatPromptContextRef | null | undefined,
|
|
): ChatPromptContextRef | null {
|
|
if (!value || value.key !== 'prompt_parent_question') {
|
|
return null;
|
|
}
|
|
|
|
const promptTitle = normalizePromptContextText(value.promptTitle, 500);
|
|
|
|
if (!promptTitle) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
key: 'prompt_parent_question',
|
|
promptTitle,
|
|
promptDescription: normalizePromptContextText(value.promptDescription, 1000) || null,
|
|
parentQuestionText: normalizePromptContextText(value.parentQuestionText, 1000) || null,
|
|
};
|
|
}
|
|
|
|
function buildPromptContextInstructionLines(promptContextRef?: ChatPromptContextRef | null) {
|
|
if (!promptContextRef || promptContextRef.key !== 'prompt_parent_question') {
|
|
return [];
|
|
}
|
|
|
|
const lines = ['prompt 문맥 참조:'];
|
|
|
|
if (promptContextRef.parentQuestionText) {
|
|
lines.push(`- 상위 사용자 질의: ${promptContextRef.parentQuestionText}`);
|
|
}
|
|
|
|
lines.push(`- 대상 질의: ${promptContextRef.promptTitle}`);
|
|
|
|
if (promptContextRef.promptDescription) {
|
|
lines.push(`- 질의 설명: ${promptContextRef.promptDescription}`);
|
|
}
|
|
|
|
lines.push('- 위 항목은 말풍선 원문이 아니라 서버가 전달한 참조 문맥입니다. 현재 요청을 해석할 때 함께 참고하세요.');
|
|
return lines;
|
|
}
|
|
|
|
function normalizeCodexParticipants(
|
|
participants: ChatContext['codexParticipants'],
|
|
): Array<{
|
|
id: string;
|
|
name: string;
|
|
model: string;
|
|
prompt: string;
|
|
chatTypeId: string | null;
|
|
defaultContextIds: string[];
|
|
role: CodexParticipantRole;
|
|
}> {
|
|
if (!Array.isArray(participants)) {
|
|
return [];
|
|
}
|
|
|
|
return Array.from(
|
|
new Map(
|
|
participants
|
|
.map((participant, index) => {
|
|
const name = participant?.name?.trim() || '';
|
|
const model = participant?.model?.trim() || '';
|
|
const prompt = participant?.prompt?.trim() || '';
|
|
const chatTypeId = participant?.chatTypeId?.trim() || null;
|
|
const defaultContextIds = normalizeChatContextIdList(participant?.defaultContextIds);
|
|
const role =
|
|
participant?.role === 'moderator'
|
|
? 'moderator'
|
|
: participant?.role === 'conversation'
|
|
? 'conversation'
|
|
: participant?.role === 'reviewer'
|
|
? 'reviewer'
|
|
: 'default';
|
|
const id = participant?.id?.trim() || `codex-participant-${index + 1}`;
|
|
|
|
if (!name || !model) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
id,
|
|
{
|
|
id,
|
|
name,
|
|
model,
|
|
prompt,
|
|
chatTypeId,
|
|
defaultContextIds,
|
|
role,
|
|
},
|
|
] as const;
|
|
})
|
|
.filter(
|
|
(
|
|
item,
|
|
): item is readonly [
|
|
string,
|
|
{
|
|
id: string;
|
|
name: string;
|
|
model: string;
|
|
prompt: string;
|
|
chatTypeId: string | null;
|
|
defaultContextIds: string[];
|
|
role: CodexParticipantRole;
|
|
},
|
|
] => Boolean(item),
|
|
),
|
|
).values(),
|
|
);
|
|
}
|
|
|
|
function buildStructuredChatContextSections(context: ChatContext | null) {
|
|
const sections: string[] = [];
|
|
const baseDescription = context?.chatTypeBaseDescription?.trim() || '';
|
|
const defaultContexts = normalizeChatContextEntries(context?.defaultContexts);
|
|
const customContextTitle = context?.customContextTitle?.trim() || '';
|
|
const customContextContent = context?.customContextContent?.trim() || '';
|
|
|
|
if (baseDescription) {
|
|
sections.push(['## 채팅 유형 context 원문', baseDescription].join('\n'));
|
|
}
|
|
|
|
if (defaultContexts.length > 0) {
|
|
sections.push(
|
|
[
|
|
'## 채팅방에서 선택한 공통 문맥',
|
|
'- 아래 공통 문맥은 채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요.',
|
|
...defaultContexts.map((entry) =>
|
|
[`### ${entry.title || '공통 문맥'}`, entry.content].filter(Boolean).join('\n'),
|
|
),
|
|
].join('\n\n'),
|
|
);
|
|
}
|
|
|
|
if (customContextTitle || customContextContent) {
|
|
sections.push(
|
|
[
|
|
`## 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`,
|
|
'- 아래 채팅방 전용 메모는 채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요.',
|
|
customContextContent,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
);
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
function resolveChatTypeExecutionPolicy(context: ChatContext | null) {
|
|
return context?.chatTypeExecutionPolicy ?? createDefaultChatTypeExecutionPolicy();
|
|
}
|
|
|
|
function buildChatSessionReferenceContextSummary(context: ChatContext | null) {
|
|
const lines: string[] = [];
|
|
const defaultContexts = normalizeChatContextEntries(context?.defaultContexts);
|
|
const customContextTitle = context?.customContextTitle?.trim() || '';
|
|
const customContextContent = context?.customContextContent?.trim() || '';
|
|
|
|
lines.push('## 현재 채팅 유형 context 요약');
|
|
lines.push('- 이 문서에는 채팅방 전용 메모와 현재 적용 중인 context 식별 정보만 짧게 유지합니다.');
|
|
lines.push('- 채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다.');
|
|
lines.push('- 공통 문맥 상세 원문은 공통 문맥 관리 데이터와 실행 prompt 본문을 기준으로 확인합니다.');
|
|
|
|
if (defaultContexts.length > 0) {
|
|
lines.push('');
|
|
lines.push('### 적용 중인 공통 문맥');
|
|
lines.push(...defaultContexts.map((entry) => `- ${entry.title || entry.id || '공통 문맥'}`));
|
|
}
|
|
|
|
if (customContextTitle || customContextContent) {
|
|
lines.push('');
|
|
lines.push(`### 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`);
|
|
lines.push(customContextContent || '- 전용 메모 본문 없음');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function composeResolvedChatTypeDescription(
|
|
baseDescription: string,
|
|
defaultContexts: Array<{ id: string; title: string; content: string }>,
|
|
customContextTitle?: string | null,
|
|
customContextContent?: string | null,
|
|
) {
|
|
const sections = [baseDescription.trim()].filter(Boolean);
|
|
|
|
defaultContexts.forEach((context) => {
|
|
const normalizedContent = context.content.trim();
|
|
|
|
if (!normalizedContent) {
|
|
return;
|
|
}
|
|
|
|
sections.push(`## 기본 유형 · ${context.title || '공통 문맥'}\n${normalizedContent}`);
|
|
});
|
|
|
|
const normalizedCustomContextTitle = customContextTitle?.trim() || '';
|
|
const normalizedCustomContextContent = customContextContent?.trim() || '';
|
|
|
|
if (normalizedCustomContextTitle || normalizedCustomContextContent) {
|
|
sections.push(
|
|
[`## 채팅방 전용 Context${normalizedCustomContextTitle ? ` · ${normalizedCustomContextTitle}` : ''}`, normalizedCustomContextContent]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
);
|
|
}
|
|
|
|
return sections.join('\n\n').trim();
|
|
}
|
|
|
|
export async function resolveCodexLiveChatContext(context: ChatContext | null, sessionId?: string | null) {
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedChatTypeId = context.chatTypeId?.trim() || null;
|
|
|
|
if (!normalizedChatTypeId) {
|
|
return cloneChatContext(context);
|
|
}
|
|
|
|
const [chatTypesConfig, chatContextSettings] = await Promise.all([
|
|
getChatTypesConfig(),
|
|
getChatContextSettingsConfig(),
|
|
]);
|
|
const resolvedChatType =
|
|
chatTypesConfig.chatTypes.find((item) => item.id === normalizedChatTypeId && item.enabled) ?? null;
|
|
const roomContext =
|
|
chatContextSettings.roomContexts.find((item) => item.sessionId === (sessionId?.trim() || '')) ?? null;
|
|
const explicitDefaultContextIds = normalizeChatContextIdList(context.defaultContextIds);
|
|
const chatTypeDefaultContextIds =
|
|
chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds ?? [];
|
|
const resolvedDefaultContextIds =
|
|
explicitDefaultContextIds.length > 0
|
|
? explicitDefaultContextIds
|
|
: roomContext?.defaultContextIds.length
|
|
? roomContext.defaultContextIds
|
|
: chatTypeDefaultContextIds;
|
|
const resolvedDefaultContexts = resolvedDefaultContextIds
|
|
.map((contextId) =>
|
|
chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled),
|
|
)
|
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
|
.map((item) => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
content: item.content,
|
|
}));
|
|
const resolvedCustomContextTitle = roomContext?.customContextTitle?.trim() || '';
|
|
const resolvedCustomContextContent = roomContext?.customContextContent?.trim() || '';
|
|
const resolvedCodexParticipants = normalizeCodexParticipants(roomContext?.codexParticipants ?? context.codexParticipants);
|
|
const resolvedBaseDescription = resolvedChatType?.description?.trim() || context.chatTypeBaseDescription?.trim() || '';
|
|
const resolvedDescription =
|
|
composeResolvedChatTypeDescription(
|
|
resolvedBaseDescription,
|
|
resolvedDefaultContexts,
|
|
resolvedCustomContextTitle,
|
|
resolvedCustomContextContent,
|
|
) ||
|
|
context.chatTypeDescription?.trim() ||
|
|
resolvedBaseDescription;
|
|
|
|
return {
|
|
...context,
|
|
chatTypeId: normalizedChatTypeId,
|
|
chatTypeLabel: resolvedChatType?.name ?? context.chatTypeLabel ?? '',
|
|
chatTypeDescription: resolvedDescription,
|
|
chatTypeBaseDescription: resolvedBaseDescription,
|
|
chatTypeExecutionPolicy:
|
|
resolvedChatType?.executionPolicy ?? context.chatTypeExecutionPolicy ?? createDefaultChatTypeExecutionPolicy(),
|
|
codexModel: (resolvedCodexParticipants[0]?.model ?? context.codexModel?.trim()) || null,
|
|
codexParticipants: resolvedCodexParticipants,
|
|
defaultContextIds: resolvedDefaultContextIds,
|
|
defaultContexts: resolvedDefaultContexts,
|
|
customContextTitle: resolvedCustomContextTitle || null,
|
|
customContextContent: resolvedCustomContextContent || null,
|
|
} satisfies ChatContext;
|
|
}
|
|
|
|
function buildChatSessionReferenceAutoSection(args: {
|
|
context: ChatContext | null;
|
|
sessionId: string;
|
|
requestId: string;
|
|
requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled';
|
|
requestedAt?: Date | null;
|
|
completedAt?: Date | null;
|
|
input?: string;
|
|
}) {
|
|
const chatTypeLabel = resolvePromptChatTypeLabel(args.context) || '없음';
|
|
const pageTitle = args.context?.pageTitle?.trim() || '없음';
|
|
const topMenu = args.context?.topMenu?.trim() || '없음';
|
|
const pageUrl = args.context?.pageUrl?.trim() || '없음';
|
|
const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
|
|
const codexModel = args.context?.codexModel?.trim() || '기본값';
|
|
const codexParticipants = normalizeCodexParticipants(args.context?.codexParticipants);
|
|
const requestStatusLabel =
|
|
args.requestStatus === 'completed'
|
|
? '완료'
|
|
: args.requestStatus === 'failed'
|
|
? '실패'
|
|
: args.requestStatus === 'cancelled'
|
|
? '취소'
|
|
: '실행 중';
|
|
|
|
return [
|
|
CHAT_SESSION_REFERENCE_AUTO_START,
|
|
'## 자동 갱신 문맥',
|
|
`- 마지막 갱신 시각: ${formatTime(new Date())}`,
|
|
`- sessionId: ${args.sessionId}`,
|
|
`- requestId: ${args.requestId}`,
|
|
`- 요청 상태: ${requestStatusLabel}`,
|
|
`- 요청 시작 시각: ${formatTime(args.requestedAt ?? new Date())}`,
|
|
...(args.completedAt ? [`- 요청 종료 시각: ${formatTime(args.completedAt)}`] : []),
|
|
`- 채팅 유형: ${chatTypeLabel}`,
|
|
`- Codex 모델: ${codexModel}`,
|
|
...(codexParticipants.length > 0
|
|
? [
|
|
`- Codex 참가자: ${codexParticipants
|
|
.map((participant) =>
|
|
`${participant.name}(${participant.model}${participant.role !== 'default' ? `, 역할:${participant.role}` : ''}${participant.chatTypeId ? `, 유형:${participant.chatTypeId}` : ''})`,
|
|
)
|
|
.join(', ')}`,
|
|
]
|
|
: []),
|
|
`- 화면 제목: ${pageTitle}`,
|
|
`- topMenu: ${topMenu}`,
|
|
`- focusedComponentId: ${focusedComponentId}`,
|
|
`- pageUrl: ${pageUrl}`,
|
|
'',
|
|
buildChatSessionReferenceContextSummary(args.context),
|
|
CHAT_SESSION_REFERENCE_AUTO_END,
|
|
].join('\n');
|
|
}
|
|
|
|
function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) {
|
|
const trimmedExisting = existingContent.trim();
|
|
const defaultHeader = [
|
|
'# 채팅방 참고 리소스',
|
|
'',
|
|
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
|
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
|
|
].join('\n');
|
|
|
|
if (!trimmedExisting) {
|
|
return `${defaultHeader}\n\n${autoSection}\n`;
|
|
}
|
|
|
|
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
|
|
|
|
if (firstAutoStartIndex >= 0) {
|
|
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
|
|
return `${preservedHeader}\n\n${autoSection}\n`;
|
|
}
|
|
|
|
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
|
|
}
|
|
|
|
async function writeChatSessionReferenceContentSafely(absolutePath: string, content: string) {
|
|
try {
|
|
await writeFile(absolutePath, content, 'utf8');
|
|
return;
|
|
} catch (error) {
|
|
if (
|
|
!error ||
|
|
typeof error !== 'object' ||
|
|
!('code' in error) ||
|
|
(error.code !== 'EACCES' && error.code !== 'EPERM')
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
await rm(absolutePath, { force: true });
|
|
await writeFile(absolutePath, content, 'utf8');
|
|
}
|
|
|
|
export async function ensureChatSessionReferenceResource(args: {
|
|
repoPath: string;
|
|
sessionId: string;
|
|
requestId: string;
|
|
context: ChatContext | null;
|
|
requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled';
|
|
requestedAt?: Date | null;
|
|
completedAt?: Date | null;
|
|
input: string;
|
|
recentHistoryLines: string[];
|
|
omittedHistoryCount: number;
|
|
}) {
|
|
const ensuredDirectories = await ensureChatSessionResourceDirectories(args.repoPath, args.sessionId);
|
|
const resourceRelativePath = `public/.codex_chat/${args.sessionId}/resource/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`;
|
|
const absolutePath = path.join(ensuredDirectories.sourceRoot, 'chat-room-reference.md');
|
|
const autoSection = buildChatSessionReferenceAutoSection({
|
|
context: args.context,
|
|
sessionId: args.sessionId,
|
|
requestId: args.requestId,
|
|
requestStatus: args.requestStatus,
|
|
requestedAt: args.requestedAt,
|
|
completedAt: args.completedAt,
|
|
input: args.input,
|
|
});
|
|
|
|
let existingContent = '';
|
|
|
|
try {
|
|
existingContent = await readFile(absolutePath, 'utf8');
|
|
} catch {
|
|
existingContent = '';
|
|
}
|
|
|
|
const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection);
|
|
|
|
if (nextContent !== existingContent) {
|
|
await writeChatSessionReferenceContentSafely(absolutePath, nextContent);
|
|
}
|
|
|
|
return resourceRelativePath;
|
|
}
|
|
|
|
async function refreshChatSessionReferenceForRequest(args: {
|
|
sessionId: string;
|
|
requestId: string;
|
|
context: ChatContext | null;
|
|
input: string;
|
|
requestStatus: 'started' | 'completed' | 'failed' | 'cancelled';
|
|
requestedAt: Date;
|
|
completedAt?: Date | null;
|
|
}) {
|
|
const repoPath = resolveMainProjectRoot();
|
|
await ensureChatSessionReferenceResource({
|
|
repoPath,
|
|
sessionId: args.sessionId,
|
|
requestId: args.requestId,
|
|
context: args.context,
|
|
requestStatus: args.requestStatus,
|
|
requestedAt: args.requestedAt,
|
|
completedAt: args.completedAt ?? null,
|
|
input: args.input,
|
|
recentHistoryLines: [],
|
|
omittedHistoryCount: 0,
|
|
});
|
|
}
|
|
|
|
function buildChatTypeInstructionBlock(context: ChatContext | null) {
|
|
const chatTypeLabel = resolvePromptChatTypeLabel(context);
|
|
const chatTypeDescription = context?.chatTypeDescription?.trim() || '';
|
|
const structuredSections = buildStructuredChatContextSections(context);
|
|
const hasSpecificChatType = Boolean(chatTypeLabel);
|
|
const hasContextDescription = Boolean(
|
|
structuredSections.length > 0 || (chatTypeDescription && chatTypeDescription !== '없음'),
|
|
);
|
|
|
|
if (!hasSpecificChatType && !hasContextDescription) {
|
|
return [
|
|
'## 채팅 유형 context 필수 규칙',
|
|
'- 선택된 채팅 유형 context가 없습니다.',
|
|
'- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'## 채팅 유형 context 필수 규칙',
|
|
'- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.',
|
|
'- 우선순위는 1. 채팅 유형 context 2. 현재 턴의 직접 사용자 지시 3. 채팅방에서 선택한 공통 문맥과 전용 메모 4. 최근 대화 문맥과 화면 문맥 순서로 해석하세요.',
|
|
'- 사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.',
|
|
'- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.',
|
|
'- 공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며, 충돌하지 않는 범위에서만 보조로 적용하세요.',
|
|
'- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.',
|
|
'- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.',
|
|
'',
|
|
'### 선택된 채팅 유형',
|
|
`- label: ${chatTypeLabel || '없음'}`,
|
|
'',
|
|
'### 반드시 지킬 context 원문',
|
|
structuredSections.length > 0
|
|
? structuredSections.join('\n\n')
|
|
: (chatTypeDescription || '선택된 채팅 유형 context 원문 없음'),
|
|
];
|
|
}
|
|
|
|
function buildChatSessionReferenceInstructionBlock(referenceContent?: string | null) {
|
|
const normalizedContent = referenceContent?.trim() || '';
|
|
|
|
if (!normalizedContent) {
|
|
return [
|
|
'## 채팅방 참고 문서',
|
|
'- 현재 채팅방 참고 문서 본문을 불러오지 못했습니다.',
|
|
'- 그래도 위에 제공된 참고 문서 경로를 우선 확인하고, 채팅 유형 context와 AGENTS.md 규칙을 먼저 따르세요.',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'## 채팅방 참고 문서',
|
|
'- 아래는 이 채팅방에서 시작 전에 먼저 읽어야 하는 참고 문서 원문입니다.',
|
|
'- 이 문서의 지시와 메모를 현재 요청 해석에 즉시 반영하세요.',
|
|
'',
|
|
normalizedContent,
|
|
];
|
|
}
|
|
|
|
export function buildAgenticCodexPrompt(
|
|
context: ChatContext | null,
|
|
input: string,
|
|
sessionId: string,
|
|
promptContext?: {
|
|
repoPath?: string;
|
|
recentHistoryLines?: string[];
|
|
omittedHistoryCount?: number;
|
|
sessionReferenceResourcePath?: string;
|
|
sessionReferenceContent?: string;
|
|
},
|
|
) {
|
|
const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot();
|
|
const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`;
|
|
const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`;
|
|
const sessionReferenceResourcePath =
|
|
promptContext?.sessionReferenceResourcePath || `${chatSessionResourceDir}/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`;
|
|
const recentHistoryLines = promptContext?.recentHistoryLines ?? [];
|
|
const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0);
|
|
|
|
return [
|
|
'당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.',
|
|
'Codex Live는 Plan 자동화와 별개입니다. 자동화 유형 context를 Codex Live 기본 문맥으로 섞지 마세요.',
|
|
`저장소 루트(main_project): ${repoPath}`,
|
|
'반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
|
|
'가능한 작업 범위:',
|
|
'- 로컬 소스 파일 읽기',
|
|
'- 필요 시 DB 직접 조회',
|
|
'- 필요 시 로컬 API 응답 확인',
|
|
'- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정',
|
|
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.',
|
|
`- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`,
|
|
`- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`,
|
|
`- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`,
|
|
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
|
|
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
|
|
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
|
|
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
|
|
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
|
|
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
|
|
...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent),
|
|
'',
|
|
...buildChatTypeInstructionBlock(context),
|
|
'',
|
|
'응답 규칙:',
|
|
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
|
|
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
|
|
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
|
|
'- 참고 화면 정보의 `pageTitle`과 `pageUrl`은 현재 열려 있는 Codex Live 채팅 컨테이너 정보일 수 있습니다. 리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면/메뉴 기준으로 판단하세요.',
|
|
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
|
|
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
|
|
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
|
|
'- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 실제 `.html` 리소스를 준비한 뒤 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 또는 `preview":{"type":"resource","url":"resource/<수정한 화면명>/<기능>/<YYYYMMDD>/sample.html"}` 형태를 사용하세요. 리소스 관리에 등록된 `resource/...` 경로는 자동으로 `/api/resource-manager/preview/...`로 해석됩니다. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
|
|
'- 변경된 소스 파일이 있는 경우에만 최종 답변에 변경 이력을 ```diff 코드블록으로 포함하세요.',
|
|
'- 변경된 소스 파일이 있으면 마지막에 해당 파일 경로를 짧게 적으세요.',
|
|
'- 한국어로 간결하게 답하세요.',
|
|
'',
|
|
'현재 채팅 컨테이너 참고 정보:',
|
|
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
|
|
`- topMenu: ${context?.topMenu ?? '없음'}`,
|
|
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
|
|
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
|
|
'',
|
|
'최근 대화 문맥(보조 참조):',
|
|
...(recentHistoryLines.length > 0
|
|
? [
|
|
...recentHistoryLines.map((line) => `- ${line}`),
|
|
...(omittedHistoryCount > 0
|
|
? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
|
|
: []),
|
|
]
|
|
: ['- 참조할 최근 대화가 없습니다.']),
|
|
'',
|
|
'사용자 요청:',
|
|
input,
|
|
'',
|
|
'최종 답변만 출력하세요.',
|
|
].join('\n');
|
|
}
|
|
|
|
export async function validateAgenticCodexRuntime(repoPath: string, codexBin: string) {
|
|
const issues: string[] = [];
|
|
|
|
try {
|
|
const repoStat = await stat(repoPath);
|
|
|
|
if (!repoStat.isDirectory()) {
|
|
issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로가 디렉터리가 아닙니다: ${repoPath}`);
|
|
}
|
|
} catch {
|
|
issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로를 찾지 못했습니다: ${repoPath}`);
|
|
}
|
|
|
|
const runnerCandidates = buildCommandRunnerApiCandidates('/health');
|
|
const headers = new Headers();
|
|
|
|
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim()) {
|
|
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
|
|
}
|
|
|
|
let runnerReachable = false;
|
|
|
|
for (const candidate of runnerCandidates) {
|
|
try {
|
|
const response = await fetch(candidate, {
|
|
method: 'GET',
|
|
headers,
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
if (response.ok) {
|
|
runnerReachable = true;
|
|
break;
|
|
}
|
|
} catch {
|
|
// try next candidate
|
|
}
|
|
}
|
|
|
|
if (!runnerReachable) {
|
|
issues.push(`SERVER_COMMAND_RUNNER_URL 경로로 Codex runner에 연결하지 못했습니다: ${env.SERVER_COMMAND_RUNNER_URL}`);
|
|
}
|
|
|
|
if (issues.length > 0) {
|
|
throw new Error(`채팅 실행 환경이 준비되지 않았습니다. ${issues.join(' / ')}`);
|
|
}
|
|
}
|
|
|
|
function normalizeRunnerUrl(value: string) {
|
|
return value.trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
function buildCommandRunnerApiCandidates(requestPath: string) {
|
|
const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health';
|
|
|
|
let parsedUrl: URL;
|
|
|
|
try {
|
|
parsedUrl = new URL(configuredHealthUrl);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const hostVariants =
|
|
parsedUrl.hostname === 'host.docker.internal'
|
|
? ['host.docker.internal', '127.0.0.1', 'localhost']
|
|
: parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost'
|
|
? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal']
|
|
: [parsedUrl.hostname];
|
|
|
|
const deduped: string[] = [];
|
|
|
|
for (const hostname of hostVariants) {
|
|
const candidate = new URL(parsedUrl.toString());
|
|
candidate.hostname = hostname;
|
|
candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
|
|
candidate.search = '';
|
|
candidate.hash = '';
|
|
const serialized = normalizeRunnerUrl(candidate.toString());
|
|
|
|
if (!deduped.includes(serialized)) {
|
|
deduped.push(serialized);
|
|
}
|
|
}
|
|
|
|
return deduped;
|
|
}
|
|
|
|
async function requestCommandRunner(requestPath: string, init?: RequestInit) {
|
|
const headers = new Headers(init?.headers);
|
|
|
|
if (init?.body != null && !headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
|
|
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) {
|
|
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
|
|
}
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
for (const url of buildCommandRunnerApiCandidates(requestPath)) {
|
|
try {
|
|
return await fetch(url, {
|
|
...init,
|
|
headers,
|
|
});
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
|
|
}
|
|
|
|
async function cancelRunnerCodexExecution(requestId: string) {
|
|
try {
|
|
const response = await requestCommandRunner(`/api/codex-live/jobs/${encodeURIComponent(requestId)}/cancel`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return false;
|
|
}
|
|
|
|
const payload = (await response.json().catch(() => null)) as { cancelled?: boolean } | null;
|
|
return payload?.cancelled === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runAgenticCodexReply(
|
|
context: ChatContext | null,
|
|
input: string,
|
|
sessionId: string,
|
|
requestId: string,
|
|
options?: {
|
|
omitPromptHistory?: boolean;
|
|
requestedAt?: Date | null;
|
|
},
|
|
onProgress?: (text: string) => void,
|
|
onActivity?: (line: string) => void,
|
|
isCancellationRequested?: () => boolean,
|
|
): Promise<ChatCodexReplyResult> {
|
|
const repoPath = resolveMainProjectRoot();
|
|
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
|
const resolvedContext = await resolveCodexLiveChatContext(context, sessionId);
|
|
const appConfig = await getAppConfigSnapshot();
|
|
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
|
|
maxMessages: appConfig.chat?.maxContextMessages,
|
|
maxChars: appConfig.chat?.maxContextChars,
|
|
disabled: options?.omitPromptHistory === true,
|
|
});
|
|
const codexLiveMaxExecutionSeconds =
|
|
typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' &&
|
|
Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds)
|
|
? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds)))
|
|
: 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 requestedAt = options?.requestedAt ?? new Date();
|
|
const syncSessionReference = async (
|
|
requestStatus: 'started' | 'completed' | 'failed' | 'cancelled',
|
|
completedAt?: Date | null,
|
|
) =>
|
|
ensureChatSessionReferenceResource({
|
|
repoPath,
|
|
sessionId,
|
|
requestId,
|
|
context: resolvedContext,
|
|
requestStatus,
|
|
requestedAt,
|
|
completedAt: completedAt ?? null,
|
|
input,
|
|
recentHistoryLines: recentHistory.items,
|
|
omittedHistoryCount: recentHistory.omittedCount,
|
|
});
|
|
const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({
|
|
repoPath,
|
|
sessionId,
|
|
requestId,
|
|
context: resolvedContext,
|
|
requestStatus: 'started',
|
|
requestedAt,
|
|
input,
|
|
recentHistoryLines: recentHistory.items,
|
|
omittedHistoryCount: recentHistory.omittedCount,
|
|
});
|
|
const sessionReferenceAbsolutePath = path.join(repoPath, sessionReferenceResourcePath);
|
|
let sessionReferenceContent = '';
|
|
|
|
try {
|
|
sessionReferenceContent = await readFile(sessionReferenceAbsolutePath, 'utf8');
|
|
} catch {
|
|
sessionReferenceContent = '';
|
|
}
|
|
const prompt = buildAgenticCodexPrompt(resolvedContext, input, sessionId, {
|
|
repoPath,
|
|
recentHistoryLines: recentHistory.items,
|
|
omittedHistoryCount: recentHistory.omittedCount,
|
|
sessionReferenceResourcePath,
|
|
sessionReferenceContent,
|
|
});
|
|
let streamedOutput = '';
|
|
let stdoutTail = '';
|
|
let stderr = '';
|
|
let jsonLineBuffer = '';
|
|
let lastProgressText = '';
|
|
let completedAgentMessage = '';
|
|
let streamedUsageSnapshot: ChatConversationRequestUsageSnapshot | null = null;
|
|
let hasIncrementalDelta = false;
|
|
const finalizeReplyOutput = async () => {
|
|
const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail);
|
|
const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId);
|
|
const usageSnapshot =
|
|
streamedUsageSnapshot ??
|
|
extractChatRequestUsageSnapshot([completedAgentMessage, stdoutTail, stderr].filter(Boolean).join('\n'));
|
|
|
|
if (!rewrittenOutput) {
|
|
return {
|
|
text: '',
|
|
usageSnapshot,
|
|
totalTokens: usageSnapshot?.totalTokens ?? null,
|
|
};
|
|
}
|
|
|
|
// 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 {
|
|
text: rewrittenOutput,
|
|
usageSnapshot,
|
|
totalTokens: usageSnapshot?.totalTokens ?? null,
|
|
};
|
|
};
|
|
const throwIfCancelled = async () => {
|
|
if (!isCancellationRequested?.()) {
|
|
return;
|
|
}
|
|
|
|
await cancelRunnerCodexExecution(requestId).catch(() => false);
|
|
throw new Error('CHAT_RUNTIME_CANCELLED');
|
|
};
|
|
|
|
await throwIfCancelled();
|
|
activeChatProcessRegistry.set(requestId, {
|
|
cancel: async () => {
|
|
const cancelled = await cancelRunnerCodexExecution(requestId);
|
|
return cancelled || isCancellationRequested?.() === true;
|
|
},
|
|
});
|
|
|
|
chatRuntimeService.appendLog(
|
|
requestId,
|
|
`실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`,
|
|
);
|
|
onActivity?.(
|
|
`# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`,
|
|
);
|
|
|
|
try {
|
|
await new Promise<void>(async (resolve, reject) => {
|
|
const emitProgress = (nextText: string) => {
|
|
const normalizedProgress = nextText.trim();
|
|
|
|
if (!normalizedProgress || normalizedProgress === lastProgressText) {
|
|
return;
|
|
}
|
|
|
|
lastProgressText = normalizedProgress;
|
|
streamedOutput = normalizedProgress;
|
|
onProgress?.(normalizedProgress);
|
|
};
|
|
|
|
try {
|
|
await throwIfCancelled();
|
|
const response = await requestCommandRunner('/api/codex-live/execute', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
requestId,
|
|
sessionId,
|
|
repoPath,
|
|
prompt,
|
|
model: context?.codexModel?.trim() || null,
|
|
resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
|
|
uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'),
|
|
maxExecutionSeconds: codexLiveMaxExecutionSeconds,
|
|
idleTimeoutSeconds: codexLiveIdleTimeoutSeconds,
|
|
}),
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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 model = typeof parsed.model === 'string' && parsed.model.trim() ? parsed.model.trim() : 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 (model) {
|
|
chatRuntimeService.appendLog(requestId, `선택 모델: ${model}`);
|
|
}
|
|
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 === 'attached') {
|
|
const attachedRequestId = String(parsed.requestId ?? '').trim();
|
|
const completed = parsed.completed === true;
|
|
const attachSummary =
|
|
attachedRequestId && attachedRequestId === requestId
|
|
? completed
|
|
? '기존 command-runner 실행 이력을 다시 연결했습니다.'
|
|
: '기존 command-runner 실행에 재부착했습니다.'
|
|
: completed
|
|
? '기존 command-runner 응답 이력을 다시 연결했습니다.'
|
|
: '기존 command-runner 실행 스트림에 다시 연결했습니다.';
|
|
chatRuntimeService.appendLog(requestId, attachSummary);
|
|
onActivity?.(`# ${attachSummary}`);
|
|
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();
|
|
const usageSnapshot =
|
|
parsed.usageSnapshot && typeof parsed.usageSnapshot === 'object'
|
|
? (parsed.usageSnapshot as ChatConversationRequestUsageSnapshot)
|
|
: null;
|
|
|
|
if (usageSnapshot) {
|
|
streamedUsageSnapshot = usageSnapshot;
|
|
}
|
|
|
|
if (completedAgentMessage && hasIncrementalDelta) {
|
|
emitProgress(completedAgentMessage);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'usage') {
|
|
const usageSnapshot =
|
|
parsed.usageSnapshot && typeof parsed.usageSnapshot === 'object'
|
|
? (parsed.usageSnapshot as ChatConversationRequestUsageSnapshot)
|
|
: null;
|
|
|
|
if (usageSnapshot) {
|
|
streamedUsageSnapshot = usageSnapshot;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'stdout') {
|
|
const stdoutLine = String(parsed.line ?? '').trim();
|
|
|
|
if (stdoutLine) {
|
|
const structuredStdout = parseStructuredCodexStdoutLine(stdoutLine);
|
|
|
|
if (!structuredStdout.shouldKeepRaw) {
|
|
if (structuredStdout.activityLog) {
|
|
chatRuntimeService.appendLog(requestId, structuredStdout.activityLog);
|
|
onActivity?.(structuredStdout.activityLog);
|
|
}
|
|
|
|
if (structuredStdout.deltaText) {
|
|
hasIncrementalDelta = true;
|
|
emitProgress(`${streamedOutput}${structuredStdout.deltaText}`);
|
|
}
|
|
|
|
if (structuredStdout.completedText) {
|
|
completedAgentMessage = structuredStdout.completedText.trim();
|
|
|
|
if (completedAgentMessage && hasIncrementalDelta) {
|
|
emitProgress(completedAgentMessage);
|
|
}
|
|
}
|
|
|
|
if (structuredStdout.usageSnapshot) {
|
|
streamedUsageSnapshot = structuredStdout.usageSnapshot;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
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();
|
|
if (trailingLine) {
|
|
handleRunnerLine(trailingLine);
|
|
}
|
|
|
|
if (remoteErrorMessage) {
|
|
reject(new Error(remoteErrorMessage));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
const completedAt = new Date();
|
|
await syncSessionReference(error instanceof Error && error.message === 'CHAT_RUNTIME_CANCELLED' ? 'cancelled' : 'failed', completedAt);
|
|
const failureResponseText = await finalizeReplyOutput();
|
|
|
|
if (failureResponseText.text) {
|
|
throw new ChatRuntimeExecutionError(
|
|
error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.',
|
|
failureResponseText.text,
|
|
failureResponseText.usageSnapshot,
|
|
failureResponseText.totalTokens,
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
await syncSessionReference('completed', new Date());
|
|
return await finalizeReplyOutput();
|
|
}
|
|
|
|
async function getTodayAutomationRegistrationCounts() {
|
|
const [planCountResult, boardReceivedCountResult] = await Promise.all([
|
|
db(PLAN_TABLE)
|
|
.whereNot('automation_type', 'none')
|
|
.whereRaw("(created_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE])
|
|
.count<{ count: string }>('* as count')
|
|
.first(),
|
|
db(BOARD_POSTS_TABLE)
|
|
.whereNotNull('automation_received_at')
|
|
.whereRaw(
|
|
"(automation_received_at at time zone ?)::date = (current_timestamp at time zone ?)::date",
|
|
[KST_TIME_ZONE, KST_TIME_ZONE],
|
|
)
|
|
.count<{ count: string }>('* as count')
|
|
.first(),
|
|
]);
|
|
|
|
return {
|
|
today: formatKstDate(),
|
|
planCount: Number(planCountResult?.count ?? 0),
|
|
boardReceivedCount: Number(boardReceivedCountResult?.count ?? 0),
|
|
};
|
|
}
|
|
|
|
async function buildAutomationRegistrationCountReply() {
|
|
const counts = await getTodayAutomationRegistrationCounts();
|
|
const lines = [
|
|
'결과',
|
|
`- ${counts.today} KST 기준 오늘 자동화 등록 총 건수는 ${counts.planCount}건입니다.`,
|
|
'- 기본 기준: `plan_items`에서 `automation_type <> \'none\'` 이고 오늘 생성된 건수',
|
|
];
|
|
|
|
if (counts.planCount === counts.boardReceivedCount) {
|
|
lines.push(`- 교차 확인: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수도 ${counts.boardReceivedCount}건으로 같습니다.`);
|
|
} else {
|
|
lines.push(`- 참고: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수는 ${counts.boardReceivedCount}건입니다.`);
|
|
lines.push('- 이 프로젝트는 게시판 등록, 자동화 접수, Plan 생성이 분리될 수 있어 기준에 따라 숫자가 달라질 수 있습니다.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function buildAutomationRegistrationDefinitionReply() {
|
|
return [
|
|
'결과',
|
|
'- 이 프로젝트에서 `자동화 등록`은 하나로 고정된 용어가 아닙니다.',
|
|
'- `board_posts.created_at`: 게시판 등록',
|
|
'- `board_posts.automation_received_at`: 자동화 접수',
|
|
'- `plan_items.created_at` + `automation_type <> \'none\'`: 실제 자동화 대상 Plan 생성',
|
|
'- 건수 질문이면 어떤 기준인지 먼저 구분하고, 모호하면 기본적으로 Plan 생성 기준을 우선 안내합니다.',
|
|
].join('\n');
|
|
}
|
|
|
|
function buildProgressMessages(input: string) {
|
|
const messages = ['요청을 분석하고 있습니다.'];
|
|
|
|
if (isAutomationRegistrationCountRequest(input)) {
|
|
messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.');
|
|
return messages;
|
|
}
|
|
|
|
if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) {
|
|
messages.push('DB와 집계 기준을 확인하고 있습니다.');
|
|
}
|
|
|
|
if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) {
|
|
messages.push('API 경로와 실제 응답을 확인하고 있습니다.');
|
|
}
|
|
|
|
if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) {
|
|
messages.push('관련 소스와 연결 흐름을 확인하고 있습니다.');
|
|
}
|
|
|
|
return [...new Set(messages)];
|
|
}
|
|
|
|
function normalizeProgressSummary(text: string) {
|
|
return text.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function shouldSkipActivityProgressSummary(text: string) {
|
|
return (
|
|
!text ||
|
|
/^요청을 처리합니다\./.test(text) ||
|
|
/^응답 생성이 완료되었습니다\.$/.test(text) ||
|
|
/^완료(?:\(\d+\))?$/i.test(text) ||
|
|
/^종료\(\d+\)$/i.test(text)
|
|
);
|
|
}
|
|
|
|
export function summarizeActivityProgressLine(activityLine: string) {
|
|
const lines = String(activityLine ?? '')
|
|
.split('\n')
|
|
.map((line) => normalizeProgressSummary(line))
|
|
.filter(Boolean);
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('# 이유:')) {
|
|
const summary = normalizeProgressSummary(line.slice('# 이유:'.length));
|
|
if (!shouldSkipActivityProgressSummary(summary)) {
|
|
return summary;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const line of lines) {
|
|
for (const prefix of ['# 진행:', '# 상태:', '# 경고:']) {
|
|
if (!line.startsWith(prefix)) {
|
|
continue;
|
|
}
|
|
|
|
const summary = normalizeProgressSummary(line.slice(prefix.length));
|
|
if (!shouldSkipActivityProgressSummary(summary)) {
|
|
return summary;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function stripActivityLinePrefix(line: string) {
|
|
return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim();
|
|
}
|
|
|
|
function resolveCompactActivityStageLineNo(summary: string, normalizedLine: string) {
|
|
if (!summary) {
|
|
return null;
|
|
}
|
|
|
|
if (/^#\s*오류:/u.test(normalizedLine) || /오류|실패|중단/u.test(summary)) {
|
|
return 5;
|
|
}
|
|
|
|
if (/요청을 처리합니다|대기열|즉시 요청 실행/u.test(summary)) {
|
|
return 1;
|
|
}
|
|
|
|
if (/분석|의도|문맥|생각 중/u.test(summary)) {
|
|
return 2;
|
|
}
|
|
|
|
if (/\bdb\b|데이터베이스|\bapi\b|엔드포인트|응답|소스|코드|파일|흐름|쿼리|집계|resource|리소스|화면/u.test(summary)) {
|
|
return 3;
|
|
}
|
|
|
|
if (/구현|수정|변경|작성|빌드|patch|diff|전송 중/u.test(summary)) {
|
|
return 4;
|
|
}
|
|
|
|
if (/검증|테스트|캡처|preview|완료|결과|정리/u.test(summary)) {
|
|
return 5;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function createCompactActivityLogEntry(activityLine: string) {
|
|
const normalizedLine = normalizeProgressSummary(activityLine);
|
|
|
|
if (!normalizedLine) {
|
|
return null;
|
|
}
|
|
|
|
const strippedSummary = stripActivityLinePrefix(normalizedLine);
|
|
const summarizedProgress = summarizeActivityProgressLine(normalizedLine);
|
|
const summary = summarizedProgress || strippedSummary;
|
|
const lineNo = resolveCompactActivityStageLineNo(summary, normalizedLine);
|
|
|
|
if (!lineNo || !summary) {
|
|
return null;
|
|
}
|
|
|
|
if (/^#\s*오류:/u.test(normalizedLine)) {
|
|
return {
|
|
line: `# 오류: ${summary}`,
|
|
lineNo,
|
|
};
|
|
}
|
|
|
|
const prefix = lineNo === 1 || lineNo === 5 ? '# 상태:' : '# 진행:';
|
|
|
|
return {
|
|
line: `${prefix} ${summary}`,
|
|
lineNo,
|
|
};
|
|
}
|
|
|
|
function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
|
|
const normalized = input.toLowerCase();
|
|
const pageTitle = context?.pageTitle ?? '현재 화면';
|
|
const previewUrl = context?.pageUrl || '없음';
|
|
const detailRequested = isDetailRequest(input);
|
|
|
|
if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
|
|
const lines = [
|
|
'결과',
|
|
`- preview: ${snapshot?.latestSourceWork?.previewUrl ?? previewUrl}`,
|
|
];
|
|
|
|
if (snapshot?.latestSourceWork?.summary) {
|
|
lines.push(`- 요약: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
|
|
}
|
|
|
|
if (detailRequested && snapshot?.latestSourceWork) {
|
|
lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`);
|
|
}
|
|
|
|
if (!detailRequested) {
|
|
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
if (input.includes('계획') || normalized.includes('plan')) {
|
|
if (!snapshot) {
|
|
return ['결과', '- 연결된 Plan을 찾지 못했습니다.', '- planId 또는 작업 ID를 함께 보내 주세요.'].join('\n');
|
|
}
|
|
|
|
const lines = [
|
|
'작업 요약',
|
|
`- #${snapshot.planId} ${snapshot.workId}`,
|
|
`- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`,
|
|
`- 메모: ${truncateText(snapshot.note, 110) || '기록 없음'}`,
|
|
];
|
|
|
|
if (detailRequested) {
|
|
if (snapshot.assignedBranch) {
|
|
lines.push(`- 브랜치: ${snapshot.assignedBranch}`);
|
|
}
|
|
|
|
lines.push(...buildRecentPlanHistoryLines(snapshot));
|
|
} else {
|
|
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
if ((input.includes('이슈') || normalized.includes('issue')) && snapshot) {
|
|
const lines = ['이슈 요약'];
|
|
|
|
if (snapshot.latestIssue) {
|
|
lines.push(
|
|
`- ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`,
|
|
`- 내용: ${truncateText(snapshot.latestIssue.message, 110)}`,
|
|
);
|
|
} else if (snapshot.issueTags.length > 0) {
|
|
lines.push(`- 태그: ${snapshot.issueTags.join(', ')}`);
|
|
} else {
|
|
lines.push('- 최근 이슈 이력이 없습니다.');
|
|
}
|
|
|
|
if (detailRequested) {
|
|
lines.push(...buildRecentPlanHistoryLines(snapshot));
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
if ((input.includes('이력') || input.includes('최근') || normalized.includes('history')) && snapshot) {
|
|
const lines = [
|
|
'최근 이력',
|
|
`- ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 120) || '최근 작업 이력이 없습니다.'}`,
|
|
];
|
|
|
|
if (detailRequested) {
|
|
lines.push(...buildRecentPlanHistoryLines(snapshot));
|
|
} else {
|
|
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
if (input.includes('문서') || normalized.includes('docs')) {
|
|
return ['결과', '- 문서 요청으로 인식했습니다.', '- 대상 문서명이나 링크를 함께 보내 주세요.'].join('\n');
|
|
}
|
|
|
|
if (isWorklogRequest(input)) {
|
|
if (snapshot) {
|
|
return buildWorklogReply(context, snapshot);
|
|
}
|
|
|
|
return ['결과', '- 작업로그 초안을 만들 Plan이 없습니다.', '- planId 또는 작업 화면에서 다시 요청해 주세요.'].join('\n');
|
|
}
|
|
|
|
if (
|
|
input.includes('컴포넌트') ||
|
|
normalized.includes('widget') ||
|
|
normalized.includes('api')
|
|
) {
|
|
return ['결과', '- 대상 리소스를 특정해 주세요.', '- 예: 버튼 컴포넌트, previewer, history api'].join('\n');
|
|
}
|
|
|
|
if (snapshot && isPlanDetailRequest(input)) {
|
|
const lines = [
|
|
'작업 요약',
|
|
`- #${snapshot.planId} ${snapshot.workId}`,
|
|
`- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`,
|
|
`- 최근 작업: ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 110) || '기록 없음'}`,
|
|
];
|
|
|
|
if (detailRequested) {
|
|
lines.push(...buildRecentPlanHistoryLines(snapshot));
|
|
} else {
|
|
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
return [
|
|
'결과',
|
|
`- 현재 화면: ${pageTitle}`,
|
|
'- 요청을 더 구체적으로 적어 주시면 필요한 정보만 짧게 정리합니다.',
|
|
'- 예: preview 링크, 최근 이슈, 작업 요약, 상세',
|
|
].join('\n');
|
|
}
|
|
|
|
function buildPlanReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
|
|
const normalized = input.toLowerCase();
|
|
const detailRequested = isDetailRequest(input);
|
|
const pageTitle = context?.pageTitle ?? '현재 화면';
|
|
|
|
if (!snapshot) {
|
|
return `현재 화면: ${pageTitle}\n선택된 Plan을 찾지 못했습니다. Plan 목록에서 항목을 선택한 뒤 다시 요청해 주세요.`;
|
|
}
|
|
|
|
const lines = ['작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`];
|
|
|
|
if (isWorklogRequest(input)) {
|
|
return buildWorklogReply(context, snapshot);
|
|
}
|
|
|
|
if (snapshot.note) {
|
|
lines.push(`- 메모: ${truncateText(snapshot.note, 110)}`);
|
|
}
|
|
|
|
if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
|
|
lines.push(`- preview: ${snapshot.latestSourceWork?.previewUrl ?? context?.pageUrl ?? '없음'}`);
|
|
if (snapshot.latestSourceWork) {
|
|
lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
|
|
if (detailRequested) {
|
|
lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`);
|
|
}
|
|
}
|
|
|
|
if (!detailRequested) {
|
|
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
if (snapshot.latestIssue) {
|
|
lines.push(`- 최근 이슈: ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`);
|
|
if (detailRequested) {
|
|
lines.push(`- 이슈 내용: ${truncateText(snapshot.latestIssue.message, 100)}`);
|
|
}
|
|
} else if (snapshot.issueTags.length > 0) {
|
|
lines.push(`- 이슈 태그: ${snapshot.issueTags.join(', ')}`);
|
|
}
|
|
|
|
if (snapshot.latestActionNote) {
|
|
lines.push(`- 최근 조치: ${truncateText(snapshot.latestActionNote, 100)}`);
|
|
}
|
|
|
|
if (snapshot.latestSourceWork) {
|
|
lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
|
|
if (detailRequested && snapshot.latestSourceWork.changedFiles.length > 0) {
|
|
lines.push(`- 파일 ${snapshot.latestSourceWork.changedFiles.length}개 변경`);
|
|
}
|
|
}
|
|
|
|
if (snapshot.lastError && snapshot.hasOpenIssues) {
|
|
lines.push(`- 주의: ${truncateText(snapshot.lastError, 100)}`);
|
|
}
|
|
|
|
if (detailRequested) {
|
|
if (snapshot.assignedBranch) {
|
|
lines.push(`- 브랜치: ${snapshot.assignedBranch}`);
|
|
}
|
|
lines.push(...buildRecentPlanHistoryLines(snapshot));
|
|
} else {
|
|
lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
async function buildCodexReply(
|
|
context: ChatContext | null,
|
|
input: string,
|
|
sessionId: string,
|
|
requestId: string,
|
|
options?: {
|
|
omitPromptHistory?: boolean;
|
|
requestedAt?: Date | null;
|
|
},
|
|
onProgress?: (text: string) => void,
|
|
onActivity?: (line: string) => void,
|
|
isCancellationRequested?: () => boolean,
|
|
): Promise<ChatCodexReplyResult> {
|
|
return runAgenticCodexReply(
|
|
context,
|
|
input,
|
|
sessionId,
|
|
requestId,
|
|
options,
|
|
onProgress,
|
|
onActivity,
|
|
isCancellationRequested,
|
|
);
|
|
}
|
|
|
|
function formatParticipantTurnLabel(participant: {
|
|
name: string;
|
|
model: string;
|
|
turn: CodexParticipantTurn;
|
|
}) {
|
|
const suffix =
|
|
participant.turn === 'opening'
|
|
? '중재 시작'
|
|
: participant.turn === 'discussion'
|
|
? '프리토킹'
|
|
: participant.turn === 'review'
|
|
? '최종 검토'
|
|
: participant.turn === 'closing'
|
|
? '최종 정리'
|
|
: null;
|
|
|
|
return suffix ? `${participant.name}(${participant.model}, ${suffix})` : `${participant.name}(${participant.model})`;
|
|
}
|
|
|
|
type ResolvedCodexParticipant = ReturnType<typeof resolveCodexParticipantsForExecution>[number];
|
|
|
|
type CodexExecutionStage = {
|
|
parallel: boolean;
|
|
participants: ResolvedCodexParticipant[];
|
|
};
|
|
|
|
type CodexExecutionPlanStep = {
|
|
key: string;
|
|
label: string;
|
|
lineNo: number;
|
|
kind: 'analysis' | 'stage' | 'finalize';
|
|
participantKeys?: string[];
|
|
};
|
|
|
|
type CodexExecutorActivitySlot = {
|
|
participantId: string;
|
|
label: string;
|
|
lineNo: number;
|
|
};
|
|
|
|
function buildCodexPlanParticipantKey(participant: ResolvedCodexParticipant) {
|
|
return `${participant.id}:${participant.turn}`;
|
|
}
|
|
|
|
export function resolveCodexParticipantsForExecution(context: ChatContext | null) {
|
|
const configuredParticipants = normalizeCodexParticipants(context?.codexParticipants);
|
|
const normalizedParticipants =
|
|
configuredParticipants.length > 0
|
|
? configuredParticipants
|
|
: [
|
|
{
|
|
id: 'codex-default',
|
|
name: 'Codex',
|
|
model: context?.codexModel?.trim() || 'gpt-5.4',
|
|
prompt: '',
|
|
chatTypeId: null,
|
|
defaultContextIds: [],
|
|
role: 'default' as const,
|
|
},
|
|
];
|
|
const executionPolicy = resolveChatTypeExecutionPolicy(context);
|
|
const shouldAutoBind =
|
|
executionPolicy.participantBinding === 'first-moderator-rest-conversation' ||
|
|
executionPolicy.participantBinding === 'first-moderator-rest-conversation-last-reviewer';
|
|
const policyBoundParticipants = shouldAutoBind
|
|
? normalizedParticipants.map((participant, index, source) => ({
|
|
...participant,
|
|
role:
|
|
index === 0
|
|
? ('moderator' as const)
|
|
: executionPolicy.participantBinding === 'first-moderator-rest-conversation-last-reviewer' &&
|
|
executionPolicy.reviewPolicy === 'reviewer' &&
|
|
index === source.length - 1
|
|
? ('reviewer' as const)
|
|
: ('conversation' as const),
|
|
}))
|
|
: normalizedParticipants;
|
|
const moderator = policyBoundParticipants.find((participant) => participant.role === 'moderator') ?? null;
|
|
|
|
if (!moderator) {
|
|
return policyBoundParticipants.map((participant) => ({
|
|
...participant,
|
|
turn:
|
|
participant.role === 'conversation'
|
|
? ('discussion' as const)
|
|
: participant.role === 'reviewer'
|
|
? ('review' as const)
|
|
: ('standard' as const),
|
|
}));
|
|
}
|
|
|
|
const reviewer =
|
|
executionPolicy.reviewPolicy === 'reviewer'
|
|
? policyBoundParticipants.find((participant) => participant.role === 'reviewer') ?? null
|
|
: null;
|
|
const discussionParticipants = policyBoundParticipants.filter(
|
|
(participant) => participant.id !== moderator.id && participant.id !== reviewer?.id,
|
|
);
|
|
|
|
if (discussionParticipants.length === 0 && !reviewer) {
|
|
return [
|
|
{
|
|
...moderator,
|
|
turn: 'standard' as const,
|
|
},
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
...moderator,
|
|
turn: 'opening' as const,
|
|
},
|
|
...discussionParticipants.map((participant) => ({
|
|
...participant,
|
|
turn: participant.role === 'conversation' ? ('discussion' as const) : ('standard' as const),
|
|
})),
|
|
...(reviewer
|
|
? [
|
|
{
|
|
...reviewer,
|
|
turn: 'review' as const,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
...moderator,
|
|
turn: 'closing' as const,
|
|
},
|
|
];
|
|
}
|
|
|
|
export function resolveCodexExecutionStages(
|
|
context: ChatContext | null,
|
|
requestMode: ChatRequestMode = 'queue',
|
|
): CodexExecutionStage[] {
|
|
const participants = resolveCodexParticipantsForExecution(context);
|
|
|
|
if (participants.length <= 1 || requestMode !== 'direct') {
|
|
return participants.map((participant) => ({
|
|
parallel: false,
|
|
participants: [participant],
|
|
}));
|
|
}
|
|
|
|
const executionPolicy = resolveChatTypeExecutionPolicy(context);
|
|
|
|
if (!executionPolicy.finalSummaryRequired) {
|
|
const parallelParticipants = participants
|
|
.filter((participant) => participant.turn !== 'closing')
|
|
.map((participant) =>
|
|
participant.turn === 'opening'
|
|
? {
|
|
...participant,
|
|
turn: 'standard' as const,
|
|
}
|
|
: participant,
|
|
);
|
|
|
|
return [
|
|
{
|
|
parallel: parallelParticipants.length > 1,
|
|
participants: parallelParticipants,
|
|
},
|
|
];
|
|
}
|
|
|
|
const stages: CodexExecutionStage[] = [];
|
|
const openingParticipants = participants.filter((participant) => participant.turn === 'opening');
|
|
const discussionParticipants = participants.filter(
|
|
(participant) => participant.turn === 'discussion' || participant.turn === 'standard',
|
|
);
|
|
const reviewParticipants = participants.filter((participant) => participant.turn === 'review');
|
|
const closingParticipants = participants.filter((participant) => participant.turn === 'closing');
|
|
|
|
openingParticipants.forEach((participant) => {
|
|
stages.push({
|
|
parallel: false,
|
|
participants: [participant],
|
|
});
|
|
});
|
|
|
|
if (discussionParticipants.length > 0) {
|
|
stages.push({
|
|
parallel: discussionParticipants.length > 1,
|
|
participants: discussionParticipants,
|
|
});
|
|
}
|
|
|
|
reviewParticipants.forEach((participant) => {
|
|
stages.push({
|
|
parallel: false,
|
|
participants: [participant],
|
|
});
|
|
});
|
|
|
|
closingParticipants.forEach((participant) => {
|
|
stages.push({
|
|
parallel: false,
|
|
participants: [participant],
|
|
});
|
|
});
|
|
|
|
return stages;
|
|
}
|
|
|
|
function buildCodexExecutionPlanSteps(stages: CodexExecutionStage[]) {
|
|
const steps: CodexExecutionPlanStep[] = [
|
|
{
|
|
key: 'analysis',
|
|
label: '요청 분석',
|
|
lineNo: 11,
|
|
kind: 'analysis',
|
|
},
|
|
];
|
|
stages.forEach((stage, index) => {
|
|
const turnSet = new Set(stage.participants.map((participant) => participant.turn));
|
|
let label = stage.parallel ? `병렬 실행 (${stage.participants.length}명)` : '실행';
|
|
|
|
if (turnSet.size === 1) {
|
|
const [turn] = [...turnSet];
|
|
|
|
if (turn === 'opening') {
|
|
label = '중재 시작';
|
|
} else if (turn === 'discussion') {
|
|
label = stage.parallel ? `병렬 토론 (${stage.participants.length}명)` : '토론 실행';
|
|
} else if (turn === 'review') {
|
|
label = '최종 검토';
|
|
} else if (turn === 'closing') {
|
|
label = '최종 정리';
|
|
} else if (turn === 'standard') {
|
|
label = stage.parallel ? `병렬 실행 (${stage.participants.length}명)` : '단일 실행';
|
|
}
|
|
}
|
|
|
|
steps.push({
|
|
key: `stage-${index + 1}`,
|
|
label,
|
|
lineNo: 11 + steps.length,
|
|
kind: 'stage',
|
|
participantKeys: stage.participants.map((participant) => buildCodexPlanParticipantKey(participant)),
|
|
});
|
|
});
|
|
|
|
steps.push({
|
|
key: 'finalize',
|
|
label: '응답 정리',
|
|
lineNo: 11 + steps.length,
|
|
kind: 'finalize',
|
|
});
|
|
|
|
return steps;
|
|
}
|
|
|
|
function buildCodexExecutorActivitySlots(participants: ResolvedCodexParticipant[], startLineNo: number) {
|
|
const slots: CodexExecutorActivitySlot[] = [];
|
|
const seenParticipantIds = new Set<string>();
|
|
let lineOffset = startLineNo;
|
|
|
|
participants.forEach((participant) => {
|
|
if (seenParticipantIds.has(participant.id)) {
|
|
return;
|
|
}
|
|
|
|
seenParticipantIds.add(participant.id);
|
|
slots.push({
|
|
participantId: participant.id,
|
|
label: `${participant.name}(${participant.model})`,
|
|
lineNo: lineOffset,
|
|
});
|
|
lineOffset += 1;
|
|
});
|
|
|
|
return slots;
|
|
}
|
|
|
|
async function resolveCodexParticipantTypeOverrides(
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
model: string;
|
|
prompt: string;
|
|
chatTypeId: string | null;
|
|
role: CodexParticipantRole;
|
|
turn: CodexParticipantTurn;
|
|
}>,
|
|
roomContext: ChatContext | null,
|
|
) {
|
|
const participantTypeIds = Array.from(
|
|
new Set(
|
|
participants
|
|
.map((participant) => participant.chatTypeId?.trim() || '')
|
|
.filter((chatTypeId) => chatTypeId && chatTypeId !== (roomContext?.chatTypeId?.trim() || '')),
|
|
),
|
|
);
|
|
|
|
if (participantTypeIds.length === 0) {
|
|
return new Map<
|
|
string,
|
|
{
|
|
label: string;
|
|
description: string;
|
|
}
|
|
>();
|
|
}
|
|
|
|
const [chatTypesConfig, chatContextSettings] = await Promise.all([
|
|
getChatTypesConfig(),
|
|
getChatContextSettingsConfig(),
|
|
]);
|
|
|
|
return new Map(
|
|
participantTypeIds.map((chatTypeId) => {
|
|
const resolvedChatType =
|
|
chatTypesConfig.chatTypes.find((item) => item.id === chatTypeId && item.enabled) ?? null;
|
|
const defaultContextIds =
|
|
chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === chatTypeId)?.defaultContextIds ?? [];
|
|
const defaultContexts = defaultContextIds
|
|
.map((contextId) =>
|
|
chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled),
|
|
)
|
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
|
.map((item) => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
content: item.content,
|
|
}));
|
|
const baseDescription = resolvedChatType?.description?.trim() || '';
|
|
const description = composeResolvedChatTypeDescription(baseDescription, defaultContexts);
|
|
|
|
return [
|
|
chatTypeId,
|
|
{
|
|
label: resolvedChatType?.name ?? '개별 채팅유형',
|
|
description: description || baseDescription,
|
|
},
|
|
] as const;
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function buildParticipantRequestInput(
|
|
baseInput: string,
|
|
participant: {
|
|
name: string;
|
|
model: string;
|
|
prompt: string;
|
|
chatTypeId: string | null;
|
|
role: CodexParticipantRole;
|
|
turn: CodexParticipantTurn;
|
|
},
|
|
participants: Array<{
|
|
name: string;
|
|
model: string;
|
|
prompt: string;
|
|
chatTypeId: string | null;
|
|
role: CodexParticipantRole;
|
|
turn: CodexParticipantTurn;
|
|
}>,
|
|
previousReplies: Array<{ name: string; model: string; text: string }>,
|
|
participantTypeOverride?: { label: string; description: string } | null,
|
|
executionPolicy?: ChatTypeExecutionPolicy | null,
|
|
promptContextRef?: ChatPromptContextRef | null,
|
|
) {
|
|
const resolvedPolicy = executionPolicy ?? createDefaultChatTypeExecutionPolicy();
|
|
const lines = [
|
|
baseInput.trim(),
|
|
'',
|
|
`추가 실행 역할: 이번 응답은 ${participant.name} (${participant.model}) 차례입니다.`,
|
|
];
|
|
|
|
if (participant.prompt) {
|
|
lines.push(`역할 메모: ${participant.prompt}`);
|
|
}
|
|
|
|
if (participant.turn === 'opening') {
|
|
lines.push(
|
|
resolvedPolicy.mode === 'dispatcher-workers'
|
|
? '역할 지시: 당신은 이번 대화의 중계 지시자입니다. 지금 턴에서는 작업을 역할·기준·검증 축으로 분해하고, 어떤 Codex가 어떤 관점으로 이어서 처리해야 하는지 분명하게 배분하세요.'
|
|
: '역할 지시: 당신은 이번 대화의 회의 기록자 겸 중재자입니다. 지금 턴에서는 결론을 먼저 확정하지 말고 요청을 재정리한 뒤, 다른 Codex가 이어서 토론할 핵심 논점·검증 기준·확인 포인트를 짧고 분명하게 남기세요.',
|
|
);
|
|
} else if (participant.turn === 'closing') {
|
|
lines.push(
|
|
resolvedPolicy.mode === 'dispatcher-workers'
|
|
? '역할 지시: 당신은 이번 대화의 중계 지시자입니다. 앞선 Codex 발언을 종합해 최종 결과물, 검토 결과, 남은 리스크와 후속 액션을 보고 형식으로 정리하세요.'
|
|
: '역할 지시: 당신은 이번 대화의 회의 기록자 겸 중재자입니다. 앞선 Codex 발언을 바탕으로 최종 결론과 남은 쟁점을 정리하고, 필요하면 resource·검증 결과·적용 결론 중심으로 보고를 마무리하세요.',
|
|
);
|
|
} else if (participant.turn === 'review' || participant.role === 'reviewer') {
|
|
lines.push(
|
|
'역할 지시: 당신은 최종 검토자입니다. 앞선 논의의 근거, 누락, 충돌, 검증 부족 지점을 점검하고 최종 종합 전에 보완 또는 승인 의견을 분명하게 남기세요.',
|
|
);
|
|
} else if (participant.turn === 'discussion' || participant.role === 'conversation') {
|
|
lines.push(
|
|
resolvedPolicy.mode === 'dispatcher-workers'
|
|
? '역할 지시: 당신은 실작업자입니다. 배정된 관점에서 구현, 설계, 검증, 반례를 구체적으로 제시하고 필요하면 앞선 지시를 수정 제안하세요.'
|
|
: '역할 지시: 당신은 프리토킹 참가자입니다. 앞선 Codex 의견에 자유롭게 동의·반박·보완하면서 구현, 설계, 검증 관점의 구체 논점을 추가하세요.',
|
|
);
|
|
}
|
|
|
|
lines.push(
|
|
`실행 정책: ${resolvedPolicy.mode}`,
|
|
`결과물 보고 정책: ${resolvedPolicy.resourceReportPolicy}`,
|
|
`최종 종합 강제: ${resolvedPolicy.finalSummaryRequired ? '예' : '아니오'}`,
|
|
);
|
|
|
|
if (participantTypeOverride?.description) {
|
|
lines.push(
|
|
'',
|
|
`참가자 전용 채팅유형: ${participantTypeOverride.label}`,
|
|
participantTypeOverride.description,
|
|
);
|
|
}
|
|
|
|
const promptContextLines = buildPromptContextInstructionLines(promptContextRef);
|
|
|
|
if (promptContextLines.length > 0) {
|
|
lines.push('', ...promptContextLines);
|
|
}
|
|
|
|
if (participants.length > 1) {
|
|
lines.push(
|
|
`참가자 순서: ${participants.map((entry) => formatParticipantTurnLabel(entry)).join(' -> ')}`,
|
|
);
|
|
}
|
|
|
|
if (previousReplies.length > 0) {
|
|
lines.push('', '이전 Codex 발언:');
|
|
previousReplies.forEach((reply, index) => {
|
|
lines.push(`${index + 1}. ${reply.name} (${reply.model})`);
|
|
lines.push(reply.text.trim());
|
|
});
|
|
lines.push('', '위 Codex 발언을 읽고 이어서 답하세요. 필요하면 동의, 반박, 보완, 정리를 명확히 적으세요.');
|
|
} else if (participants.length > 1) {
|
|
lines.push(
|
|
'',
|
|
participant.turn === 'opening'
|
|
? '중재 시작 턴입니다. 다음 Codex가 바로 이어서 토론할 수 있게 논점을 분명히 남기세요.'
|
|
: '첫 번째 Codex로서 먼저 답한 뒤, 다음 Codex가 이어서 토론할 수 있게 논점을 분명히 남기세요.',
|
|
);
|
|
}
|
|
|
|
return lines.filter(Boolean).join('\n');
|
|
}
|
|
|
|
export class ChatService {
|
|
private readonly wss = new WebSocketServer({ noServer: true });
|
|
|
|
private readonly clientStates = new Map<WebSocket, ChatSessionState>();
|
|
private readonly sessions = new Map<string, ChatSessionState>();
|
|
private readonly cancelledRequestIds = new Set<string>();
|
|
private readonly unsubscribeRuntimeBroadcast: () => void;
|
|
private readonly unsubscribeNotificationBroadcast: () => void;
|
|
|
|
constructor(private readonly logger: FastifyBaseLogger) {
|
|
activeChatService = this;
|
|
activeRuntimeController = {
|
|
getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId),
|
|
cancelJob: (requestId) => this.cancelRuntimeJob(requestId),
|
|
removeQueuedJob: (requestId) => this.removeQueuedRuntimeJob(requestId),
|
|
};
|
|
|
|
this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => {
|
|
this.broadcastRuntimeSnapshot(snapshot);
|
|
});
|
|
this.unsubscribeNotificationBroadcast = subscribeNotificationMessageChanges((event) => {
|
|
this.broadcastNotificationMessageChange(event);
|
|
});
|
|
|
|
this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => {
|
|
void this.handleConnection(socket, request).catch((error: unknown) => {
|
|
const session = this.clientStates.get(socket);
|
|
this.logger.error(error, 'chat websocket connection initialization failed');
|
|
this.clientStates.delete(socket);
|
|
if (session) {
|
|
session.sockets.delete(socket);
|
|
}
|
|
closeSocketSafely(this.logger, socket, 'failed to close websocket after initialization error');
|
|
});
|
|
});
|
|
}
|
|
|
|
attachUpgradeHandler() {
|
|
return (request: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
|
|
const url = new URL(request.url ?? '/', origin);
|
|
|
|
if (url.pathname !== SOCKET_PATH) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
this.wss.handleUpgrade(request, socket, head, (websocket: WebSocket) => {
|
|
this.wss.emit('connection', websocket, request);
|
|
});
|
|
};
|
|
}
|
|
|
|
getRuntimeSnapshot(): ChatServiceRuntimeSnapshot {
|
|
let activeRequestCount = 0;
|
|
let queuedRequestCount = 0;
|
|
let activeSocketCount = 0;
|
|
let connectedSessionCount = 0;
|
|
|
|
for (const session of this.sessions.values()) {
|
|
activeRequestCount += session.activeRequestCount;
|
|
queuedRequestCount += session.queue.length;
|
|
|
|
let sessionHasOpenSocket = false;
|
|
for (const socket of session.sockets) {
|
|
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
|
|
activeSocketCount += 1;
|
|
sessionHasOpenSocket = true;
|
|
}
|
|
}
|
|
|
|
if (sessionHasOpenSocket) {
|
|
connectedSessionCount += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
activeRequestCount,
|
|
queuedRequestCount,
|
|
connectedSessionCount,
|
|
activeSocketCount,
|
|
canAcceptNewRequests: !isRuntimeDraining(),
|
|
};
|
|
}
|
|
|
|
close() {
|
|
activeRuntimeController = null;
|
|
if (activeChatService === this) {
|
|
activeChatService = null;
|
|
}
|
|
this.unsubscribeRuntimeBroadcast();
|
|
this.unsubscribeNotificationBroadcast();
|
|
|
|
for (const execution of activeChatProcessRegistry.values()) {
|
|
void Promise.resolve(execution.cancel()).catch(() => {
|
|
// noop
|
|
});
|
|
}
|
|
|
|
activeChatProcessRegistry.clear();
|
|
chatRuntimeService.clearAll();
|
|
|
|
for (const client of this.clientStates.keys()) {
|
|
client.close();
|
|
}
|
|
|
|
this.wss.close();
|
|
}
|
|
|
|
async recoverInterruptedSessions() {
|
|
if (!(await isCurrentWorkServerSlotActive())) {
|
|
this.logger.info(
|
|
{
|
|
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
|
|
},
|
|
'skip interrupted chat recovery on inactive work-server slot',
|
|
);
|
|
|
|
return {
|
|
sessionCount: 0,
|
|
restartedCount: 0,
|
|
requeuedCount: 0,
|
|
};
|
|
}
|
|
|
|
const recoverableRequests = await listRecoverableChatConversationRequests();
|
|
|
|
if (recoverableRequests.length === 0) {
|
|
return {
|
|
sessionCount: 0,
|
|
restartedCount: 0,
|
|
requeuedCount: 0,
|
|
};
|
|
}
|
|
|
|
const requestsBySession = new Map<string, typeof recoverableRequests>();
|
|
|
|
for (const item of recoverableRequests) {
|
|
const existing = requestsBySession.get(item.sessionId);
|
|
|
|
if (existing) {
|
|
existing.push(item);
|
|
} else {
|
|
requestsBySession.set(item.sessionId, [item]);
|
|
}
|
|
}
|
|
|
|
let restartedCount = 0;
|
|
let requeuedCount = 0;
|
|
|
|
for (const [sessionId, items] of requestsBySession.entries()) {
|
|
const recoverableItems = items.filter((item) => !shouldClearConversationJobState({
|
|
currentRequestId: item.currentRequestId,
|
|
currentJobStatus: item.currentJobStatus,
|
|
currentStatusUpdatedAt: item.currentStatusUpdatedAt,
|
|
runtimeActive: false,
|
|
request: {
|
|
requestId: item.requestId,
|
|
status: item.status,
|
|
responseMessageId: null,
|
|
responseText: '',
|
|
terminalAt: null,
|
|
updatedAt: item.updatedAt,
|
|
},
|
|
}));
|
|
|
|
for (const staleItem of items.filter((item) => !recoverableItems.includes(item))) {
|
|
await upsertChatConversationRequest(staleItem.sessionId, {
|
|
requestId: staleItem.requestId,
|
|
requestOrigin: staleItem.requestOrigin,
|
|
sharedResourceTokenId: staleItem.sharedResourceTokenId,
|
|
parentRequestId: staleItem.parentRequestId,
|
|
status: 'failed',
|
|
statusMessage: '중단된 오래된 요청',
|
|
userText: staleItem.userText,
|
|
});
|
|
}
|
|
|
|
if (recoverableItems.length === 0) {
|
|
continue;
|
|
}
|
|
const session = this.getOrCreateSession(sessionId, recoverableItems[0]?.clientId ?? null);
|
|
const primaryItem =
|
|
recoverableItems.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ??
|
|
recoverableItems.find((item) => item.status === 'started') ??
|
|
recoverableItems[0];
|
|
|
|
session.clientId = primaryItem?.clientId?.trim() || session.clientId;
|
|
session.context = {
|
|
pageId: null,
|
|
pageTitle: '',
|
|
topMenu: '',
|
|
focusedComponentId: null,
|
|
pageUrl: '',
|
|
chatTypeId: primaryItem?.chatTypeId ?? primaryItem?.lastChatTypeId ?? null,
|
|
chatTypeLabel: primaryItem?.contextLabel ?? '',
|
|
chatTypeDescription: primaryItem?.contextDescription ?? '',
|
|
};
|
|
|
|
for (const item of recoverableItems) {
|
|
if (item.status === 'started' && item.requestId !== primaryItem?.requestId) {
|
|
await upsertChatConversationRequest(item.sessionId, {
|
|
requestId: item.requestId,
|
|
requestOrigin: item.requestOrigin,
|
|
sharedResourceTokenId: item.sharedResourceTokenId,
|
|
parentRequestId: item.parentRequestId,
|
|
status: 'failed',
|
|
statusMessage: '중단된 오래된 요청',
|
|
userText: item.userText,
|
|
});
|
|
continue;
|
|
}
|
|
if (item.requestId === primaryItem?.requestId) {
|
|
continue;
|
|
}
|
|
|
|
const requestedAtMs = parseRequestedAtMs(item.createdAt);
|
|
session.queue.push({
|
|
requestId: item.requestId,
|
|
text: item.userText,
|
|
mode: 'queue',
|
|
requestedAtMs,
|
|
requestOrigin: item.requestOrigin ?? 'composer',
|
|
sharedResourceTokenId: item.sharedResourceTokenId ?? null,
|
|
parentRequestId: item.parentRequestId ?? null,
|
|
context: session.context ? cloneChatContext(session.context) : null,
|
|
});
|
|
chatRuntimeService.enqueueJob({
|
|
sessionId,
|
|
requestId: item.requestId,
|
|
mode: 'queue',
|
|
text: item.userText,
|
|
});
|
|
chatRuntimeService.appendLog(item.requestId, '워크서버 재기동 후 대기열 복구를 준비합니다.');
|
|
requeuedCount += 1;
|
|
}
|
|
|
|
if (!primaryItem) {
|
|
continue;
|
|
}
|
|
|
|
restartedCount += 1;
|
|
void this.executeRequest(session, {
|
|
requestId: primaryItem.requestId,
|
|
text: primaryItem.userText,
|
|
mode: 'queue',
|
|
requestedAtMs: parseRequestedAtMs(primaryItem.createdAt),
|
|
requestOrigin: primaryItem.requestOrigin ?? 'composer',
|
|
sharedResourceTokenId: primaryItem.sharedResourceTokenId ?? null,
|
|
parentRequestId: primaryItem.parentRequestId ?? null,
|
|
context: session.context ? cloneChatContext(session.context) : null,
|
|
}).catch((error: unknown) => {
|
|
this.logger.error(
|
|
{ error, sessionId: session.sessionId, requestId: primaryItem.requestId },
|
|
'failed to recover interrupted chat request',
|
|
);
|
|
});
|
|
}
|
|
|
|
this.logger.info(
|
|
{
|
|
sessionCount: requestsBySession.size,
|
|
restartedCount,
|
|
requeuedCount,
|
|
},
|
|
'recovered interrupted chat sessions after work-server restart',
|
|
);
|
|
|
|
return {
|
|
sessionCount: requestsBySession.size,
|
|
restartedCount,
|
|
requeuedCount,
|
|
};
|
|
}
|
|
|
|
private getOrCreateSession(sessionId: string, clientId?: string | null) {
|
|
const existing = this.sessions.get(sessionId);
|
|
|
|
if (existing) {
|
|
if (clientId?.trim()) {
|
|
existing.clientId = clientId.trim();
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
const nextSession: ChatSessionState = {
|
|
sessionId,
|
|
clientId: clientId?.trim() || null,
|
|
sockets: new Set<WebSocket>(),
|
|
lastSeenAt: Date.now(),
|
|
isDeleted: false,
|
|
context: null,
|
|
queue: [],
|
|
activeRequestCount: 0,
|
|
pendingQueueReleaseEventId: null,
|
|
pendingQueueReleaseTimer: null,
|
|
nextEventId: 1,
|
|
eventHistory: [],
|
|
messagePersistenceTail: Promise.resolve(),
|
|
watchedRuntimeRequestId: null,
|
|
};
|
|
this.sessions.set(sessionId, nextSession);
|
|
return nextSession;
|
|
}
|
|
|
|
private createSessionEnvelope(session: ChatSessionState, message: ChatOutboundPayload): ChatOutboundMessage {
|
|
return {
|
|
...message,
|
|
eventId: session.nextEventId++,
|
|
sessionId: session.sessionId,
|
|
};
|
|
}
|
|
|
|
private sendEnvelopeToSessionSockets(session: ChatSessionState, envelope: ChatOutboundMessage, errorMessage: string) {
|
|
for (const socket of session.sockets) {
|
|
sendSocketEnvelope(this.logger, socket, envelope, errorMessage);
|
|
}
|
|
}
|
|
|
|
private retainEnvelopeForReplay(session: ChatSessionState, envelope: ChatOutboundMessage) {
|
|
if (envelope.type === 'chat:init' || envelope.type === 'chat:status') {
|
|
return;
|
|
}
|
|
|
|
session.eventHistory.push(envelope);
|
|
|
|
if (session.eventHistory.length > CHAT_SESSION_EVENT_HISTORY_LIMIT) {
|
|
session.eventHistory.splice(0, session.eventHistory.length - CHAT_SESSION_EVENT_HISTORY_LIMIT);
|
|
}
|
|
}
|
|
|
|
private persistConversationMessage(session: ChatSessionState, message: ChatMessage) {
|
|
if (session.isDeleted) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const nextPersistence = session.messagePersistenceTail
|
|
.catch(() => undefined)
|
|
.then(() =>
|
|
appendChatConversationMessage(
|
|
{
|
|
sessionId: session.sessionId,
|
|
clientId: session.clientId,
|
|
title: null,
|
|
contextLabel: session.context?.chatTypeLabel ?? null,
|
|
contextDescription: session.context?.chatTypeDescription ?? null,
|
|
notifyOffline: undefined,
|
|
},
|
|
{
|
|
sessionId: session.sessionId,
|
|
messageId: message.id,
|
|
author: message.author,
|
|
text: message.text,
|
|
timestamp: message.timestamp,
|
|
clientRequestId: message.clientRequestId ?? null,
|
|
parts: message.parts ?? [],
|
|
},
|
|
),
|
|
);
|
|
|
|
session.messagePersistenceTail = nextPersistence.catch((error: unknown) => {
|
|
this.logger.error(error, 'failed to persist chat message');
|
|
});
|
|
|
|
return session.messagePersistenceTail;
|
|
}
|
|
|
|
private sendToSession(
|
|
session: ChatSessionState,
|
|
message: ChatOutboundPayload,
|
|
options?: {
|
|
skipOfflineNotification?: boolean;
|
|
},
|
|
) {
|
|
if (session.isDeleted) {
|
|
const normalizedDeletedMessage =
|
|
message.type === 'chat:message'
|
|
? {
|
|
...message,
|
|
payload: normalizeStructuredChatMessage(message.payload),
|
|
}
|
|
: message;
|
|
return this.createSessionEnvelope(session, normalizedDeletedMessage);
|
|
}
|
|
|
|
const normalizedMessage =
|
|
message.type === 'chat:message'
|
|
? {
|
|
...message,
|
|
payload: normalizeStructuredChatMessage(message.payload),
|
|
}
|
|
: message;
|
|
const envelope = this.createSessionEnvelope(session, normalizedMessage);
|
|
this.retainEnvelopeForReplay(session, envelope);
|
|
|
|
this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send websocket session envelope');
|
|
|
|
if (normalizedMessage.type === 'chat:message') {
|
|
this.persistConversationMessage(session, normalizedMessage.payload);
|
|
|
|
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
|
|
void this.sendOfflineNotificationBestEffort(session, normalizedMessage.payload);
|
|
}
|
|
}
|
|
|
|
return envelope;
|
|
}
|
|
|
|
private async sendOfflineNotificationBestEffort(session: ChatSessionState, message: ChatMessage) {
|
|
try {
|
|
await this.sendOfflineNotificationIfNeeded(session, message);
|
|
} catch (error: unknown) {
|
|
this.logger.error(error, 'failed to send offline chat notification; request processing will continue');
|
|
}
|
|
}
|
|
|
|
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
|
|
const normalizedMessage = normalizeStructuredChatMessage(message);
|
|
const envelope = this.createSessionEnvelope(session, {
|
|
type: 'chat:message:update',
|
|
payload: normalizedMessage,
|
|
});
|
|
this.retainEnvelopeForReplay(session, envelope);
|
|
|
|
this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send websocket message update envelope');
|
|
|
|
// Streaming codex deltas and synthesized activity summaries are transient UI state.
|
|
// Persist only the final chat message / activity rows to avoid long DB tails that
|
|
// can keep a finished request looking "running" until every intermediate update flushes.
|
|
if (shouldPersistMessageUpdate(normalizedMessage)) {
|
|
this.persistConversationMessage(session, normalizedMessage);
|
|
}
|
|
|
|
return envelope;
|
|
}
|
|
|
|
broadcastRequestUpdate(sessionId: string, request: ChatConversationRequestItem) {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
for (const session of this.sessions.values()) {
|
|
if (session.sessionId !== normalizedSessionId) {
|
|
continue;
|
|
}
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:request:update',
|
|
payload: request,
|
|
});
|
|
}
|
|
}
|
|
|
|
broadcastMessageUpdate(sessionId: string, message: ChatMessage) {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
for (const session of this.sessions.values()) {
|
|
if (session.sessionId !== normalizedSessionId) {
|
|
continue;
|
|
}
|
|
|
|
this.updateMessageInSession(session, message);
|
|
}
|
|
}
|
|
|
|
private broadcastRuntimeSnapshot(snapshot = chatRuntimeService.getSnapshot()) {
|
|
for (const session of this.sessions.values()) {
|
|
this.sendToSession(session, {
|
|
type: 'chat:runtime',
|
|
payload: snapshot,
|
|
});
|
|
|
|
this.pushRuntimeDetail(session);
|
|
}
|
|
}
|
|
|
|
private broadcastNotificationMessageChange(event: NotificationMessageChangeEvent) {
|
|
for (const session of this.sessions.values()) {
|
|
const payload =
|
|
event.action === 'deleted'
|
|
? {
|
|
action: 'deleted' as const,
|
|
itemId: event.id,
|
|
}
|
|
: {
|
|
action: event.action,
|
|
itemId: event.item.id,
|
|
category: event.item.category,
|
|
read: event.item.read,
|
|
};
|
|
const envelope = this.createSessionEnvelope(session, {
|
|
type: 'notification:messages-updated',
|
|
payload,
|
|
});
|
|
this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send notification message update envelope');
|
|
}
|
|
}
|
|
|
|
getSessionClientIdMap() {
|
|
return new Map(
|
|
[...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]),
|
|
);
|
|
}
|
|
|
|
private pushRuntimeDetail(session: ChatSessionState) {
|
|
const requestId = session.watchedRuntimeRequestId?.trim();
|
|
|
|
if (!requestId) {
|
|
return;
|
|
}
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:runtime:detail',
|
|
payload: chatRuntimeService.getJobDetail(requestId),
|
|
});
|
|
}
|
|
|
|
private async cancelRuntimeJob(requestId: string) {
|
|
const execution = activeChatProcessRegistry.get(requestId);
|
|
const detail = chatRuntimeService.getJobDetail(requestId);
|
|
|
|
if (!execution && detail.item && detail.terminalStatus == null) {
|
|
chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.');
|
|
this.cancelledRequestIds.add(requestId);
|
|
const session = this.findSessionByRequestId(requestId);
|
|
|
|
if (session) {
|
|
void upsertChatConversationRequest(session.sessionId, {
|
|
requestId,
|
|
status: 'cancelled',
|
|
statusMessage: '사용자 요청으로 실행 취소를 대기합니다.',
|
|
}).catch((error: unknown) => {
|
|
this.logger.warn(error, 'failed to persist pending chat request cancellation state');
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (!execution) {
|
|
return false;
|
|
}
|
|
|
|
chatRuntimeService.appendLog(requestId, '사용자 요청으로 실행 취소를 시도합니다.');
|
|
this.cancelledRequestIds.add(requestId);
|
|
const session = this.findSessionByRequestId(requestId);
|
|
|
|
if (session) {
|
|
void upsertChatConversationRequest(session.sessionId, {
|
|
requestId,
|
|
status: 'cancelled',
|
|
statusMessage: '사용자 요청으로 실행 취소를 시도합니다.',
|
|
}).catch((error: unknown) => {
|
|
this.logger.warn(error, 'failed to persist chat request cancellation state');
|
|
});
|
|
}
|
|
|
|
try {
|
|
const cancelled = await execution.cancel();
|
|
|
|
if (!cancelled && this.cancelledRequestIds.has(requestId)) {
|
|
chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.');
|
|
return true;
|
|
}
|
|
|
|
return cancelled;
|
|
} catch (error) {
|
|
this.logger.warn(error, 'failed to cancel chat runtime job');
|
|
chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
|
|
this.cancelledRequestIds.delete(requestId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async removeQueuedRuntimeJob(requestId: string) {
|
|
let removed = false;
|
|
let removedSessionId: string | null = null;
|
|
|
|
for (const session of this.sessions.values()) {
|
|
const nextQueue = session.queue.filter((item) => item.requestId !== requestId);
|
|
|
|
if (nextQueue.length === session.queue.length) {
|
|
continue;
|
|
}
|
|
|
|
session.queue = nextQueue;
|
|
removed = true;
|
|
removedSessionId = session.sessionId;
|
|
this.emitJobState(session, {
|
|
requestId,
|
|
status: 'completed',
|
|
mode: 'queue',
|
|
queueSize: session.queue.length,
|
|
message: '대기열에서 제거됨',
|
|
});
|
|
this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: createMessage('system', '대기열 요청이 관리 화면에서 제거되었습니다.'),
|
|
});
|
|
break;
|
|
}
|
|
|
|
if (!removed) {
|
|
return false;
|
|
}
|
|
|
|
chatRuntimeService.finishJob(requestId, 'removed');
|
|
if (removedSessionId) {
|
|
await upsertChatConversationRequest(removedSessionId, {
|
|
requestId,
|
|
status: 'removed',
|
|
statusMessage: '대기열에서 제거되었습니다.',
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private tryStartNextQueuedRequest(session: ChatSessionState) {
|
|
this.normalizeSessionExecutionState(session);
|
|
|
|
if (session.activeRequestCount > 0 || session.queue.length === 0 || session.pendingQueueReleaseEventId !== null) {
|
|
return;
|
|
}
|
|
|
|
const nextQueuedRequest = session.queue.shift();
|
|
|
|
if (!nextQueuedRequest) {
|
|
return;
|
|
}
|
|
|
|
void this.executeRequest(session, nextQueuedRequest).catch((error: unknown) => {
|
|
this.logger.error(error, 'queued chat reply build failed');
|
|
this.sendToSession(session, {
|
|
type: 'chat:error',
|
|
payload: {
|
|
message: '대기 중이던 채팅 요청 처리 중 오류가 발생했습니다.',
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
private clearPendingQueueReleaseTimer(session: ChatSessionState) {
|
|
if (session.pendingQueueReleaseTimer !== null) {
|
|
clearTimeout(session.pendingQueueReleaseTimer);
|
|
session.pendingQueueReleaseTimer = null;
|
|
}
|
|
}
|
|
|
|
private releaseQueuedRequests(session: ChatSessionState) {
|
|
session.pendingQueueReleaseEventId = null;
|
|
this.clearPendingQueueReleaseTimer(session);
|
|
this.tryStartNextQueuedRequest(session);
|
|
}
|
|
|
|
private schedulePendingQueueRelease(session: ChatSessionState) {
|
|
this.clearPendingQueueReleaseTimer(session);
|
|
session.pendingQueueReleaseTimer = setTimeout(() => {
|
|
if (session.pendingQueueReleaseEventId === null) {
|
|
session.pendingQueueReleaseTimer = null;
|
|
return;
|
|
}
|
|
|
|
this.releaseQueuedRequests(session);
|
|
}, QUEUE_RELEASE_FALLBACK_DELAY_MS);
|
|
}
|
|
|
|
private normalizeSessionExecutionState(session: ChatSessionState) {
|
|
const snapshot = chatRuntimeService.getSnapshot();
|
|
const runtimeSession = snapshot.sessions.find((item) => item.sessionId === session.sessionId) ?? null;
|
|
const runtimeRunningCount = runtimeSession?.runningCount ?? 0;
|
|
const runtimeQueuedIds = new Set(
|
|
snapshot.queued.filter((item) => item.sessionId === session.sessionId).map((item) => item.requestId),
|
|
);
|
|
|
|
session.activeRequestCount = runtimeRunningCount;
|
|
session.queue = session.queue.filter((item) => runtimeQueuedIds.has(item.requestId));
|
|
}
|
|
|
|
private emitJobState(
|
|
session: ChatSessionState,
|
|
payload: {
|
|
requestId: string;
|
|
status: 'queued' | 'started' | 'completed' | 'failed';
|
|
mode: 'queue' | 'direct';
|
|
queueSize: number;
|
|
message: string;
|
|
},
|
|
) {
|
|
this.sendToSession(session, {
|
|
type: 'chat:job',
|
|
payload,
|
|
});
|
|
|
|
void updateChatConversationJobState(session.sessionId, {
|
|
requestId: payload.requestId,
|
|
status: payload.status,
|
|
message: payload.message,
|
|
queueSize: payload.queueSize,
|
|
clear: false,
|
|
}).catch((error: unknown) => {
|
|
this.logger.error(error, 'failed to persist chat job state');
|
|
});
|
|
|
|
const requestStatus =
|
|
payload.status === 'queued'
|
|
? 'queued'
|
|
: payload.status === 'started'
|
|
? 'started'
|
|
: payload.status === 'completed'
|
|
? 'completed'
|
|
: this.cancelledRequestIds.has(payload.requestId)
|
|
? 'cancelled'
|
|
: 'failed';
|
|
|
|
void upsertChatConversationRequest(session.sessionId, {
|
|
requestId: payload.requestId,
|
|
status: requestStatus,
|
|
statusMessage: payload.message,
|
|
}).catch((error: unknown) => {
|
|
this.logger.error(error, 'failed to persist chat request state');
|
|
});
|
|
}
|
|
|
|
private findSessionByRequestId(requestId: string) {
|
|
for (const session of this.sessions.values()) {
|
|
if (session.queue.some((item) => item.requestId === requestId)) {
|
|
return session;
|
|
}
|
|
|
|
if (
|
|
session.eventHistory.some((event) => {
|
|
if (event.type === 'chat:job') {
|
|
return event.payload.requestId === requestId;
|
|
}
|
|
|
|
if (event.type === 'chat:message' || event.type === 'chat:message:update') {
|
|
return event.payload.clientRequestId === requestId;
|
|
}
|
|
|
|
return false;
|
|
})
|
|
) {
|
|
return session;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async sendOfflineNotificationIfNeeded(session: ChatSessionState, message: ChatMessage) {
|
|
if (!session.clientId?.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (message.author !== 'codex' || isPreparingChatReply(message.text)) {
|
|
return;
|
|
}
|
|
|
|
const appOrigin = resolveChatContextAppOrigin(session.context);
|
|
const appDomain = resolveChatContextAppDomain(session.context);
|
|
const [conversation, appConfig] = await Promise.all([
|
|
getChatConversation(session.sessionId, session.clientId),
|
|
getAppConfigSnapshot(appOrigin),
|
|
]);
|
|
|
|
if (
|
|
!shouldSendOfflineChatNotification({
|
|
receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications,
|
|
conversationNotifyOffline: conversation?.notifyOffline,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const requestOwnerClientId = message.clientRequestId?.trim()
|
|
? (await getChatConversationRequest(session.sessionId, message.clientRequestId).catch(() => null))
|
|
?.requesterClientId?.trim() || null
|
|
: null;
|
|
|
|
if (!requestOwnerClientId) {
|
|
return;
|
|
}
|
|
|
|
const preferredNotificationClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId, {
|
|
keepClientIds: [session.clientId, requestOwnerClientId],
|
|
}).catch(() => []);
|
|
const notificationCandidateClientIds = collectOfflineNotificationClientIds([
|
|
requestOwnerClientId,
|
|
...preferredNotificationClientIds,
|
|
]);
|
|
const notificationTargetClientIds = notificationCandidateClientIds.filter((clientId) => {
|
|
const evaluation = evaluateChatClientActiveViewing(clientId, this.sessions.values());
|
|
logChatClientActiveViewingEvaluation(this.logger, clientId, evaluation);
|
|
return !evaluation.isActive;
|
|
});
|
|
|
|
if (!notificationTargetClientIds.length) {
|
|
this.logger.info(
|
|
{
|
|
sessionId: session.sessionId,
|
|
messageId: message.id,
|
|
requestOwnerClientId,
|
|
notificationCandidateClientIds,
|
|
},
|
|
'chat offline notification skipped because every target client is actively viewing',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const notificationPayload = await this.buildOfflineChatNotificationPayload(
|
|
session,
|
|
message,
|
|
conversation?.title || '현재 채팅방',
|
|
);
|
|
|
|
if (!notificationPayload) {
|
|
return;
|
|
}
|
|
|
|
await this.createOfflineAppNotificationIfNeeded(notificationPayload, notificationTargetClientIds);
|
|
|
|
const result = await sendNotifications(
|
|
{
|
|
title: notificationPayload.title,
|
|
body: notificationPayload.body,
|
|
data: notificationPayload.data,
|
|
threadId: notificationPayload.threadId,
|
|
targetClientIds: notificationTargetClientIds,
|
|
targetAppOrigins: appOrigin ? [appOrigin] : undefined,
|
|
targetAppDomains: appDomain ? [appDomain] : undefined,
|
|
},
|
|
{
|
|
disableIos: true,
|
|
},
|
|
);
|
|
|
|
if (!result.web.ok && !result.web.skipped) {
|
|
this.logger.warn(
|
|
{
|
|
sessionId: session.sessionId,
|
|
messageId: message.id,
|
|
targetClientIds: notificationTargetClientIds,
|
|
webPush: result.web,
|
|
},
|
|
'chat webpush delivery reported failures',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async createOfflineAppNotificationIfNeeded(
|
|
payload: {
|
|
title: string;
|
|
body: string;
|
|
previewText: string;
|
|
metadata: Record<string, string>;
|
|
linkUrl: string;
|
|
},
|
|
targetClientIds: string[],
|
|
) {
|
|
await createNotificationMessage({
|
|
title: payload.title,
|
|
body: payload.body,
|
|
category: 'chat',
|
|
source: 'codex-live',
|
|
priority: 'normal',
|
|
metadata: {
|
|
...payload.metadata,
|
|
targetClientIds,
|
|
linkUrl: payload.linkUrl,
|
|
linkLabel: '채팅 바로 열기',
|
|
previewText: payload.previewText,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async buildOfflineChatNotificationPayload(
|
|
session: ChatSessionState,
|
|
message: ChatMessage,
|
|
conversationTitle: string,
|
|
) {
|
|
const answerText = String(message.text ?? '').trim();
|
|
|
|
if (!answerText || isPreparingChatReply(answerText)) {
|
|
return null;
|
|
}
|
|
|
|
const linkUrl = buildChatNotificationTargetUrl(session.context, session.sessionId);
|
|
const requests = message.clientRequestId?.trim()
|
|
? await listChatConversationRequests(session.sessionId, 200).catch(() => [])
|
|
: [];
|
|
const matchingRequest =
|
|
requests.find((request) => request.requestId === message.clientRequestId) ??
|
|
requests.find((request) => request.responseMessageId === message.id) ??
|
|
null;
|
|
const questionText = matchingRequest?.userText?.trim() || '';
|
|
const fallback = createChatNotificationPreview(answerText) || `${conversationTitle} 채팅방에 새 응답이 도착했습니다.`;
|
|
const body = createChatQuestionAnswerNotificationBody({
|
|
questionText,
|
|
answerText,
|
|
fallback,
|
|
});
|
|
const previewText = createChatQuestionOnlyNotificationPreview(questionText, fallback);
|
|
|
|
if (!body.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const appOrigin = resolveChatContextAppOrigin(session.context);
|
|
const appDomain = resolveChatContextAppDomain(session.context);
|
|
const metadata = {
|
|
category: 'chat',
|
|
sessionId: session.sessionId,
|
|
conversationTitle,
|
|
messageId: String(message.id),
|
|
messageTimestamp: message.timestamp,
|
|
questionText: normalizeNotificationDetailText(questionText) ?? '',
|
|
answerText: normalizeNotificationDetailText(answerText) ?? '',
|
|
targetUrl: linkUrl,
|
|
notificationKey: `chat:${session.sessionId}:${message.id}`,
|
|
type: 'chat-reply',
|
|
suppressIfVisible: 'true',
|
|
appOrigin: normalizeNotificationDetailText(appOrigin) ?? '',
|
|
appDomain: normalizeNotificationDetailText(appDomain) ?? '',
|
|
};
|
|
|
|
return {
|
|
title: 'Codex Live 새 메시지',
|
|
body,
|
|
previewText,
|
|
linkUrl,
|
|
threadId: `chat:${session.sessionId}`,
|
|
data: metadata,
|
|
metadata,
|
|
};
|
|
}
|
|
|
|
private replaySessionHistory(session: ChatSessionState, lastEventId: number) {
|
|
if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const pendingEnvelopes = session.eventHistory.filter((envelope) => envelope.eventId > lastEventId);
|
|
|
|
for (const envelope of pendingEnvelopes) {
|
|
this.sendEnvelopeToSessionSockets(session, envelope, 'failed to replay websocket session history envelope');
|
|
}
|
|
}
|
|
|
|
private async initializeSession(session: ChatSessionState) {
|
|
if (session.isDeleted) {
|
|
return;
|
|
}
|
|
|
|
await session.messagePersistenceTail.catch(() => undefined);
|
|
|
|
const messages = await listChatConversationMessages(session.sessionId, { limit: 500 });
|
|
|
|
const initEnvelope = this.createSessionEnvelope(session, {
|
|
type: 'chat:init',
|
|
payload: {
|
|
messages,
|
|
},
|
|
});
|
|
|
|
this.sendEnvelopeToSessionSockets(session, initEnvelope, 'failed to send websocket init envelope');
|
|
|
|
const statusEnvelope = this.createSessionEnvelope(session, {
|
|
type: 'chat:status',
|
|
payload: {
|
|
connectedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
this.sendEnvelopeToSessionSockets(session, statusEnvelope, 'failed to send websocket status envelope');
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:runtime',
|
|
payload: chatRuntimeService.getSnapshot(),
|
|
});
|
|
}
|
|
|
|
private async handleConnection(socket: WebSocket, request: IncomingMessage) {
|
|
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
|
|
const url = new URL(request.url ?? '/', origin);
|
|
|
|
if (!(await hasAuthorizedChatSocketAccess(request, url))) {
|
|
closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized');
|
|
return;
|
|
}
|
|
|
|
const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId();
|
|
const clientId = url.searchParams.get('clientId')?.trim() || null;
|
|
const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0);
|
|
const lastEventId = Number.isFinite(lastEventIdRaw) && lastEventIdRaw > 0 ? lastEventIdRaw : 0;
|
|
const session = this.getOrCreateSession(requestedSessionId, clientId);
|
|
|
|
session.sockets.add(socket);
|
|
session.lastSeenAt = Date.now();
|
|
this.clientStates.set(socket, session);
|
|
trackWebSocketConnectionOpened();
|
|
|
|
socket.on('message', (raw: RawData) => {
|
|
this.handleMessage(socket, raw);
|
|
});
|
|
|
|
socket.on('close', () => {
|
|
this.clientStates.delete(socket);
|
|
session.sockets.delete(socket);
|
|
trackWebSocketConnectionClosed();
|
|
});
|
|
|
|
socket.on('error', (error: Error) => {
|
|
this.logger.error(error, 'chat websocket error');
|
|
this.clientStates.delete(socket);
|
|
session.sockets.delete(socket);
|
|
trackWebSocketConnectionClosed();
|
|
});
|
|
|
|
await this.initializeSession(session);
|
|
this.replaySessionHistory(session, lastEventId);
|
|
}
|
|
|
|
async forgetSession(sessionId: string) {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
const session = this.sessions.get(normalizedSessionId);
|
|
const runtimeSnapshot = chatRuntimeService.getSnapshot();
|
|
const runtimeRequestIds = new Set(
|
|
[...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
|
|
.filter((item) => item.sessionId === normalizedSessionId)
|
|
.map((item) => item.requestId),
|
|
);
|
|
|
|
if (session) {
|
|
session.isDeleted = true;
|
|
session.queue = [];
|
|
session.eventHistory = [];
|
|
session.pendingQueueReleaseEventId = null;
|
|
this.clearPendingQueueReleaseTimer(session);
|
|
session.watchedRuntimeRequestId = null;
|
|
session.activeRequestCount = 0;
|
|
|
|
for (const socket of session.sockets) {
|
|
this.clientStates.delete(socket);
|
|
closeSocketSafely(this.logger, socket, 'failed to close deleted chat websocket session');
|
|
}
|
|
session.sockets.clear();
|
|
|
|
this.sessions.delete(normalizedSessionId);
|
|
}
|
|
|
|
for (const requestId of runtimeRequestIds) {
|
|
const detail = chatRuntimeService.getJobDetail(requestId);
|
|
|
|
if (detail.availableActions.cancel) {
|
|
try {
|
|
await this.cancelRuntimeJob(requestId);
|
|
} catch {
|
|
// ignore and hard-clear runtime state below
|
|
}
|
|
} else if (detail.availableActions.remove) {
|
|
try {
|
|
await this.removeQueuedRuntimeJob(requestId);
|
|
} catch {
|
|
// ignore and hard-clear runtime state below
|
|
}
|
|
}
|
|
|
|
activeChatProcessRegistry.delete(requestId);
|
|
this.cancelledRequestIds.delete(requestId);
|
|
}
|
|
|
|
chatRuntimeService.clearSession(normalizedSessionId);
|
|
}
|
|
|
|
resetSessionData(sessionId: string) {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
const session = this.sessions.get(normalizedSessionId);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.queue = [];
|
|
session.eventHistory = [];
|
|
session.pendingQueueReleaseEventId = null;
|
|
this.clearPendingQueueReleaseTimer(session);
|
|
session.watchedRuntimeRequestId = null;
|
|
session.activeRequestCount = 0;
|
|
}
|
|
|
|
private handleMessage(socket: WebSocket, raw: RawData) {
|
|
try {
|
|
const message = JSON.parse(raw.toString()) as ChatInboundMessage;
|
|
const session = this.clientStates.get(socket);
|
|
|
|
if (session) {
|
|
session.lastSeenAt = Date.now();
|
|
}
|
|
|
|
if (message.type === 'context:update') {
|
|
this.handleContextUpdate(socket, message.payload);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'presence:ping') {
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'event:received') {
|
|
const receivedEventId = Math.round(Number(message.payload?.eventId ?? 0));
|
|
|
|
if (
|
|
session &&
|
|
Number.isFinite(receivedEventId) &&
|
|
receivedEventId > 0 &&
|
|
session.pendingQueueReleaseEventId !== null &&
|
|
receivedEventId >= session.pendingQueueReleaseEventId
|
|
) {
|
|
this.releaseQueuedRequests(session);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'message:send') {
|
|
this.rebindSocketToSession(socket, message.payload.sessionId);
|
|
|
|
void this.handleUserMessage(
|
|
socket,
|
|
message.payload.text,
|
|
message.payload.requestId,
|
|
message.payload.mode === 'direct' ? 'direct' : 'queue',
|
|
{
|
|
codexModel: message.payload.codexModel ?? null,
|
|
codexParticipants: message.payload.codexParticipants,
|
|
chatTypeId: message.payload.chatTypeId ?? null,
|
|
chatTypeLabel: message.payload.chatTypeLabel,
|
|
chatTypeDescription: message.payload.chatTypeDescription,
|
|
chatTypeBaseDescription: message.payload.chatTypeBaseDescription,
|
|
defaultContextIds: message.payload.defaultContextIds,
|
|
defaultContexts: message.payload.defaultContexts,
|
|
customContextTitle: message.payload.customContextTitle,
|
|
customContextContent: message.payload.customContextContent,
|
|
},
|
|
{
|
|
omitPromptHistory: message.payload.omitPromptHistory === true,
|
|
requestOrigin:
|
|
message.payload.requestOrigin === 'prompt' || message.payload.requestOrigin === 'composer'
|
|
? message.payload.requestOrigin
|
|
: undefined,
|
|
parentRequestId: message.payload.parentRequestId,
|
|
promptContextRef: normalizeChatPromptContextRef(message.payload.promptContextRef),
|
|
},
|
|
).catch((error: unknown) => {
|
|
this.logger.error(error, 'chat reply build failed');
|
|
const session = this.clientStates.get(socket);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:error',
|
|
payload: {
|
|
message:
|
|
error instanceof Error && error.message.trim()
|
|
? error.message.trim()
|
|
: '채팅 응답을 만드는 중 오류가 발생했습니다.',
|
|
},
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'runtime:watch') {
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.watchedRuntimeRequestId = message.payload?.requestId?.trim() || null;
|
|
this.pushRuntimeDetail(session);
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn(error, 'invalid chat websocket payload');
|
|
const session = this.clientStates.get(socket);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:error',
|
|
payload: {
|
|
message: '채팅 메시지를 처리하지 못했습니다.',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private handleContextUpdate(socket: WebSocket, context: ChatContext) {
|
|
const state = this.clientStates.get(socket);
|
|
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
const previousFocus = state.context?.focusedComponentId;
|
|
const previousContextLabel = state.context?.chatTypeLabel ?? null;
|
|
const previousContextDescription = state.context?.chatTypeDescription ?? null;
|
|
const previousCodexModel = state.context?.codexModel ?? null;
|
|
state.context = context;
|
|
const nextContextLabel = context.chatTypeLabel ?? null;
|
|
const nextContextDescription = context.chatTypeDescription ?? null;
|
|
const nextCodexModel = context.codexModel ?? null;
|
|
|
|
if (
|
|
previousContextLabel !== nextContextLabel ||
|
|
previousContextDescription !== nextContextDescription ||
|
|
previousCodexModel !== nextCodexModel
|
|
) {
|
|
void updateChatConversationContext(state.sessionId, {
|
|
clientId: state.clientId,
|
|
codexModel: nextCodexModel,
|
|
contextLabel: nextContextLabel,
|
|
contextDescription: nextContextDescription,
|
|
}).catch((error: unknown) => {
|
|
this.logger.error(error, 'failed to persist chat context');
|
|
});
|
|
}
|
|
|
|
if (context.focusedComponentId && context.focusedComponentId !== previousFocus) {
|
|
this.sendToSession(state, {
|
|
type: 'chat:message',
|
|
payload: createMessage('codex', `선택 포커스가 ${context.focusedComponentId}로 이동했습니다. 관련 문맥을 반영합니다.`),
|
|
});
|
|
}
|
|
}
|
|
|
|
private rebindSocketToSession(socket: WebSocket, targetSessionId?: string | null) {
|
|
const normalizedTargetSessionId = targetSessionId?.trim() || '';
|
|
const currentSession = this.clientStates.get(socket);
|
|
|
|
if (!currentSession || !normalizedTargetSessionId || currentSession.sessionId === normalizedTargetSessionId) {
|
|
return currentSession ?? null;
|
|
}
|
|
|
|
const targetSession = this.getOrCreateSession(normalizedTargetSessionId, currentSession.clientId);
|
|
|
|
currentSession.sockets.delete(socket);
|
|
targetSession.sockets.add(socket);
|
|
targetSession.lastSeenAt = Date.now();
|
|
this.clientStates.set(socket, targetSession);
|
|
return targetSession;
|
|
}
|
|
|
|
private async handleUserMessage(
|
|
socket: WebSocket,
|
|
text: string,
|
|
requestId?: string,
|
|
mode: 'queue' | 'direct' = 'queue',
|
|
contextOverride?: Partial<ChatContext> | null,
|
|
requestOptions?: {
|
|
omitPromptHistory?: boolean;
|
|
requestOrigin?: 'composer' | 'prompt';
|
|
sharedResourceTokenId?: string | null;
|
|
parentRequestId?: string | null;
|
|
promptContextRef?: ChatPromptContextRef | null;
|
|
},
|
|
) {
|
|
const state = this.clientStates.get(socket);
|
|
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
await this.submitUserMessageToSession(state, text, requestId, mode, contextOverride, requestOptions);
|
|
}
|
|
|
|
async submitExternalMessage(
|
|
sessionId: string,
|
|
text: string,
|
|
options?: {
|
|
requestId?: string;
|
|
mode?: 'queue' | 'direct';
|
|
requestOrigin?: 'composer' | 'prompt';
|
|
sharedResourceTokenId?: string | null;
|
|
parentRequestId?: string | null;
|
|
promptContextRef?: ChatPromptContextRef | null;
|
|
omitPromptHistory?: boolean;
|
|
contextOverride?: Partial<ChatContext> | null;
|
|
clientId?: string | null;
|
|
},
|
|
) {
|
|
const normalizedSessionId = sessionId.trim();
|
|
const trimmedText = text.trim();
|
|
|
|
if (!normalizedSessionId || !trimmedText) {
|
|
return null;
|
|
}
|
|
|
|
const conversation = await getChatConversation(normalizedSessionId, options?.clientId ?? null);
|
|
const parentRequest = options?.parentRequestId?.trim()
|
|
? await getChatConversationRequest(normalizedSessionId, options.parentRequestId.trim())
|
|
: null;
|
|
const session = this.getOrCreateSession(
|
|
normalizedSessionId,
|
|
conversation?.clientId ?? parentRequest?.requesterClientId ?? options?.clientId ?? null,
|
|
);
|
|
|
|
session.clientId =
|
|
conversation?.clientId?.trim() ||
|
|
parentRequest?.requesterClientId?.trim() ||
|
|
options?.clientId?.trim() ||
|
|
session.clientId;
|
|
|
|
const baseContext: Partial<ChatContext> = {
|
|
pageId: null,
|
|
pageTitle: '',
|
|
topMenu: '',
|
|
focusedComponentId: null,
|
|
pageUrl: '',
|
|
codexModel: conversation?.codexModel ?? null,
|
|
chatTypeId: parentRequest?.chatTypeId ?? conversation?.chatTypeId ?? conversation?.lastChatTypeId ?? null,
|
|
chatTypeLabel: parentRequest?.chatTypeLabel ?? conversation?.contextLabel ?? '',
|
|
chatTypeDescription: conversation?.contextDescription ?? '',
|
|
};
|
|
|
|
return this.submitUserMessageToSession(
|
|
session,
|
|
trimmedText,
|
|
options?.requestId,
|
|
options?.mode === 'direct' ? 'direct' : 'queue',
|
|
{
|
|
...baseContext,
|
|
...(options?.contextOverride ?? {}),
|
|
},
|
|
{
|
|
omitPromptHistory: options?.omitPromptHistory,
|
|
requestOrigin: options?.requestOrigin,
|
|
sharedResourceTokenId: options?.sharedResourceTokenId,
|
|
parentRequestId: options?.parentRequestId,
|
|
promptContextRef: options?.promptContextRef,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async submitUserMessageToSession(
|
|
state: ChatSessionState,
|
|
text: string,
|
|
requestId?: string,
|
|
mode: 'queue' | 'direct' = 'queue',
|
|
contextOverride?: Partial<ChatContext> | null,
|
|
requestOptions?: {
|
|
omitPromptHistory?: boolean;
|
|
requestOrigin?: 'composer' | 'prompt';
|
|
sharedResourceTokenId?: string | null;
|
|
parentRequestId?: string | null;
|
|
promptContextRef?: ChatPromptContextRef | null;
|
|
},
|
|
) {
|
|
const trimmed = text.trim();
|
|
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (isRuntimeDraining()) {
|
|
this.sendToSession(state, {
|
|
type: 'chat:error',
|
|
payload: {
|
|
message: '현재 서버가 배포 전환 중이라 새 AI 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
|
|
},
|
|
});
|
|
return null;
|
|
}
|
|
|
|
if (contextOverride) {
|
|
const mergedContext = {
|
|
...(state.context ?? {
|
|
pageId: null,
|
|
pageTitle: '',
|
|
topMenu: '',
|
|
focusedComponentId: null,
|
|
pageUrl: '',
|
|
}),
|
|
...contextOverride,
|
|
};
|
|
state.context = (await resolveCodexLiveChatContext(mergedContext, state.sessionId)) ?? mergedContext;
|
|
void updateChatConversationContext(state.sessionId, {
|
|
clientId: state.clientId,
|
|
codexModel: state.context.codexModel ?? null,
|
|
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);
|
|
|
|
const nextRequestId = requestId?.trim() || createRequestId();
|
|
const requestedAtMs = Date.now();
|
|
const requestOrigin: 'composer' | 'prompt' =
|
|
requestOptions?.requestOrigin === 'prompt' ? 'prompt' : 'composer';
|
|
|
|
const request: {
|
|
requestId: string;
|
|
text: string;
|
|
mode: 'queue' | 'direct';
|
|
requestedAtMs: number;
|
|
requestOrigin: 'composer' | 'prompt';
|
|
sharedResourceTokenId: string | null;
|
|
parentRequestId: string | null;
|
|
promptContextRef: ChatPromptContextRef | null;
|
|
omitPromptHistory?: boolean;
|
|
context: ChatContext | null;
|
|
} = {
|
|
requestId: nextRequestId,
|
|
text: trimmed,
|
|
mode,
|
|
requestedAtMs,
|
|
requestOrigin,
|
|
sharedResourceTokenId: requestOptions?.sharedResourceTokenId?.trim() || null,
|
|
parentRequestId: requestOptions?.parentRequestId?.trim() || null,
|
|
promptContextRef: normalizeChatPromptContextRef(requestOptions?.promptContextRef),
|
|
omitPromptHistory: requestOptions?.omitPromptHistory === true,
|
|
context: cloneChatContext(state.context),
|
|
};
|
|
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
|
|
const queuedUserMessage = {
|
|
...createMessage('user', trimmed, nextRequestId),
|
|
timestamp: formatTime(new Date(requestedAtMs)),
|
|
};
|
|
|
|
this.sendToSession(state, {
|
|
type: 'chat:message',
|
|
payload: queuedUserMessage,
|
|
});
|
|
state.queue.push(request);
|
|
chatRuntimeService.enqueueJob({
|
|
sessionId: state.sessionId,
|
|
requestId: nextRequestId,
|
|
mode,
|
|
text: trimmed,
|
|
});
|
|
chatRuntimeService.registerQueuedControl(nextRequestId, {
|
|
remove: () => this.removeQueuedRuntimeJob(nextRequestId),
|
|
});
|
|
this.emitJobState(state, {
|
|
requestId: nextRequestId,
|
|
status: 'queued',
|
|
mode,
|
|
queueSize: state.queue.length,
|
|
message: `대기열 ${state.queue.length}건`,
|
|
});
|
|
void upsertChatConversationRequest(state.sessionId, {
|
|
requestId: nextRequestId,
|
|
chatTypeId: request.context?.chatTypeId ?? state.context?.chatTypeId ?? null,
|
|
chatTypeLabel: request.context?.chatTypeLabel ?? state.context?.chatTypeLabel ?? null,
|
|
requestOrigin: request.requestOrigin,
|
|
sharedResourceTokenId: request.sharedResourceTokenId,
|
|
parentRequestId: request.parentRequestId,
|
|
promptContextRef: request.promptContextRef,
|
|
status: 'queued',
|
|
statusMessage: `대기열 ${state.queue.length}건`,
|
|
userMessageId: queuedUserMessage.id,
|
|
userText: trimmed,
|
|
}).catch((error: unknown) => {
|
|
this.logger.error(error, 'failed to persist queued chat request');
|
|
});
|
|
return nextRequestId;
|
|
}
|
|
|
|
void this.executeRequest(state, request).catch((error: unknown) => {
|
|
this.logger.error(error, 'direct chat reply build failed');
|
|
this.sendToSession(state, {
|
|
type: 'chat:error',
|
|
payload: {
|
|
message: '즉시 채팅 요청 처리 중 오류가 발생했습니다.',
|
|
},
|
|
});
|
|
});
|
|
return nextRequestId;
|
|
}
|
|
|
|
private async executeRequest(
|
|
session: ChatSessionState,
|
|
request: {
|
|
requestId: string;
|
|
text: string;
|
|
mode: 'queue' | 'direct';
|
|
requestedAtMs: number;
|
|
requestOrigin?: 'composer' | 'prompt';
|
|
sharedResourceTokenId?: string | null;
|
|
parentRequestId?: string | null;
|
|
promptContextRef?: ChatPromptContextRef | null;
|
|
omitPromptHistory?: boolean;
|
|
context: ChatContext | null;
|
|
},
|
|
) {
|
|
let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed';
|
|
let hasAnnouncedStreaming = false;
|
|
const compactActivityLineMap = new Map<number, string>();
|
|
session.activeRequestCount += 1;
|
|
const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId);
|
|
const isRetryAttempt =
|
|
existingRequest != null
|
|
&& !existingRequest.hasResponse
|
|
&& (existingRequest.status === 'failed' || existingRequest.status === 'cancelled');
|
|
const hasStoredUserMessage = existingRequest?.userMessageId != null;
|
|
let userMessageId = existingRequest?.userMessageId ?? null;
|
|
|
|
if (!hasStoredUserMessage) {
|
|
const userMessage = {
|
|
...createMessage('user', request.text, request.requestId),
|
|
timestamp: formatTime(new Date(request.requestedAtMs)),
|
|
};
|
|
userMessageId = userMessage.id;
|
|
this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: userMessage,
|
|
});
|
|
}
|
|
|
|
await upsertChatConversationRequest(session.sessionId, {
|
|
requestId: request.requestId,
|
|
chatTypeId: request.context?.chatTypeId ?? session.context?.chatTypeId ?? null,
|
|
chatTypeLabel: request.context?.chatTypeLabel ?? session.context?.chatTypeLabel ?? null,
|
|
requestOrigin: request.requestOrigin,
|
|
sharedResourceTokenId: request.sharedResourceTokenId,
|
|
parentRequestId: request.parentRequestId,
|
|
promptContextRef: request.promptContextRef,
|
|
status: request.mode === 'direct' ? 'accepted' : existingRequest?.status ?? 'queued',
|
|
statusMessage: isRetryAttempt ? '재처리 요청 접수' : undefined,
|
|
incrementRetryCount: isRetryAttempt,
|
|
allowTerminalStatusReset: isRetryAttempt,
|
|
userMessageId,
|
|
userText: request.text,
|
|
});
|
|
|
|
const appendActivityLineAt = (lineNo: number, line: string) => {
|
|
const normalizedLine = normalizeProgressSummary(line);
|
|
|
|
if (!normalizedLine || lineNo <= 0) {
|
|
return;
|
|
}
|
|
|
|
const previousLine = compactActivityLineMap.get(lineNo);
|
|
|
|
if (previousLine === normalizedLine) {
|
|
return;
|
|
}
|
|
|
|
compactActivityLineMap.set(lineNo, normalizedLine);
|
|
const activityLines = Array.from(compactActivityLineMap.entries())
|
|
.sort((left, right) => left[0] - right[0])
|
|
.map(([, activityLine]) => activityLine);
|
|
|
|
void appendChatConversationActivityLine(
|
|
session.sessionId,
|
|
request.requestId,
|
|
normalizedLine,
|
|
lineNo,
|
|
).catch((error: unknown) => {
|
|
this.logger.warn(error, 'failed to persist compact chat activity line');
|
|
});
|
|
this.sendToSession(session, {
|
|
type: 'chat:activity',
|
|
payload: {
|
|
requestId: request.requestId,
|
|
line: normalizedLine,
|
|
lineCount: activityLines.length,
|
|
lineNo,
|
|
},
|
|
});
|
|
const activityMessage = createActivityLogMessage(request.requestId, activityLines);
|
|
|
|
if (activityMessage) {
|
|
this.updateMessageInSession(session, activityMessage);
|
|
}
|
|
};
|
|
const appendActivityLine = (line: string) => {
|
|
const compactEntry = createCompactActivityLogEntry(line);
|
|
|
|
if (!compactEntry) {
|
|
return;
|
|
}
|
|
|
|
appendActivityLineAt(compactEntry.lineNo, compactEntry.line);
|
|
};
|
|
|
|
chatRuntimeService.startJob({
|
|
sessionId: session.sessionId,
|
|
requestId: request.requestId,
|
|
mode: request.mode,
|
|
text: request.text,
|
|
});
|
|
chatRuntimeService.registerRunningControl(request.requestId, {
|
|
cancel: () => this.cancelRuntimeJob(request.requestId),
|
|
});
|
|
chatRuntimeService.appendLog(request.requestId, `요청을 처리합니다. mode=${request.mode}`);
|
|
appendActivityLine(`# 상태: 요청을 처리합니다. mode=${request.mode}`);
|
|
this.emitJobState(session, {
|
|
requestId: request.requestId,
|
|
status: 'started',
|
|
mode: request.mode,
|
|
queueSize: session.queue.length,
|
|
message:
|
|
request.mode === 'direct'
|
|
? '즉시 요청 실행 중'
|
|
: `요청 처리 중${session.queue.length > 0 ? ` · 대기열 ${session.queue.length}건` : ''}`,
|
|
});
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: createMessage(
|
|
'system',
|
|
request.mode === 'direct'
|
|
? '즉시 요청 실행 중입니다.'
|
|
: `요청 실행 중입니다.${session.queue.length > 0 ? ` 남은 대기열 ${session.queue.length}건` : ''}`,
|
|
request.requestId,
|
|
),
|
|
});
|
|
|
|
const stopProgressTimer = () => {
|
|
if (progressTimer !== null) {
|
|
clearTimeout(progressTimer);
|
|
progressTimer = null;
|
|
}
|
|
};
|
|
const progressMessages = buildProgressMessages(request.text);
|
|
let progressIndex = progressMessages.length > 1 ? 1 : 0;
|
|
let lastProgressMessage = progressMessages[0] ?? '';
|
|
let lastMeaningfulProgressSummary = '';
|
|
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const emitSystemProgressMessage = (nextMessage: string, options?: { appendActivity?: boolean }) => {
|
|
const normalizedMessage = normalizeProgressSummary(nextMessage);
|
|
|
|
if (!normalizedMessage || normalizedMessage === lastProgressMessage) {
|
|
return false;
|
|
}
|
|
|
|
lastProgressMessage = normalizedMessage;
|
|
chatRuntimeService.appendLog(request.requestId, normalizedMessage);
|
|
|
|
if (options?.appendActivity !== false) {
|
|
appendActivityLine(`# 진행: ${normalizedMessage}`);
|
|
}
|
|
|
|
this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: createMessage('system', normalizedMessage, request.requestId),
|
|
});
|
|
return true;
|
|
};
|
|
const scheduleFallbackProgressMessage = () => {
|
|
if (progressIndex >= progressMessages.length) {
|
|
return;
|
|
}
|
|
|
|
stopProgressTimer();
|
|
progressTimer = setTimeout(() => {
|
|
const nextMessage = progressMessages[progressIndex] ?? '';
|
|
|
|
if (!nextMessage || lastMeaningfulProgressSummary) {
|
|
stopProgressTimer();
|
|
return;
|
|
}
|
|
|
|
if (emitSystemProgressMessage(nextMessage)) {
|
|
progressIndex += 1;
|
|
}
|
|
|
|
stopProgressTimer();
|
|
}, 2200);
|
|
};
|
|
|
|
const executionContext = request.context ?? session.context ?? null;
|
|
const codexParticipants = resolveCodexParticipantsForExecution(executionContext);
|
|
const codexExecutionStages = resolveCodexExecutionStages(executionContext, request.mode);
|
|
const shouldShowCodexExecutionPlan = request.mode === 'direct' && codexParticipants.length > 1;
|
|
const planSteps = shouldShowCodexExecutionPlan ? buildCodexExecutionPlanSteps(codexExecutionStages) : [];
|
|
const executorSlots = shouldShowCodexExecutionPlan
|
|
? buildCodexExecutorActivitySlots(codexParticipants, 12 + planSteps.length + 2)
|
|
: [];
|
|
const planStepsByParticipantKey = new Map<string, CodexExecutionPlanStep[]>();
|
|
const completedPlanParticipantKeys = new Set<string>();
|
|
const executorSlotByParticipantId = new Map<string, CodexExecutorActivitySlot>();
|
|
planSteps.forEach((step) => {
|
|
step.participantKeys?.forEach((participantKey) => {
|
|
const currentSteps = planStepsByParticipantKey.get(participantKey) ?? [];
|
|
currentSteps.push(step);
|
|
planStepsByParticipantKey.set(participantKey, currentSteps);
|
|
});
|
|
});
|
|
executorSlots.forEach((slot) => {
|
|
executorSlotByParticipantId.set(slot.participantId, slot);
|
|
});
|
|
const syncPlanStepCompletion = (step: CodexExecutionPlanStep) => {
|
|
if (!step.participantKeys || step.participantKeys.length === 0) {
|
|
return;
|
|
}
|
|
|
|
writePlanStep(
|
|
step,
|
|
step.participantKeys.every((participantKey) => completedPlanParticipantKeys.has(participantKey)),
|
|
);
|
|
};
|
|
const writePlanStep = (step: CodexExecutionPlanStep, completed: boolean) => {
|
|
appendActivityLineAt(step.lineNo, `${completed ? '☑' : '☐'} ${step.label}`);
|
|
};
|
|
const writeExecutorActivity = (participantId: string, status: string) => {
|
|
const slot = executorSlotByParticipantId.get(participantId);
|
|
|
|
if (!slot) {
|
|
return;
|
|
}
|
|
|
|
appendActivityLineAt(slot.lineNo, `실행기: ${slot.label} · ${normalizeProgressSummary(status) || '대기 중'}`);
|
|
};
|
|
|
|
if (shouldShowCodexExecutionPlan) {
|
|
appendActivityLineAt(10, '# 계획: 관리자 계획');
|
|
planSteps.forEach((step) => {
|
|
writePlanStep(step, false);
|
|
});
|
|
appendActivityLineAt(12 + planSteps.length, '# 실행기: 진행 현황');
|
|
executorSlots.forEach((slot) => {
|
|
appendActivityLineAt(slot.lineNo, `실행기: ${slot.label} · 대기 중`);
|
|
});
|
|
}
|
|
const participantTypeOverrides = await resolveCodexParticipantTypeOverrides(
|
|
codexParticipants,
|
|
executionContext,
|
|
);
|
|
const completedParticipantReplies: Array<{ name: string; model: string; text: string }> = [];
|
|
let finalCodexReplyMessage: ChatMessage | null = null;
|
|
let finalUsageSnapshot: ChatConversationRequestUsageSnapshot | null = null;
|
|
let finalTotalTokens: number | null = null;
|
|
|
|
try {
|
|
chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.');
|
|
appendActivityLine('# 진행: 요청 분석을 시작합니다.');
|
|
this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId),
|
|
});
|
|
scheduleFallbackProgressMessage();
|
|
const runParticipant = async (
|
|
participant: (typeof codexParticipants)[number],
|
|
previousReplies: Array<{ name: string; model: string; text: string }>,
|
|
) => {
|
|
const participantPlanKey = buildCodexPlanParticipantKey(participant);
|
|
const participantPlanSteps = planStepsByParticipantKey.get(participantPlanKey) ?? [];
|
|
|
|
if (participantPlanSteps.length > 0) {
|
|
const analysisStep = planSteps.find((step) => step.kind === 'analysis');
|
|
|
|
if (analysisStep) {
|
|
writePlanStep(analysisStep, true);
|
|
}
|
|
}
|
|
|
|
writeExecutorActivity(participant.id, '응답 준비 중');
|
|
const codexReplyMessage = createMessage('codex', '', request.requestId);
|
|
const participantPrefix = `[${participant.name} · ${participant.model}]`;
|
|
this.updateMessageInSession(session, {
|
|
...codexReplyMessage,
|
|
text: `${participantPrefix}\n응답을 준비하고 있습니다...`,
|
|
timestamp: resolveResponseTimestamp(request.requestedAtMs),
|
|
});
|
|
|
|
const baseExecutionContext = request.context ?? session.context ?? null;
|
|
const participantContextSeed: ChatContext | null = baseExecutionContext
|
|
? {
|
|
...baseExecutionContext,
|
|
pageId: baseExecutionContext.pageId ?? null,
|
|
pageTitle: baseExecutionContext.pageTitle ?? '',
|
|
topMenu: baseExecutionContext.topMenu ?? '',
|
|
focusedComponentId: baseExecutionContext.focusedComponentId ?? null,
|
|
pageUrl: baseExecutionContext.pageUrl ?? '',
|
|
codexModel: participant.model,
|
|
...(participant.chatTypeId
|
|
? {
|
|
chatTypeId: participant.chatTypeId,
|
|
}
|
|
: null),
|
|
...(participant.defaultContextIds.length > 0
|
|
? {
|
|
defaultContextIds: participant.defaultContextIds,
|
|
}
|
|
: null),
|
|
}
|
|
: {
|
|
pageId: null,
|
|
pageTitle: '',
|
|
topMenu: '',
|
|
focusedComponentId: null,
|
|
pageUrl: '',
|
|
codexModel: participant.model,
|
|
chatTypeId: participant.chatTypeId,
|
|
defaultContextIds: participant.defaultContextIds,
|
|
};
|
|
const participantExecutionContext =
|
|
(await resolveCodexLiveChatContext(participantContextSeed, session.sessionId)) ?? participantContextSeed;
|
|
if (participantExecutionContext) {
|
|
participantExecutionContext.codexModel = participant.model;
|
|
}
|
|
|
|
const reply = await buildCodexReply(
|
|
participantExecutionContext,
|
|
buildParticipantRequestInput(
|
|
request.text,
|
|
participant,
|
|
codexParticipants,
|
|
previousReplies,
|
|
participant.chatTypeId ? participantTypeOverrides.get(participant.chatTypeId) : null,
|
|
resolveChatTypeExecutionPolicy(executionContext),
|
|
request.promptContextRef,
|
|
),
|
|
session.sessionId,
|
|
request.requestId,
|
|
{
|
|
omitPromptHistory: request.omitPromptHistory === true,
|
|
requestedAt: new Date(request.requestedAtMs),
|
|
},
|
|
(partialReply) => {
|
|
stopProgressTimer();
|
|
if (!hasAnnouncedStreaming) {
|
|
hasAnnouncedStreaming = true;
|
|
chatRuntimeService.appendLog(request.requestId, '응답을 실시간으로 전송 중입니다.');
|
|
appendActivityLine('# 진행: 응답을 실시간으로 전송 중입니다.');
|
|
}
|
|
this.updateMessageInSession(session, {
|
|
...codexReplyMessage,
|
|
text: `${participantPrefix}\n${partialReply}`.trim(),
|
|
timestamp: resolveResponseTimestamp(request.requestedAtMs),
|
|
});
|
|
},
|
|
(activityLine) => {
|
|
appendActivityLine(activityLine);
|
|
const activitySummary = summarizeActivityProgressLine(activityLine);
|
|
|
|
if (activitySummary) {
|
|
writeExecutorActivity(participant.id, activitySummary);
|
|
}
|
|
|
|
if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) {
|
|
lastMeaningfulProgressSummary = activitySummary;
|
|
stopProgressTimer();
|
|
emitSystemProgressMessage(activitySummary, { appendActivity: false });
|
|
}
|
|
},
|
|
() => this.cancelledRequestIds.has(request.requestId),
|
|
);
|
|
|
|
const extracted = extractChatMessageParts(reply.text);
|
|
finalCodexReplyMessage = {
|
|
...codexReplyMessage,
|
|
text: `${participantPrefix}\n${extracted.strippedText}`.trim(),
|
|
parts: extracted.parts,
|
|
timestamp: resolveResponseTimestamp(request.requestedAtMs),
|
|
};
|
|
|
|
this.sendToSession(
|
|
session,
|
|
{
|
|
type: 'chat:message',
|
|
payload: finalCodexReplyMessage,
|
|
},
|
|
{
|
|
skipOfflineNotification: true,
|
|
},
|
|
);
|
|
|
|
await this.persistConversationMessage(session, finalCodexReplyMessage);
|
|
finalUsageSnapshot = reply.usageSnapshot;
|
|
finalTotalTokens = reply.totalTokens;
|
|
|
|
completedPlanParticipantKeys.add(participantPlanKey);
|
|
participantPlanSteps.forEach((step) => {
|
|
syncPlanStepCompletion(step);
|
|
});
|
|
|
|
writeExecutorActivity(participant.id, '처리 완료');
|
|
|
|
return {
|
|
name: participant.name,
|
|
model: participant.model,
|
|
text: extracted.strippedText,
|
|
};
|
|
};
|
|
|
|
for (const stage of codexExecutionStages) {
|
|
const stagePreviousReplies = [...completedParticipantReplies];
|
|
|
|
if (stage.parallel) {
|
|
const results = await Promise.allSettled(
|
|
stage.participants.map((participant) => runParticipant(participant, stagePreviousReplies)),
|
|
);
|
|
const rejected = results.find(
|
|
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
|
);
|
|
|
|
if (rejected) {
|
|
throw rejected.reason;
|
|
}
|
|
|
|
results.forEach((result) => {
|
|
if (result.status === 'fulfilled') {
|
|
completedParticipantReplies.push(result.value);
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const participant of stage.participants) {
|
|
const result = await runParticipant(participant, [...completedParticipantReplies]);
|
|
completedParticipantReplies.push(result);
|
|
}
|
|
}
|
|
const finalizeStep = planSteps.find((step) => step.kind === 'finalize');
|
|
|
|
if (finalizeStep) {
|
|
writePlanStep(finalizeStep, true);
|
|
}
|
|
chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
|
|
appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');
|
|
|
|
// Final replies must be durably stored before the request is marked complete,
|
|
// otherwise replay/reload can be stuck on the placeholder text.
|
|
if (!finalCodexReplyMessage) {
|
|
throw new Error('Codex 참가자 응답을 만들지 못했습니다.');
|
|
}
|
|
const completedReplyMessage = finalCodexReplyMessage as ChatMessage;
|
|
await upsertChatConversationRequest(session.sessionId, {
|
|
requestId: request.requestId,
|
|
chatTypeId: request.context?.chatTypeId ?? session.context?.chatTypeId ?? null,
|
|
chatTypeLabel: request.context?.chatTypeLabel ?? session.context?.chatTypeLabel ?? null,
|
|
requestOrigin: request.requestOrigin,
|
|
sharedResourceTokenId: request.sharedResourceTokenId,
|
|
parentRequestId: request.parentRequestId,
|
|
status: 'completed',
|
|
statusMessage: '요청 처리 완료',
|
|
responseMessageId: completedReplyMessage.id,
|
|
responseText: completedReplyMessage.text,
|
|
usageSnapshot: finalUsageSnapshot,
|
|
totalTokens: finalTotalTokens,
|
|
});
|
|
await refreshChatSessionReferenceForRequest({
|
|
sessionId: session.sessionId,
|
|
requestId: request.requestId,
|
|
context: request.context ?? session.context ?? null,
|
|
input: request.text,
|
|
requestStatus: 'completed',
|
|
requestedAt: new Date(request.requestedAtMs),
|
|
completedAt: new Date(),
|
|
});
|
|
|
|
const terminalMessageEnvelope = this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: createMessage(
|
|
'system',
|
|
request.mode === 'direct'
|
|
? '즉시 요청 처리가 끝났습니다.'
|
|
: `요청 처리가 끝났습니다.${session.queue.length > 0 ? ` 대기열 ${session.queue.length}건이 이어집니다.` : ''}`,
|
|
request.requestId,
|
|
),
|
|
});
|
|
session.pendingQueueReleaseEventId = terminalMessageEnvelope.eventId;
|
|
this.schedulePendingQueueRelease(session);
|
|
this.emitJobState(session, {
|
|
requestId: request.requestId,
|
|
status: 'completed',
|
|
mode: request.mode,
|
|
queueSize: session.queue.length,
|
|
message: '요청 처리 완료',
|
|
});
|
|
chatRuntimeService.finishJob(request.requestId, 'completed');
|
|
await this.sendOfflineNotificationBestEffort(session, completedReplyMessage);
|
|
} catch (error) {
|
|
const finalizeStep = planSteps.find((step) => step.kind === 'finalize');
|
|
|
|
if (finalizeStep) {
|
|
writePlanStep(finalizeStep, false);
|
|
}
|
|
|
|
const wasCancelled = this.cancelledRequestIds.has(request.requestId);
|
|
terminalStatus = wasCancelled ? 'cancelled' : 'failed';
|
|
const failureResponseText =
|
|
error instanceof ChatRuntimeExecutionError ? error.responseText : '';
|
|
const failureUsageSnapshot =
|
|
error instanceof ChatRuntimeExecutionError ? error.usageSnapshot : null;
|
|
const failureTotalTokens =
|
|
error instanceof ChatRuntimeExecutionError ? error.totalTokens : null;
|
|
|
|
if (failureResponseText) {
|
|
const extractedFailureReply = extractChatMessageParts(failureResponseText);
|
|
const failedCodexReplyBase = createMessage('codex', '', request.requestId);
|
|
const failedCodexReplyMessage = {
|
|
...failedCodexReplyBase,
|
|
text: extractedFailureReply.strippedText,
|
|
parts: extractedFailureReply.parts,
|
|
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,
|
|
requestOrigin: request.requestOrigin,
|
|
sharedResourceTokenId: request.sharedResourceTokenId,
|
|
parentRequestId: request.parentRequestId,
|
|
status: wasCancelled ? 'cancelled' : 'failed',
|
|
statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
|
|
responseMessageId: failedCodexReplyMessage.id,
|
|
responseText: failedCodexReplyMessage.text,
|
|
usageSnapshot: failureUsageSnapshot,
|
|
totalTokens: failureTotalTokens,
|
|
});
|
|
} else if (failureUsageSnapshot || failureTotalTokens != null) {
|
|
await upsertChatConversationRequest(session.sessionId, {
|
|
requestId: request.requestId,
|
|
requestOrigin: request.requestOrigin,
|
|
sharedResourceTokenId: request.sharedResourceTokenId,
|
|
parentRequestId: request.parentRequestId,
|
|
status: wasCancelled ? 'cancelled' : 'failed',
|
|
statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
|
|
usageSnapshot: failureUsageSnapshot,
|
|
totalTokens: failureTotalTokens,
|
|
});
|
|
}
|
|
|
|
chatRuntimeService.appendLog(
|
|
request.requestId,
|
|
wasCancelled
|
|
? '사용자 요청으로 실행이 중단되었습니다.'
|
|
: error instanceof Error
|
|
? `실행 오류: ${error.message}`
|
|
: '실행 오류가 발생했습니다.',
|
|
);
|
|
appendActivityLine(
|
|
wasCancelled
|
|
? '# 상태: 사용자 요청으로 실행이 중단되었습니다.'
|
|
: error instanceof Error
|
|
? `# 오류: ${error.message}`
|
|
: '# 오류: 실행 오류가 발생했습니다.',
|
|
);
|
|
const terminalErrorEnvelope = this.sendToSession(session, {
|
|
type: 'chat:message',
|
|
payload: createMessage(
|
|
'system',
|
|
wasCancelled ? '요청 실행이 중단되었습니다.' : '요청 처리 중 오류가 발생했습니다.',
|
|
request.requestId,
|
|
),
|
|
});
|
|
session.pendingQueueReleaseEventId = terminalErrorEnvelope.eventId;
|
|
this.schedulePendingQueueRelease(session);
|
|
this.emitJobState(session, {
|
|
requestId: request.requestId,
|
|
status: 'failed',
|
|
mode: request.mode,
|
|
queueSize: session.queue.length,
|
|
message: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
|
|
});
|
|
await refreshChatSessionReferenceForRequest({
|
|
sessionId: session.sessionId,
|
|
requestId: request.requestId,
|
|
context: request.context ?? session.context ?? null,
|
|
input: request.text,
|
|
requestStatus: wasCancelled ? 'cancelled' : 'failed',
|
|
requestedAt: new Date(request.requestedAtMs),
|
|
completedAt: new Date(),
|
|
});
|
|
throw error;
|
|
} finally {
|
|
stopProgressTimer();
|
|
activeChatProcessRegistry.delete(request.requestId);
|
|
if (chatRuntimeService.getJobDetail(request.requestId).terminalStatus == null) {
|
|
chatRuntimeService.finishJob(request.requestId, terminalStatus);
|
|
}
|
|
this.cancelledRequestIds.delete(request.requestId);
|
|
session.activeRequestCount = Math.max(0, session.activeRequestCount - 1);
|
|
this.tryStartNextQueuedRequest(session);
|
|
}
|
|
}
|
|
}
|