feat: refine codex live chat context flows
This commit is contained in:
@@ -27,6 +27,7 @@ import { hasErrorLogViewAccessToken } from './error-log-service.js';
|
||||
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
|
||||
import { createNotificationMessage } from './notification-message-service.js';
|
||||
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
import {
|
||||
findLatestPlanItem,
|
||||
findPlanItemByPreviewUrl,
|
||||
@@ -329,6 +330,26 @@ function createChatQuestionAnswerNotificationBody(args: {
|
||||
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 ?? '';
|
||||
@@ -1584,9 +1605,6 @@ 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() || '없음';
|
||||
@@ -1594,14 +1612,6 @@ function buildChatSessionReferenceAutoSection(args: {
|
||||
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,
|
||||
@@ -1617,12 +1627,6 @@ function buildChatSessionReferenceAutoSection(args: {
|
||||
'',
|
||||
'## 현재 채팅 유형 context',
|
||||
chatTypeDescription,
|
||||
'',
|
||||
'## 최신 사용자 요청',
|
||||
args.input.trim() || '없음',
|
||||
'',
|
||||
'## 최근 대화 요약',
|
||||
...historyLines,
|
||||
CHAT_SESSION_REFERENCE_AUTO_END,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1635,30 +1639,19 @@ function mergeChatSessionReferenceContent(existingContent: string, autoSection:
|
||||
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
||||
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
|
||||
].join('\n');
|
||||
const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n');
|
||||
|
||||
if (!trimmedExisting) {
|
||||
return [
|
||||
defaultHeader,
|
||||
'',
|
||||
autoSection,
|
||||
'',
|
||||
defaultManualSection,
|
||||
'',
|
||||
].join('\n');
|
||||
return `${defaultHeader}\n\n${autoSection}\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`;
|
||||
return `${preservedHeader}\n\n${autoSection}\n`;
|
||||
}
|
||||
|
||||
const preservedHeader = existingContent.replace(/(^|\n)(## 수동 메모[\s\S]*)$/m, '').trim() || defaultHeader;
|
||||
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
|
||||
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
|
||||
}
|
||||
|
||||
export async function ensureChatSessionReferenceResource(args: {
|
||||
@@ -1677,9 +1670,6 @@ export async function ensureChatSessionReferenceResource(args: {
|
||||
context: args.context,
|
||||
sessionId: args.sessionId,
|
||||
requestId: args.requestId,
|
||||
input: args.input,
|
||||
recentHistoryLines: args.recentHistoryLines,
|
||||
omittedHistoryCount: args.omittedHistoryCount,
|
||||
});
|
||||
|
||||
let existingContent = '';
|
||||
@@ -1765,7 +1755,7 @@ export function buildAgenticCodexPrompt(
|
||||
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
|
||||
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
|
||||
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
|
||||
'- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.',
|
||||
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
|
||||
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
|
||||
...buildChatTypeInstructionBlock(context),
|
||||
'',
|
||||
@@ -1776,6 +1766,7 @@ export function buildAgenticCodexPrompt(
|
||||
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[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"}` 형태를 사용하세요. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
|
||||
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
|
||||
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
|
||||
'- 한국어로 간결하게 답하세요.',
|
||||
@@ -1947,7 +1938,7 @@ async function runAgenticCodexReply(
|
||||
onActivity?: (line: string) => void,
|
||||
isCancellationRequested?: () => boolean,
|
||||
) {
|
||||
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
|
||||
const repoPath = resolveMainProjectRoot();
|
||||
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
||||
const appConfig = await getAppConfigSnapshot();
|
||||
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
|
||||
@@ -2856,19 +2847,33 @@ export class ChatService {
|
||||
},
|
||||
) {
|
||||
if (session.isDeleted) {
|
||||
return this.createSessionEnvelope(session, message);
|
||||
const normalizedDeletedMessage =
|
||||
message.type === 'chat:message'
|
||||
? {
|
||||
...message,
|
||||
payload: normalizeStructuredChatMessage(message.payload),
|
||||
}
|
||||
: message;
|
||||
return this.createSessionEnvelope(session, normalizedDeletedMessage);
|
||||
}
|
||||
|
||||
const envelope = this.createSessionEnvelope(session, message);
|
||||
const normalizedMessage =
|
||||
message.type === 'chat:message'
|
||||
? {
|
||||
...message,
|
||||
payload: normalizeStructuredChatMessage(message.payload),
|
||||
}
|
||||
: message;
|
||||
const envelope = this.createSessionEnvelope(session, normalizedMessage);
|
||||
this.retainEnvelopeForReplay(session, envelope);
|
||||
|
||||
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope');
|
||||
|
||||
if (message.type === 'chat:message') {
|
||||
this.persistConversationMessage(session, message.payload);
|
||||
if (normalizedMessage.type === 'chat:message') {
|
||||
this.persistConversationMessage(session, normalizedMessage.payload);
|
||||
|
||||
if (message.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
|
||||
void this.sendOfflineNotificationIfNeeded(session, message.payload).catch((error: unknown) => {
|
||||
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
|
||||
void this.sendOfflineNotificationIfNeeded(session, normalizedMessage.payload).catch((error: unknown) => {
|
||||
this.logger.error(error, 'failed to send offline chat notification');
|
||||
});
|
||||
}
|
||||
@@ -2878,9 +2883,10 @@ export class ChatService {
|
||||
}
|
||||
|
||||
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
|
||||
const normalizedMessage = normalizeStructuredChatMessage(message);
|
||||
const envelope = this.createSessionEnvelope(session, {
|
||||
type: 'chat:message:update',
|
||||
payload: message,
|
||||
payload: normalizedMessage,
|
||||
});
|
||||
this.retainEnvelopeForReplay(session, envelope);
|
||||
|
||||
@@ -2889,8 +2895,8 @@ export class ChatService {
|
||||
// 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(message)) {
|
||||
this.persistConversationMessage(session, message);
|
||||
if (shouldPersistMessageUpdate(normalizedMessage)) {
|
||||
this.persistConversationMessage(session, normalizedMessage);
|
||||
}
|
||||
|
||||
return envelope;
|
||||
@@ -3465,6 +3471,26 @@ export class ChatService {
|
||||
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;
|
||||
session.watchedRuntimeRequestId = null;
|
||||
session.activeRequestCount = 0;
|
||||
}
|
||||
|
||||
private handleMessage(socket: WebSocket, raw: RawData) {
|
||||
try {
|
||||
const message = JSON.parse(raw.toString()) as ChatInboundMessage;
|
||||
|
||||
Reference in New Issue
Block a user