feat: refine codex live chat flow

This commit is contained in:
2026-04-24 21:02:01 +09:00
parent d53532508b
commit 63e5d263a7
18 changed files with 1747 additions and 297 deletions

View File

@@ -45,7 +45,7 @@ prepare_runtime() {
start_child() {
log "starting server process"
npm run start &
node dist/server.js &
CHILD_PID=$!
}

View File

@@ -1,6 +1,12 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js';
import {
getAppConfig,
getChatTypesConfig,
normalizeAppConfigSnapshot,
upsertAppConfig,
upsertChatTypesConfig,
} from '../services/app-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) {
@@ -9,7 +15,7 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
return {
ok: true,
config: config ?? {},
config: normalizeAppConfigSnapshot(config),
};
});
@@ -115,7 +121,7 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
return {
ok: true,
config: savedConfig,
config: normalizeAppConfigSnapshot(savedConfig),
};
} catch (error) {
return reply.code(409).send({

View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mergeDefaultChatTypes } from './app-config-service.js';
test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => {
const merged = mergeDefaultChatTypes([
{
id: 'general-request',
name: '일반 요청',
description: '사용자가 수정한 일반 요청 문맥',
permissions: ['guest', 'token-user'],
enabled: true,
updatedAt: '2026-04-24T09:00:00.000Z',
},
]);
const generalRequest = merged.find((item) => item.id === 'general-request');
assert.ok(generalRequest);
assert.equal(generalRequest.description, '사용자가 수정한 일반 요청 문맥');
assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']);
});
test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
const merged = mergeDefaultChatTypes([]);
assert.ok(merged.some((item) => item.id === 'general-request'));
assert.ok(merged.some((item) => item.id === 'api-request-template'));
assert.ok(merged.some((item) => item.id === 'general-inquiry'));
});

View File

