feat: refine codex live chat flow
This commit is contained in:
@@ -45,7 +45,7 @@ prepare_runtime() {
|
||||
|
||||
start_child() {
|
||||
log "starting server process"
|
||||
npm run start &
|
||||
node dist/server.js &
|
||||
CHILD_PID=$!
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user