chore: sync local workspace changes
This commit is contained in:
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user