@@ -2,6 +2,11 @@ import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
const DEFAULT_CHAT_APP_CONFIG = {
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
} as const;
type ChatPermissionRole = 'guest' | 'token-user';
@@ -19,7 +24,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -98,6 +103,14 @@ function normalizeConfigRecord(value: unknown) {
return value as Record<string, unknown>;
}
function normalizeIntegerInRange(value: unknown, fallback: number, min: number, max: number) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -183,24 +196,14 @@ function sanitizeChatTypes(items: unknown[]) {
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
}
function mergeDefaultChatTypes(items: unknown[]) {
export function mergeDefaultChatTypes(items: unknown[]) {
const savedItems = sanitizeChatTypes(items);
const byId = new Map(savedItems.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_CHAT_TYPES) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
if (!byId.has(defaultItem.id)) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: defaultItem.description,
permissions: defaultItem.permissions,
});
}
return sanitizeChatTypes(Array.from(byId.values()));
@@ -230,6 +233,7 @@ export type AppConfigSnapshot = {
chat?: {
maxContextMessages?: number;
maxContextChars?: number;
codexLiveMaxExecutionSeconds?: number;
};
automation?: {
autoRefreshEnabled?: boolean;
@@ -260,26 +264,45 @@ export type AppConfigSnapshot = {
};
};
export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
const normalized = normalizeConfigRecord(value);
const chat = normalizeConfigRecord(normalized.chat);
const worklogAutomation = normalizeConfigRecord(normalized.worklogAutomation);
return {
...(normalized as AppConfigSnapshot),
chat: {
maxContextMessages: normalizeIntegerInRange(
chat.maxContextMessages,
DEFAULT_CHAT_APP_CONFIG.maxContextMessages,
1,
50,
),
maxContextChars: normalizeIntegerInRange(
chat.maxContextChars,
DEFAULT_CHAT_APP_CONFIG.maxContextChars,
500,
20_000,
),
codexLiveMaxExecutionSeconds: normalizeIntegerInRange(
chat.codexLiveMaxExecutionSeconds,
DEFAULT_CHAT_APP_CONFIG.codexLiveMaxExecutionSeconds,
60,
7200,
),
},
worklogAutomation:
Object.keys(worklogAutomation).length > 0
? {
...(normalized.worklogAutomation as AppConfigSnapshot['worklogAutomation']),
repeatRequestEnabled: false,
}
: undefined,
};
}
export async function getAppConfigSnapshot(): Promise<AppConfigSnapshot> {
const raw = await getAppConfig();
if (!raw || typeof raw !== 'object') {
return {};
}
const snapshot = raw as AppConfigSnapshot;
if (snapshot.worklogAutomation) {
return {
...snapshot,
worklogAutomation: {
...snapshot.worklogAutomation,
repeatRequestEnabled: false,
},
};
}
return snapshot;
return normalizeAppConfigSnapshot(await getAppConfig());
}
export async function upsertAppConfig(config: Record<string, unknown>) {

View File

@@ -4,6 +4,7 @@ import {
buildChatConversationRequestPatchFromMessage,
isVisibleConversationMessage,
mergeChatConversationRequestStatus,
resolveNextConversationChatTypeId,
shouldClearConversationJobState,
selectChatConversationResponseCandidate,
} from './chat-room-service.js';
@@ -15,6 +16,17 @@ test('mergeChatConversationRequestStatus keeps terminal states from being downgr
assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed');
});
test('resolveNextConversationChatTypeId prefers the requested chat type over the stored one', () => {
assert.equal(resolveNextConversationChatTypeId('general-request', 'api-request-template'), 'api-request-template');
assert.equal(resolveNextConversationChatTypeId('general-request', ' api-request-template '), 'api-request-template');
});
test('resolveNextConversationChatTypeId falls back to the stored chat type when the request is empty', () => {
assert.equal(resolveNextConversationChatTypeId('general-request', null), 'general-request');
assert.equal(resolveNextConversationChatTypeId('general-request', ' '), 'general-request');
assert.equal(resolveNextConversationChatTypeId(null, null), null);
});
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
assert.equal(
buildChatConversationRequestPatchFromMessage({

View File

@@ -1072,7 +1072,7 @@ export async function updateChatConversationContext(
const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null;
const requestedChatTypeId = payload.chatTypeId?.trim() || null;
const nextChatTypeId = currentChatTypeId || requestedChatTypeId || null;
const nextChatTypeId = resolveNextConversationChatTypeId(currentChatTypeId, requestedChatTypeId);
const requestedContextLabel = payload.contextLabel?.trim() || null;
const requestedContextDescription = payload.contextDescription?.trim() || null;
@@ -1103,6 +1103,12 @@ export async function updateChatConversationContext(
return getChatConversation(sessionId, normalizedClientId);
}
export function resolveNextConversationChatTypeId(currentChatTypeId?: string | null, requestedChatTypeId?: string | null) {
const normalizedCurrentChatTypeId = String(currentChatTypeId ?? '').trim() || null;
const normalizedRequestedChatTypeId = String(requestedChatTypeId ?? '').trim() || null;
return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null;
}
export async function listChatConversations(
clientId?: string | null,
limit = 50,

View File

@@ -1469,7 +1469,9 @@ function buildAgenticCodexPrompt(
'- 한국어로 간결하게 답하세요.',
'',
'채팅 유형 문맥(우선 적용):',
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
context?.chatTypeLabel && context.chatTypeLabel.trim() !== '일반 요청'
? `- chatTypeLabel: ${context.chatTypeLabel}`
: '- chatTypeLabel: 없음',
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
@@ -1645,6 +1647,11 @@ async function runAgenticCodexReply(
maxMessages: appConfig.chat?.maxContextMessages,
maxChars: appConfig.chat?.maxContextChars,
});
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 prompt = buildAgenticCodexPrompt(context, input, sessionId, {
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
@@ -1697,6 +1704,7 @@ async function runAgenticCodexReply(
prompt,
resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'),
maxExecutionSeconds: codexLiveMaxExecutionSeconds,
}),
});