chore: sync local workspace changes

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

View File

@@ -1,4 +1,4 @@
import { cp, mkdir, stat, writeFile } from 'node:fs/promises';
import { chmod, cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import webpush from 'web-push';
import type { FastifyBaseLogger } from 'fastify';
@@ -14,6 +14,7 @@ import {
appendChatConversationActivityLine,
getChatConversationRequest,
getChatConversation,
listRecoverableChatConversationRequests,
listChatConversationMessages,
listChatConversationOfflineNotificationClientIds,
listChatConversationRequests,
@@ -87,6 +88,7 @@ type ChatInboundMessage =
text: string;
requestId?: string;
mode?: 'queue' | 'direct';
omitPromptHistory?: boolean;
chatTypeId?: string | null;
chatTypeLabel?: string;
chatTypeDescription?: string;
@@ -170,6 +172,7 @@ type ChatSessionState = {
text: string;
mode: 'queue' | 'direct';
requestedAtMs: number;
omitPromptHistory?: boolean;
context: ChatContext | null;
}>;
activeRequestCount: number;
@@ -208,6 +211,10 @@ 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;
@@ -352,6 +359,17 @@ export function collectOfflineNotificationClientIds(sessionClientId?: string | n
return [...nextClientIds];
}
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('응답을 준비하고 있습니다');
@@ -380,6 +398,11 @@ export function resolveResponseTimestamp(requestedAtMs?: number | null, 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,
@@ -1166,10 +1189,12 @@ function buildChatResourcePublicUrl(relativePath: string) {
}
function normalizeEmbeddedChatResourceUrls(text: string) {
return String(text ?? '').replace(
/(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g,
(_match, resourcePath) => resourcePath,
);
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) {
@@ -1286,6 +1311,7 @@ export async function stageChatResourceFile(repoPath: string, sessionId: string,
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 });
@@ -1302,6 +1328,7 @@ async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, o
}
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`;
@@ -1316,6 +1343,30 @@ async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, o
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;
@@ -1331,6 +1382,80 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
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?)$/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));
@@ -1366,11 +1491,11 @@ export async function rewriteCodexOutputWithChatResources(output: string, repoPa
}
}
rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput);
rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath);
rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
return appendDiffResourceLinks(rewrittenOutput, diffUrls);
return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath);
}
function normalizeChatPromptHistoryText(text: string) {
@@ -1399,8 +1524,16 @@ async function buildRecentChatPromptHistory(
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 });
@@ -1447,6 +1580,125 @@ function cloneChatContext(context: ChatContext | null): ChatContext | null {
return context ? { ...context } : null;
}
function buildChatSessionReferenceAutoSection(args: {
context: ChatContext | null;
sessionId: string;
requestId: string;
input: string;
recentHistoryLines: string[];
omittedHistoryCount: number;
}) {
const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청';
const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음';
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 historyLines =
args.recentHistoryLines.length > 0
? args.recentHistoryLines.map((line) => `- ${line}`)
: ['- 최근 대화 없음'];
if (args.omittedHistoryCount > 0) {
historyLines.push(`- 최근 문맥 일부만 포함했습니다. 이전 ${args.omittedHistoryCount}개 메시지는 제외되었습니다.`);
}
return [
CHAT_SESSION_REFERENCE_AUTO_START,
'## 자동 갱신 문맥',
`- 마지막 갱신 시각: ${formatTime(new Date())}`,
`- sessionId: ${args.sessionId}`,
`- requestId: ${args.requestId}`,
`- 채팅 유형: ${chatTypeLabel}`,
`- 화면 제목: ${pageTitle}`,
`- topMenu: ${topMenu}`,
`- focusedComponentId: ${focusedComponentId}`,
`- pageUrl: ${pageUrl}`,
'',
'## 현재 채팅 유형 context',
chatTypeDescription,
'',
'## 최신 사용자 요청',
args.input.trim() || '없음',
'',
'## 최근 대화 요약',
...historyLines,
CHAT_SESSION_REFERENCE_AUTO_END,
].join('\n');
}
function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) {
const trimmedExisting = existingContent.trim();
const defaultHeader = [
'# 채팅방 참고 리소스',
'',
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
].join('\n');
const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n');
if (!trimmedExisting) {
return [
defaultHeader,
'',
autoSection,
'',
defaultManualSection,
'',
].join('\n');
}
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
const manualSectionMatch = existingContent.match(/(^|\n)(## 수동 메모[\s\S]*)$/m);
const preservedManualSection = manualSectionMatch?.[2]?.trim() || defaultManualSection;
if (firstAutoStartIndex >= 0) {
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
}
const preservedHeader = existingContent.replace(/(^|\n)(## 수동 메모[\s\S]*)$/m, '').trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
}
export async function ensureChatSessionReferenceResource(args: {
repoPath: string;
sessionId: string;
requestId: string;
context: ChatContext | 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,
input: args.input,
recentHistoryLines: args.recentHistoryLines,
omittedHistoryCount: args.omittedHistoryCount,
});
let existingContent = '';
try {
existingContent = await readFile(absolutePath, 'utf8');
} catch {
existingContent = '';
}
const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection);
if (nextContent !== existingContent) {
await writeFile(absolutePath, nextContent, 'utf8');
}
return resourceRelativePath;
}
function buildChatTypeInstructionBlock(context: ChatContext | null) {
const chatTypeLabel = context?.chatTypeLabel?.trim() || '';
const chatTypeDescription = context?.chatTypeDescription?.trim() || '';
@@ -1484,11 +1736,14 @@ export function buildAgenticCodexPrompt(
promptContext?: {
recentHistoryLines?: string[];
omittedHistoryCount?: number;
sessionReferenceResourcePath?: string;
},
) {
const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
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);
@@ -1505,16 +1760,22 @@ export function buildAgenticCodexPrompt(
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.',
`- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`,
`- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`,
`- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`,
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
'- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.',
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
...buildChatTypeInstructionBlock(context),
'',
'응답 규칙:',
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 보여줘야 하면 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
@@ -1679,6 +1940,9 @@ async function runAgenticCodexReply(
input: string,
sessionId: string,
requestId: string,
options?: {
omitPromptHistory?: boolean;
},
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
@@ -1689,6 +1953,7 @@ async function runAgenticCodexReply(
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' &&
@@ -1700,9 +1965,19 @@ async function runAgenticCodexReply(
Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds)
? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds)))
: null;
const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({
repoPath,
sessionId,
requestId,
context,
input,
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
});
const prompt = buildAgenticCodexPrompt(context, input, sessionId, {
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
sessionReferenceResourcePath,
});
let streamedOutput = '';
let stdoutTail = '';
@@ -2017,29 +2292,74 @@ function buildAutomationRegistrationDefinitionReply() {
}
function buildProgressMessages(input: string) {
const messages = ['생각 중입니다. 요청 의도와 현재 화면 문맥을 먼저 정리하고 있습니다.'];
const messages = ['요청을 분석하고 있습니다.'];
if (isAutomationRegistrationCountRequest(input)) {
messages.push('오늘 기준 집계가 필요한 요청이라 DB 기준과 시간대 기준을 확인하고 있습니다.');
messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.');
return messages;
}
if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) {
messages.push('DB를 직접 확인할 수 있는지와 집계 기준을 함께 보고 있습니다.');
messages.push('DB와 집계 기준을 확인하고 있습니다.');
}
if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) {
messages.push('관련 API 경로와 실제 응답 기준을 확인하고 있습니다.');
messages.push('API 경로와 실제 응답을 확인하고 있습니다.');
}
if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) {
messages.push('관련 소스 파일과 연결 흐름을 고 있습니다.');
messages.push('관련 소스 연결 흐름을 확인하고 있습니다.');
}
messages.push('필요하면 실제 Codex 실행기로 이어서 조회하거나 수정하겠습니다.');
return [...new Set(messages)];
}
function normalizeProgressSummary(text: string) {
return text.replace(/\s+/g, ' ').trim();
}
function shouldSkipActivityProgressSummary(text: string) {
return (
!text ||
/^요청을 처리합니다\./.test(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 buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
const normalized = input.toLowerCase();
const pageTitle = context?.pageTitle ?? '현재 화면';
@@ -2249,6 +2569,9 @@ async function buildCodexReply(
input: string,
sessionId: string,
requestId: string,
options?: {
omitPromptHistory?: boolean;
},
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
@@ -2258,6 +2581,7 @@ async function buildCodexReply(
input,
sessionId,
requestId,
options,
onProgress,
onActivity,
isCancellationRequested,
@@ -2336,6 +2660,109 @@ export class ChatService {
this.wss.close();
}
async recoverInterruptedSessions() {
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 session = this.getOrCreateSession(sessionId, items[0]?.clientId ?? null);
const primaryItem =
items.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ??
items.find((item) => item.status === 'started') ??
items[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 items) {
if (item.requestId === primaryItem?.requestId) {
continue;
}
const requestedAtMs = parseRequestedAtMs(item.createdAt);
session.queue.push({
requestId: item.requestId,
text: item.userText,
mode: 'queue',
requestedAtMs,
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),
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);
@@ -2480,6 +2907,12 @@ export class ChatService {
}
}
getSessionClientIdMap() {
return new Map(
[...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]),
);
}
private pushRuntimeDetail(session: ChatSessionState) {
const requestId = session.watchedRuntimeRequestId?.trim();
@@ -2713,16 +3146,24 @@ export class ChatService {
return;
}
const conversation = await getChatConversation(session.sessionId, session.clientId);
const [conversation, appConfig] = await Promise.all([
getChatConversation(session.sessionId, session.clientId),
getAppConfigSnapshot(),
]);
if (!conversation?.notifyOffline) {
if (
!shouldSendOfflineChatNotification({
receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications,
conversationNotifyOffline: conversation?.notifyOffline,
})
) {
return;
}
const notificationPayload = await this.buildOfflineChatNotificationPayload(
session,
message,
conversation.title || '현재 채팅방',
conversation?.title || '현재 채팅방',
);
if (!notificationPayload) {
@@ -3069,6 +3510,9 @@ export class ChatService {
chatTypeLabel: message.payload.chatTypeLabel,
chatTypeDescription: message.payload.chatTypeDescription,
},
{
omitPromptHistory: message.payload.omitPromptHistory === true,
},
).catch((error: unknown) => {
this.logger.error(error, 'chat reply build failed');
const session = this.clientStates.get(socket);
@@ -3153,6 +3597,9 @@ export class ChatService {
requestId?: string,
mode: 'queue' | 'direct' = 'queue',
contextOverride?: Partial<ChatContext> | null,
requestOptions?: {
omitPromptHistory?: boolean;
},
) {
const trimmed = text.trim();
@@ -3198,6 +3645,7 @@ export class ChatService {
text: trimmed,
mode,
requestedAtMs,
omitPromptHistory: requestOptions?.omitPromptHistory === true,
context: cloneChatContext(state.context),
};
@@ -3250,6 +3698,7 @@ export class ChatService {
text: string;
mode: 'queue' | 'direct';
requestedAtMs: number;
omitPromptHistory?: boolean;
context: ChatContext | null;
},
) {
@@ -3329,42 +3778,60 @@ export class ChatService {
),
});
const progressMessages = buildProgressMessages(request.text);
let progressIndex = progressMessages.length > 1 ? 1 : 0;
let lastProgressMessage = progressMessages[0] ?? '';
let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)];
if (!nextMessage || nextMessage === lastProgressMessage) {
if (progressIndex >= progressMessages.length - 1) {
stopProgressTimer();
}
return;
}
lastProgressMessage = nextMessage;
chatRuntimeService.appendLog(request.requestId, nextMessage);
appendActivityLine(`# 진행: ${nextMessage}`);
this.sendToSession(session, {
type: 'chat:message',
payload: createMessage('system', nextMessage, request.requestId),
});
if (progressIndex < progressMessages.length - 1) {
progressIndex += 1;
} else {
stopProgressTimer();
}
}, 2200);
const codexReplyMessage = createMessage('codex', '', request.requestId);
const stopProgressTimer = () => {
if (progressTimer !== null) {
clearInterval(progressTimer);
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 codexReplyMessage = createMessage('codex', '', request.requestId);
try {
chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.');
@@ -3373,6 +3840,7 @@ export class ChatService {
type: 'chat:message',
payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId),
});
scheduleFallbackProgressMessage();
this.updateMessageInSession(session, {
...codexReplyMessage,
text: '응답을 준비하고 있습니다...',
@@ -3384,6 +3852,9 @@ export class ChatService {
request.text,
session.sessionId,
request.requestId,
{
omitPromptHistory: request.omitPromptHistory === true,
},
(partialReply) => {
stopProgressTimer();
if (!hasAnnouncedStreaming) {
@@ -3399,6 +3870,13 @@ export class ChatService {
},
(activityLine) => {
appendActivityLine(activityLine);
const activitySummary = summarizeActivityProgressLine(activityLine);
if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) {
lastMeaningfulProgressSummary = activitySummary;
stopProgressTimer();
emitSystemProgressMessage(activitySummary, { appendActivity: false });
}
},
() => this.cancelledRequestIds.has(request.requestId),
);