feat: refine codex live chat context flows
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getAppConfig,
|
||||
getChatContextSettingsConfig,
|
||||
getAppConfigSnapshot,
|
||||
getChatTypesConfig,
|
||||
normalizeAppConfigSnapshot,
|
||||
upsertAppConfig,
|
||||
@@ -52,20 +52,20 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async (request) => {
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const config = await getAppConfig(appOrigin);
|
||||
const config = await getAppConfigSnapshot(appOrigin);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: normalizeAppConfigSnapshot(config),
|
||||
config,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat-types', async (request) => {
|
||||
const chatTypes = await getChatTypesConfig(getRequestAppOrigin(request));
|
||||
const chatTypeConfig = await getChatTypesConfig(getRequestAppOrigin(request));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chatTypes,
|
||||
...chatTypeConfig,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -108,17 +108,21 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
chatTypes: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
const parsed = z
|
||||
.object({
|
||||
chatTypes: z.array(z.unknown()).optional(),
|
||||
customChatTypes: z.array(z.unknown()).optional(),
|
||||
})
|
||||
.parse(payload ?? {});
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const appDomain = getRequestAppDomain(request);
|
||||
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes, appOrigin, appDomain);
|
||||
const targetChatTypes = parsed.customChatTypes ?? parsed.chatTypes ?? [];
|
||||
const savedChatTypeConfig = await upsertChatTypesConfig(targetChatTypes, appOrigin, appDomain);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chatTypes: savedChatTypes,
|
||||
...savedChatTypeConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
|
||||
13
etc/servers/work-server/src/routes/chat.test.ts
Normal file
13
etc/servers/work-server/src/routes/chat.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './chat.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
|
||||
});
|
||||
|
||||
test('resolveStaticContentType keeps plain text content type for code resources', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRunt
|
||||
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||
import {
|
||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||
clearChatConversationData,
|
||||
createChatConversation,
|
||||
deleteUnansweredChatConversationRequest,
|
||||
deleteChatConversation,
|
||||
@@ -22,13 +23,14 @@ import {
|
||||
updateChatConversationContext,
|
||||
} from '../services/chat-room-service.js';
|
||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
|
||||
|
||||
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
||||
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
||||
|
||||
function resolveStaticContentType(filePath: string) {
|
||||
export function resolveStaticContentType(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
@@ -40,10 +42,12 @@ function resolveStaticContentType(filePath: string) {
|
||||
case '.cjs':
|
||||
case '.json':
|
||||
case '.css':
|
||||
case '.html':
|
||||
case '.txt':
|
||||
case '.diff':
|
||||
return 'text/plain; charset=utf-8';
|
||||
case '.html':
|
||||
case '.htm':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'text/markdown; charset=utf-8';
|
||||
@@ -139,7 +143,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
|
||||
}
|
||||
|
||||
function resolveChatAttachmentRepoPath() {
|
||||
return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH);
|
||||
return resolveMainProjectRoot();
|
||||
}
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
||||
@@ -421,7 +425,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const messageLimit = query.limit ?? 6;
|
||||
const messageLimit = query.limit ?? 8;
|
||||
const detailPage = await listChatConversationDetailPage(params.sessionId, {
|
||||
limit: messageLimit,
|
||||
beforeMessageId: query.beforeMessageId ?? null,
|
||||
@@ -562,4 +566,34 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/conversations/:sessionId/clear', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const current = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!current) {
|
||||
return reply.code(404).send({
|
||||
message: '초기화할 채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
getActiveChatService()?.resetSessionData(params.sessionId);
|
||||
chatRuntimeService.clearSession(params.sessionId);
|
||||
const item = await clearChatConversationData(params.sessionId, clientId || null);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅방 데이터 초기화 후 다시 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
cancelServerRestartReservation,
|
||||
confirmServerRestartReservation,
|
||||
getRestartReservationWorkloadSummary,
|
||||
requestImmediateRestartRecovery,
|
||||
getServerRestartReservation,
|
||||
scheduleServerRestartReservation,
|
||||
} from '../services/server-restart-reservation-service.js';
|
||||
@@ -90,14 +91,40 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await restartServerCommand(key);
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
|
||||
if (key !== 'test' && key !== 'work-server') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!/(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await requestImmediateRestartRecovery(app.log, key, message);
|
||||
const server = (await listServerCommands()).find((item) => item.key === key);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${key} 서버 상태를 다시 읽지 못했습니다.`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: server,
|
||||
commandOutput: `${message}\n\n빌드 실패를 감지해 Codex 자동 개선과 재기동 재시도를 시작했습니다.`,
|
||||
restartState: 'accepted' as const,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mergeDefaultChatTypes, resolveAppConfigByOrigin } from './app-config-service.js';
|
||||
import {
|
||||
mergeDefaultChatTypes,
|
||||
migrateLegacyChatTypeContexts,
|
||||
stripBuiltInChatTypes,
|
||||
resolveAppConfigByOrigin,
|
||||
resolveCanonicalChatTypesFromConfig,
|
||||
resolveCanonicalChatContextSettingsFromConfig,
|
||||
stripChatContextSettingsFromScopedAppConfigs,
|
||||
} from './app-config-service.js';
|
||||
|
||||
test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => {
|
||||
const merged = mergeDefaultChatTypes([
|
||||
@@ -64,9 +72,74 @@ test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
|
||||
assert.ok(merged.some((item) => item.id === 'layout-editor-execution'));
|
||||
assert.ok(merged.some((item) => item.id === 'api-request-template'));
|
||||
assert.ok(merged.some((item) => item.id === 'general-inquiry'));
|
||||
assert.ok(!merged.some((item) => item.id === 'plan-checklist-execution'));
|
||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
|
||||
});
|
||||
|
||||
test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () => {
|
||||
const stripped = stripBuiltInChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: 'builtin',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
description: 'custom-seeded',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'custom-support-flow',
|
||||
name: '운영 문의 전용',
|
||||
description: 'custom',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'plan-checklist-execution']);
|
||||
});
|
||||
|
||||
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
|
||||
const migrated = migrateLegacyChatTypeContexts(
|
||||
{
|
||||
defaultContexts: [],
|
||||
chatTypeDefaults: [
|
||||
{
|
||||
chatTypeId: 'plan-checklist-execution',
|
||||
defaultContextIds: ['legacy-linked-context'],
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
roomContexts: [],
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
description: 'legacy plan context',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(migrated.defaultContexts.some((item) => item.id === 'chat-default-plan-checklist-execution'), true);
|
||||
assert.equal(
|
||||
migrated.defaultContexts.find((item) => item.id === 'chat-default-plan-checklist-execution')?.content,
|
||||
'legacy plan context',
|
||||
);
|
||||
assert.equal(migrated.chatTypeDefaults.some((item) => item.chatTypeId === 'plan-checklist-execution'), false);
|
||||
});
|
||||
|
||||
test('resolveAppConfigByOrigin prefers scoped app config over legacy global config', () => {
|
||||
const resolved = resolveAppConfigByOrigin(
|
||||
{
|
||||
@@ -112,3 +185,149 @@ test('resolveAppConfigByOrigin falls back to legacy global config when scoped co
|
||||
|
||||
assert.equal(resolved.chat?.receiveRoomNotifications, true);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context settings over stale scoped entries', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||
{
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'global-a',
|
||||
title: '전역 A',
|
||||
content: 'global',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'global-b',
|
||||
title: '전역 B',
|
||||
content: 'global',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'scoped-a',
|
||||
title: '스코프 A',
|
||||
content: 'scoped',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => item.id),
|
||||
['global-a', 'global-b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped settings only when global settings are empty', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||
{
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'scoped-a',
|
||||
title: '스코프 A',
|
||||
content: 'scoped',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => item.id),
|
||||
['scoped-a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatTypesFromConfig merges global chat types with stale scoped entries', () => {
|
||||
const resolved = resolveCanonicalChatTypesFromConfig(
|
||||
{
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'verification-test-generation',
|
||||
name: '검증 밑 테스트 생성',
|
||||
description: 'global',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T08:15:18.440Z',
|
||||
},
|
||||
],
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: 'scoped',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.some((item) => item.id === 'verification-test-generation'), true);
|
||||
assert.equal(resolved.some((item) => item.id === 'general-request'), true);
|
||||
});
|
||||
|
||||
test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat context settings only', () => {
|
||||
const stripped = stripChatContextSettingsFromScopedAppConfigs({
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [{ id: 'legacy', title: 'legacy', content: 'legacy', enabled: true }],
|
||||
},
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(stripped.changed, true);
|
||||
assert.deepEqual(stripped.scopedConfigs, {
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { db } from '../db/client.js';
|
||||
import { DEFAULT_CHAT_TYPES } from './chat-type-defaults.js';
|
||||
import {
|
||||
DEFAULT_CHAT_TYPES,
|
||||
PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT,
|
||||
PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
|
||||
PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
|
||||
} from './chat-type-defaults.js';
|
||||
|
||||
export const APP_CONFIG_TABLE = 'app_configs';
|
||||
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
||||
@@ -25,6 +30,14 @@ type ChatTypeRecord = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID = 'plan-checklist-execution';
|
||||
|
||||
export type ChatTypesConfigSnapshot = {
|
||||
builtInChatTypes: ChatTypeRecord[];
|
||||
customChatTypes: ChatTypeRecord[];
|
||||
chatTypes: ChatTypeRecord[];
|
||||
};
|
||||
|
||||
type ChatDefaultContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -53,25 +66,6 @@ export type ChatContextSettingsSnapshot = {
|
||||
roomContexts: ChatRoomContextSettings[];
|
||||
};
|
||||
|
||||
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
|
||||
{
|
||||
id: 'chat-default-mobile-verification',
|
||||
title: '모바일 검증',
|
||||
content:
|
||||
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'chat-default-resource-output',
|
||||
title: '리소스 출력',
|
||||
content:
|
||||
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureAppConfigTable() {
|
||||
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
||||
|
||||
@@ -154,6 +148,82 @@ function getScopedAppConfigsRecord(value: unknown) {
|
||||
return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]);
|
||||
}
|
||||
|
||||
function getScopedAppConfigEntryRecord(value: unknown) {
|
||||
return normalizeConfigRecord(value);
|
||||
}
|
||||
|
||||
function hasChatContextSettingsSnapshot(value: ChatContextSettingsSnapshot) {
|
||||
return (
|
||||
value.defaultContexts.length > 0 ||
|
||||
value.chatTypeDefaults.length > 0 ||
|
||||
value.roomContexts.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function stripChatContextSettingsFromScopedAppConfigs(value: unknown) {
|
||||
const scopedConfigs = getScopedAppConfigsRecord(value);
|
||||
let changed = false;
|
||||
|
||||
const sanitizedScopedConfigs = Object.fromEntries(
|
||||
Object.entries(scopedConfigs).map(([origin, entry]) => {
|
||||
const normalizedEntry = getScopedAppConfigEntryRecord(entry);
|
||||
const normalizedConfig = normalizeConfigRecord(normalizedEntry.config);
|
||||
|
||||
if (!(CHAT_CONTEXT_SETTINGS_CONFIG_KEY in normalizedConfig)) {
|
||||
return [origin, normalizedEntry];
|
||||
}
|
||||
|
||||
const { [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: _removed, ...nextConfig } = normalizedConfig;
|
||||
changed = true;
|
||||
|
||||
return [
|
||||
origin,
|
||||
{
|
||||
...normalizedEntry,
|
||||
config: nextConfig,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
changed,
|
||||
scopedConfigs: sanitizedScopedConfigs,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCanonicalChatContextSettingsFromConfig(value: unknown, appOrigin?: string | null) {
|
||||
const normalized = normalizeConfigRecord(value);
|
||||
const globalSettings = sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
|
||||
if (hasChatContextSettingsSnapshot(globalSettings)) {
|
||||
return globalSettings;
|
||||
}
|
||||
|
||||
const scopedSettings = sanitizeChatContextSettings(
|
||||
normalizeConfigRecord(resolveAppConfigByOrigin(normalized, appOrigin))[CHAT_CONTEXT_SETTINGS_CONFIG_KEY],
|
||||
);
|
||||
|
||||
return hasChatContextSettingsSnapshot(scopedSettings) ? scopedSettings : globalSettings;
|
||||
}
|
||||
|
||||
export function resolveCanonicalChatTypesFromConfig(value: unknown, appOrigin?: string | null) {
|
||||
const normalized = normalizeConfigRecord(value);
|
||||
const globalChatTypes = Array.isArray(normalized[CHAT_TYPES_CONFIG_KEY])
|
||||
? sanitizeChatTypes(normalized[CHAT_TYPES_CONFIG_KEY])
|
||||
: [];
|
||||
const scopedConfig = resolveScopedAppConfig(normalized, appOrigin);
|
||||
const scopedChatTypes = Array.isArray(normalizeConfigRecord(scopedConfig)[CHAT_TYPES_CONFIG_KEY])
|
||||
? sanitizeChatTypes(normalizeConfigRecord(scopedConfig)[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||
: [];
|
||||
|
||||
if (globalChatTypes.length === 0 && scopedChatTypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mergeDefaultChatTypes([...globalChatTypes, ...scopedChatTypes]);
|
||||
}
|
||||
|
||||
function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) {
|
||||
const normalizedAppOrigin = normalizeAppOrigin(appOrigin);
|
||||
|
||||
@@ -229,6 +299,26 @@ export async function getAppConfig(appOrigin?: string | null) {
|
||||
return resolveAppConfigByOrigin(row.config_json ?? {}, appOrigin);
|
||||
}
|
||||
|
||||
async function getRawAppConfigRecord() {
|
||||
await ensureAppConfigTable();
|
||||
|
||||
const row = await db(APP_CONFIG_TABLE).first();
|
||||
|
||||
if (!row) {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (typeof row.config_json === 'string') {
|
||||
try {
|
||||
return normalizeConfigRecord(JSON.parse(row.config_json));
|
||||
} catch {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeConfigRecord(row.config_json);
|
||||
}
|
||||
|
||||
function normalizeConfigRecord(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {} as Record<string, unknown>;
|
||||
@@ -299,7 +389,7 @@ function sanitizeDefaultContexts(items: unknown) {
|
||||
const byId = new Map<string, ChatDefaultContextRecord>();
|
||||
const sourceItems = Array.isArray(items) ? items : [];
|
||||
|
||||
[...sourceItems, ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
|
||||
sourceItems
|
||||
.map((item) => normalizeDefaultContextRecord(item))
|
||||
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
@@ -420,6 +510,14 @@ function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
|
||||
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function isBuiltInChatTypeId(chatTypeId: string) {
|
||||
return DEFAULT_CHAT_TYPES.some((item) => item.id === chatTypeId);
|
||||
}
|
||||
|
||||
function isLegacyMigratedChatTypeId(chatTypeId: string) {
|
||||
return chatTypeId === LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID;
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
@@ -473,6 +571,56 @@ export function mergeDefaultChatTypes(items: unknown[]) {
|
||||
return sanitizeChatTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
export function stripBuiltInChatTypes(items: unknown[]) {
|
||||
return sanitizeChatTypes(items).filter((item) => !isBuiltInChatTypeId(item.id));
|
||||
}
|
||||
|
||||
function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) {
|
||||
return items.filter((item) => !isLegacyMigratedChatTypeId(item.id));
|
||||
}
|
||||
|
||||
function buildPlanChecklistDefaultContext(record?: ChatTypeRecord | null, existing?: ChatDefaultContextRecord | null) {
|
||||
const content = normalizeText(record?.description) || normalizeText(existing?.content) || PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT;
|
||||
|
||||
return normalizeDefaultContextRecord({
|
||||
id: PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
|
||||
title: normalizeText(record?.name) || normalizeText(existing?.title) || PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
|
||||
content,
|
||||
enabled: existing?.enabled ?? record?.enabled ?? true,
|
||||
updatedAt: normalizeText(record?.updatedAt) || normalizeText(existing?.updatedAt) || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function migrateLegacyChatTypeContexts(
|
||||
settings: ChatContextSettingsSnapshot,
|
||||
chatTypes: ChatTypeRecord[],
|
||||
): ChatContextSettingsSnapshot {
|
||||
const legacyPlanChecklistChatType = chatTypes.find((item) => item.id === LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID);
|
||||
|
||||
if (!legacyPlanChecklistChatType) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const existingContext =
|
||||
settings.defaultContexts.find((item) => item.id === PLAN_CHECKLIST_DEFAULT_CONTEXT_ID) ?? null;
|
||||
const migratedContext = buildPlanChecklistDefaultContext(legacyPlanChecklistChatType, existingContext);
|
||||
const nextDefaultContexts = migratedContext
|
||||
? sanitizeDefaultContexts([
|
||||
...settings.defaultContexts.filter((item) => item.id !== PLAN_CHECKLIST_DEFAULT_CONTEXT_ID),
|
||||
migratedContext,
|
||||
])
|
||||
: settings.defaultContexts;
|
||||
const nextChatTypeDefaults = sanitizeChatTypeDefaultSelections(
|
||||
settings.chatTypeDefaults.filter((item) => item.chatTypeId !== LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID),
|
||||
);
|
||||
|
||||
return {
|
||||
defaultContexts: nextDefaultContexts,
|
||||
chatTypeDefaults: nextChatTypeDefaults,
|
||||
roomContexts: settings.roomContexts,
|
||||
};
|
||||
}
|
||||
|
||||
function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
@@ -585,7 +733,18 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
|
||||
}
|
||||
|
||||
export async function getAppConfigSnapshot(appOrigin?: string | null): Promise<AppConfigSnapshot> {
|
||||
return normalizeAppConfigSnapshot(await getAppConfig(appOrigin));
|
||||
const config = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
|
||||
const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin);
|
||||
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(rawConfig);
|
||||
|
||||
return normalizeAppConfigSnapshot({
|
||||
...config,
|
||||
...(canonicalChatTypes ? { [CHAT_TYPES_CONFIG_KEY]: canonicalChatTypes } : null),
|
||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: canonicalChatContextSettings,
|
||||
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertAppConfig(
|
||||
@@ -626,42 +785,83 @@ export async function upsertAppConfig(
|
||||
return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin);
|
||||
}
|
||||
|
||||
export async function getChatTypesConfig(appOrigin?: string | null) {
|
||||
const config = await getAppConfig(appOrigin);
|
||||
const normalized = normalizeConfigRecord(config);
|
||||
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
|
||||
if (chatTypes == null) {
|
||||
return null;
|
||||
}
|
||||
export async function getChatTypesConfig(appOrigin?: string | null): Promise<ChatTypesConfigSnapshot> {
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
|
||||
const builtInChatTypes = sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
|
||||
const customChatTypes = stripBuiltInChatTypes(migratedChatTypeList);
|
||||
const mergedChatTypes = mergeDefaultChatTypes(customChatTypes);
|
||||
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
|
||||
canonicalChatTypes ?? [],
|
||||
);
|
||||
|
||||
const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
|
||||
const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
|
||||
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||
: [];
|
||||
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
|
||||
if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) {
|
||||
if (!isSameChatTypeList(resolvedCustomChatTypes, customChatTypes)) {
|
||||
await upsertAppConfig({
|
||||
[CHAT_TYPES_CONFIG_KEY]: mergedChatTypes,
|
||||
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
|
||||
}, appOrigin);
|
||||
}
|
||||
|
||||
return mergedChatTypes;
|
||||
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
|
||||
await upsertChatContextSettingsConfig(migratedSettings);
|
||||
}
|
||||
|
||||
return {
|
||||
builtInChatTypes,
|
||||
customChatTypes,
|
||||
chatTypes: mergedChatTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
|
||||
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
|
||||
const customChatTypes = stripBuiltInChatTypes(chatTypes);
|
||||
const nextConfig = {
|
||||
...current,
|
||||
[CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
|
||||
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
|
||||
};
|
||||
|
||||
await upsertAppConfig(nextConfig, appOrigin, appDomain);
|
||||
return resolvedChatTypes;
|
||||
return {
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes,
|
||||
chatTypes: mergeDefaultChatTypes(customChatTypes),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChatContextSettingsConfig(appOrigin?: string | null) {
|
||||
const config = await getAppConfig(appOrigin);
|
||||
const normalized = normalizeConfigRecord(config);
|
||||
return sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin) ?? [];
|
||||
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
|
||||
canonicalChatTypes,
|
||||
);
|
||||
const migratedChatTypes = stripLegacyMigratedChatTypes(canonicalChatTypes);
|
||||
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||
: [];
|
||||
const nextCustomChatTypes = stripBuiltInChatTypes(migratedChatTypes);
|
||||
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
|
||||
if (!isSameChatTypeList(resolvedCustomChatTypes, nextCustomChatTypes)) {
|
||||
await upsertAppConfig({
|
||||
[CHAT_TYPES_CONFIG_KEY]: nextCustomChatTypes,
|
||||
}, appOrigin);
|
||||
}
|
||||
|
||||
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
|
||||
await upsertChatContextSettingsConfig(migratedSettings);
|
||||
}
|
||||
|
||||
return migratedSettings;
|
||||
}
|
||||
|
||||
export async function upsertChatContextSettingsConfig(
|
||||
@@ -669,13 +869,17 @@ export async function upsertChatContextSettingsConfig(
|
||||
appOrigin?: string | null,
|
||||
appDomain?: string | null,
|
||||
) {
|
||||
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const current = await getRawAppConfigRecord();
|
||||
const nextSettings = sanitizeChatContextSettings(settings);
|
||||
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(current);
|
||||
const nextConfig = {
|
||||
...current,
|
||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: nextSettings,
|
||||
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||
};
|
||||
|
||||
await upsertAppConfig(nextConfig, appOrigin, appDomain);
|
||||
void appOrigin;
|
||||
void appDomain;
|
||||
await upsertAppConfig(nextConfig);
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,79 @@ export type ChatMessagePart =
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'prompt';
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
currentStepKey?: string | null;
|
||||
steps?: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
optional?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
selectedValues?: string[];
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
}>;
|
||||
readOnly?: boolean;
|
||||
selectedValues?: string[];
|
||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||
resolvedAt?: string | null;
|
||||
resultText?: string | null;
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||
type PromptOption = PromptPart['options'][number];
|
||||
type PromptPreview = NonNullable<PromptOption['preview']>;
|
||||
type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
@@ -27,6 +94,25 @@ function normalizeUrl(value: string) {
|
||||
return `/${malformedResourceMatch[1]}`;
|
||||
}
|
||||
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
if (apiMarkerIndex >= 0) {
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -34,6 +120,114 @@ function normalizeUrl(value: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizePromptPreview(value: unknown): PromptPreview | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const type: 'image' | 'markdown' | 'html' | 'resource' | null =
|
||||
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
||||
? record.type
|
||||
: null;
|
||||
const url = normalizeUrl(normalizeText(record.url));
|
||||
const content = String(record.content ?? '').trim() || null;
|
||||
const alt = normalizeText(record.alt) || null;
|
||||
const title = normalizeText(record.title) || null;
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'image' || type === 'resource') {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
} else if (!content && !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
url: url || null,
|
||||
content,
|
||||
alt,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptOption(value: unknown): PromptOption | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const optionValue = normalizeText(record.value);
|
||||
const label = normalizeText(record.label);
|
||||
|
||||
if (!optionValue || !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: optionValue,
|
||||
label,
|
||||
description: normalizeText(record.description) || null,
|
||||
preview: normalizePromptPreview(record.preview),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptSelectedValues(value: unknown) {
|
||||
return [
|
||||
...(Array.isArray(value) ? value : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((item, index, array) => array.indexOf(item) === index);
|
||||
}
|
||||
|
||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const key = normalizeText(record.key) || `step-${index + 1}`;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((option) => normalizePromptOption(option))
|
||||
.filter((option): option is PromptOption => Boolean(option))
|
||||
: [];
|
||||
|
||||
if (!title || options.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key,
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
|
||||
multiple: record.multiple === true,
|
||||
optional: record.optional === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
selectedValues: normalizePromptSelectedValues(record.selectedValues),
|
||||
options,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function decodeUrlComponentSafely(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
@@ -141,6 +335,66 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((item) => normalizePromptOption(item))
|
||||
.filter((option): option is PromptOption => Boolean(option))
|
||||
: [];
|
||||
const steps = normalizePromptSteps(record.steps);
|
||||
|
||||
if (!title || (options.length === 0 && steps.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
|
||||
const selectedValues = [
|
||||
...normalizePromptSelectedValues(record.selectedValues),
|
||||
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
const resolvedBy =
|
||||
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
|
||||
? record.resolvedBy
|
||||
: null;
|
||||
|
||||
return {
|
||||
type: 'prompt',
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode,
|
||||
multiple: record.multiple === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
resultText: normalizeText(record.resultText) || null,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatMessageParts(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
@@ -151,7 +405,38 @@ export function extractChatMessageParts(text: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
||||
const dedupeKey =
|
||||
nextPart.type === 'link_card'
|
||||
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
|
||||
: [
|
||||
nextPart.type,
|
||||
nextPart.title,
|
||||
nextPart.options
|
||||
.map((option) =>
|
||||
[
|
||||
option.value,
|
||||
option.label,
|
||||
option.preview?.type ?? '',
|
||||
option.preview?.url ?? '',
|
||||
option.preview?.content ?? '',
|
||||
option.preview?.title ?? '',
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
(nextPart.steps ?? [])
|
||||
.map((step) =>
|
||||
[
|
||||
step.key,
|
||||
step.title,
|
||||
step.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
nextPart.selectedValues?.join(',') ?? '',
|
||||
nextPart.resolvedBy ?? '',
|
||||
nextPart.resultText ?? '',
|
||||
nextPart.readOnly === true ? 'readonly' : '',
|
||||
].join(':');
|
||||
|
||||
if (seenLinkKeys.has(dedupeKey)) {
|
||||
return true;
|
||||
@@ -163,6 +448,15 @@ export function extractChatMessageParts(text: string) {
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const promptMatched = line.match(PROMPT_LINE_PATTERN);
|
||||
|
||||
if (promptMatched) {
|
||||
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
|
||||
keptLines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
@@ -196,7 +490,7 @@ export function extractChatMessageParts(text: string) {
|
||||
}
|
||||
|
||||
const latestPart = parts.at(-1);
|
||||
if (latestPart && isInternalResourceUrl(latestPart.url)) {
|
||||
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
|
||||
parts.pop();
|
||||
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
||||
keptLines.push(latestPart.url);
|
||||
@@ -222,24 +516,29 @@ export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.type !== 'link_card') {
|
||||
return null;
|
||||
if (record.type === 'link_card') {
|
||||
const title = normalizeText(record.title);
|
||||
const url = normalizeUrl(String(record.url ?? ''));
|
||||
const actionLabel = normalizeText(record.actionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card' as const,
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
}
|
||||
|
||||
const title = normalizeText(record.title);
|
||||
const url = normalizeUrl(String(record.url ?? ''));
|
||||
const actionLabel = normalizeText(record.actionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
if (record.type === 'prompt') {
|
||||
const promptPart = buildPromptPart(JSON.stringify(record));
|
||||
return promptPart;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card' as const,
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as ChatMessagePart[];
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export type ChatConversationItem = {
|
||||
currentJobMessage: string | null;
|
||||
currentQueueSize: number;
|
||||
currentStatusUpdatedAt: string | null;
|
||||
isPendingWork: boolean;
|
||||
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
|
||||
lastRequestPreview: string;
|
||||
lastMessagePreview: string;
|
||||
lastResponsePreview: string;
|
||||
@@ -173,6 +175,160 @@ function createPreview(text: string) {
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
const PENDING_WORK_ANALYSIS_PATTERNS = [
|
||||
/분석/u,
|
||||
/검토/u,
|
||||
/조사/u,
|
||||
/원인/u,
|
||||
/파악/u,
|
||||
/\banalysis\b/i,
|
||||
/\binvestigat(?:e|ion)\b/i,
|
||||
] as const;
|
||||
|
||||
const PENDING_WORK_DESIGN_PATTERNS = [
|
||||
/설계/u,
|
||||
/프롬프트/u,
|
||||
/시안/u,
|
||||
/구조/u,
|
||||
/방향/u,
|
||||
/기획/u,
|
||||
/플로우/u,
|
||||
/아키텍처/u,
|
||||
/\bdesign\b/i,
|
||||
/\barchitecture\b/i,
|
||||
] as const;
|
||||
|
||||
const PENDING_WORK_IMPLEMENTATION_PATTERNS = [
|
||||
/구현했/u,
|
||||
/수정했/u,
|
||||
/반영했/u,
|
||||
/적용했/u,
|
||||
/완료했/u,
|
||||
/마무리했/u,
|
||||
/배포했/u,
|
||||
/검증했/u,
|
||||
/빌드.*통과/u,
|
||||
/테스트.*통과/u,
|
||||
/캡처/u,
|
||||
/preview/iu,
|
||||
/변경 파일/u,
|
||||
/diff/u,
|
||||
/\bimplement(?:ed|ation)?\b/i,
|
||||
/\bfix(?:ed)?\b/i,
|
||||
/\bverified?\b/i,
|
||||
/\btested?\b/i,
|
||||
] as const;
|
||||
|
||||
const PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
|
||||
/원하시면/u,
|
||||
/진행해드릴/u,
|
||||
/이어(?:서|가)/u,
|
||||
/다음 단계/u,
|
||||
/선택/u,
|
||||
/옵션/u,
|
||||
/후속/u,
|
||||
/\bif you want\b/i,
|
||||
/\bnext step\b/i,
|
||||
] as const;
|
||||
|
||||
function normalizePendingWorkText(text: string | null | undefined) {
|
||||
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function hasPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function resolvePendingWorkReasonFromText(text: string) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasPendingWorkPattern(text, PENDING_WORK_DESIGN_PATTERNS)) {
|
||||
return 'design' as const;
|
||||
}
|
||||
|
||||
if (hasPendingWorkPattern(text, PENDING_WORK_ANALYSIS_PATTERNS)) {
|
||||
return 'analysis' as const;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasOpenPromptParts(parts: ChatMessagePart[] | undefined) {
|
||||
return (parts ?? []).some((part) => {
|
||||
if (part.type !== 'prompt' || part.readOnly === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((part.selectedValues?.length ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((part.resultText?.trim() ?? '').length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((part.resolvedAt?.trim() ?? '').length > 0 || part.resolvedBy != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePendingWorkState(args: {
|
||||
requestText?: string | null;
|
||||
responseText?: string | null;
|
||||
latestCodexParts?: ChatMessagePart[] | undefined;
|
||||
}) {
|
||||
if (hasOpenPromptParts(args.latestCodexParts)) {
|
||||
return {
|
||||
isPendingWork: true,
|
||||
pendingWorkReason: 'prompt' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const requestText = normalizePendingWorkText(args.requestText);
|
||||
const responseText = normalizePendingWorkText(args.responseText);
|
||||
const requestReason = resolvePendingWorkReasonFromText(requestText);
|
||||
|
||||
if (!requestReason) {
|
||||
return {
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasPendingWorkPattern(responseText, PENDING_WORK_IMPLEMENTATION_PATTERNS)) {
|
||||
return {
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseText) {
|
||||
return {
|
||||
isPendingWork: true,
|
||||
pendingWorkReason: requestReason,
|
||||
};
|
||||
}
|
||||
|
||||
const responseReason = resolvePendingWorkReasonFromText(responseText);
|
||||
|
||||
if (responseReason || hasPendingWorkPattern(responseText, PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
|
||||
return {
|
||||
isPendingWork: true,
|
||||
pendingWorkReason: responseReason ?? requestReason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
|
||||
/이전\s*(채팅|대화|문맥)/u,
|
||||
/이전\s*요청/u,
|
||||
@@ -279,6 +435,8 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
|
||||
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
|
||||
currentQueueSize: Number(row.current_queue_size ?? 0),
|
||||
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
lastRequestPreview: '',
|
||||
lastMessagePreview: String(row.last_message_preview ?? ''),
|
||||
lastResponsePreview: '',
|
||||
@@ -876,6 +1034,40 @@ async function getLatestResponseMessageIdMap(sessionIds: string[]) {
|
||||
return responseMap;
|
||||
}
|
||||
|
||||
async function getLatestCodexPromptPartsMap(sessionIds: string[]) {
|
||||
const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));
|
||||
|
||||
if (normalizedSessionIds.length === 0) {
|
||||
return new Map<string, ChatMessagePart[]>();
|
||||
}
|
||||
|
||||
const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
|
||||
.select('session_id', 'parts_json', 'created_at', 'message_id')
|
||||
.whereIn('session_id', normalizedSessionIds)
|
||||
.andWhere('author', 'codex')
|
||||
.orderBy('session_id', 'asc')
|
||||
.orderBy('created_at', 'desc')
|
||||
.orderBy('message_id', 'desc');
|
||||
|
||||
const promptPartMap = new Map<string, ChatMessagePart[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const sessionId = String(row.session_id ?? '').trim();
|
||||
|
||||
if (!sessionId || promptPartMap.has(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = parseChatMessageParts(row.parts_json);
|
||||
|
||||
if ((parts ?? []).some((part) => part.type === 'prompt')) {
|
||||
promptPartMap.set(sessionId, parts ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
return promptPartMap;
|
||||
}
|
||||
|
||||
async function getLatestResponseMessageId(sessionId: string) {
|
||||
const responseMap = await getLatestResponseMessageIdMap([sessionId]);
|
||||
return responseMap.get(sessionId.trim()) ?? null;
|
||||
@@ -1444,17 +1636,26 @@ export async function listChatConversations(
|
||||
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
|
||||
rows.map((row) => String(row.session_id ?? '')),
|
||||
);
|
||||
const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap(
|
||||
rows.map((row) => String(row.session_id ?? '')),
|
||||
);
|
||||
|
||||
if (!normalizedUnreadStateClientId) {
|
||||
return rows
|
||||
.map((row) => {
|
||||
const mapped = mapConversationRow(row);
|
||||
const pendingWorkState = resolvePendingWorkState({
|
||||
requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||
responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||
latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
|
||||
});
|
||||
return {
|
||||
...resolveConversationPreviewOverride(
|
||||
mapped,
|
||||
latestPreviewMessageMap.get(mapped.sessionId),
|
||||
latestRequestPreviewMap.get(mapped.sessionId),
|
||||
),
|
||||
...pendingWorkState,
|
||||
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||
hasUnreadResponse: false,
|
||||
@@ -1489,6 +1690,11 @@ export async function listChatConversations(
|
||||
const mapped = mapConversationRow(row);
|
||||
const preference = preferenceMap.get(mapped.sessionId);
|
||||
const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId);
|
||||
const pendingWorkState = resolvePendingWorkState({
|
||||
requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||
responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||
latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
|
||||
});
|
||||
|
||||
return {
|
||||
...resolveConversationPreviewOverride(
|
||||
@@ -1496,6 +1702,7 @@ export async function listChatConversations(
|
||||
latestPreviewMessage,
|
||||
latestRequestPreviewMap.get(mapped.sessionId),
|
||||
),
|
||||
...pendingWorkState,
|
||||
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||
clientId: normalizedUnreadStateClientId,
|
||||
@@ -1654,7 +1861,7 @@ export async function listChatConversationDetailPage(
|
||||
): Promise<ChatConversationDetailPage> {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
|
||||
const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 6)));
|
||||
const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 8)));
|
||||
const normalizedBeforeMessageId =
|
||||
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
|
||||
? Math.trunc(options.beforeMessageId as number)
|
||||
@@ -2435,6 +2642,36 @@ export async function deleteChatConversation(sessionId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearChatConversationData(sessionId: string, clientId?: string | null) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del();
|
||||
await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del();
|
||||
await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del();
|
||||
await trx(CHAT_CONVERSATION_CLIENT_TABLE)
|
||||
.where({ session_id: normalizedSessionId })
|
||||
.update({
|
||||
last_read_response_message_id: null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
await trx(CHAT_CONVERSATION_TABLE)
|
||||
.where({ session_id: normalizedSessionId })
|
||||
.update({
|
||||
current_request_id: null,
|
||||
current_job_status: null,
|
||||
current_job_message: null,
|
||||
current_queue_size: 0,
|
||||
current_status_updated_at: null,
|
||||
last_message_preview: '',
|
||||
last_message_at: null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return getChatConversation(normalizedSessionId, clientId);
|
||||
}
|
||||
|
||||
export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
|
||||
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
|
||||
.where({
|
||||
|
||||
@@ -149,13 +149,17 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
||||
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
||||
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
|
||||
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
|
||||
assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
|
||||
assert.match(prompt, /`steps` 배열을 추가해/);
|
||||
assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
|
||||
assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
|
||||
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
|
||||
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
|
||||
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
|
||||
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
|
||||
});
|
||||
|
||||
test('ensureChatSessionReferenceResource creates a persistent per-room markdown resource and preserves manual notes', async () => {
|
||||
test('ensureChatSessionReferenceResource creates a minimal per-room markdown resource without chat memo accumulation', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-'));
|
||||
|
||||
const resourcePath = await ensureChatSessionReferenceResource({
|
||||
@@ -182,13 +186,9 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
|
||||
const firstContent = await readFile(absolutePath, 'utf8');
|
||||
assert.match(firstContent, /# 채팅방 참고 리소스/);
|
||||
assert.match(firstContent, /## 자동 갱신 문맥/);
|
||||
assert.match(firstContent, /## 수동 메모/);
|
||||
|
||||
const manuallyEditedContent = firstContent.replace(
|
||||
'- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.',
|
||||
'- 유지 메모: 이 줄은 보존되어야 합니다.',
|
||||
);
|
||||
await writeFile(absolutePath, manuallyEditedContent, 'utf8');
|
||||
assert.doesNotMatch(firstContent, /## 수동 메모/);
|
||||
assert.doesNotMatch(firstContent, /## 최신 사용자 요청/);
|
||||
assert.doesNotMatch(firstContent, /## 최근 대화 요약/);
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
@@ -210,9 +210,8 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
|
||||
|
||||
const updatedContent = await readFile(absolutePath, 'utf8');
|
||||
assert.match(updatedContent, /request-2/);
|
||||
assert.match(updatedContent, /둘째 요청/);
|
||||
assert.match(updatedContent, /이전 1개 메시지는 제외되었습니다\./);
|
||||
assert.match(updatedContent, /유지 메모: 이 줄은 보존되어야 합니다\./);
|
||||
assert.doesNotMatch(updatedContent, /둘째 요청/);
|
||||
assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/);
|
||||
});
|
||||
|
||||
test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => {
|
||||
@@ -249,9 +248,6 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
|
||||
"이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {",
|
||||
'<!-- codex-live:auto:end -->',
|
||||
'',
|
||||
'## 수동 메모',
|
||||
'- 유지 메모',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
@@ -277,8 +273,8 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
|
||||
const rebuiltContent = await readFile(absolutePath, 'utf8');
|
||||
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
|
||||
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/g) ?? []).length, 1);
|
||||
assert.match(rebuiltContent, /셋째 요청/);
|
||||
assert.match(rebuiltContent, /## 수동 메모\n- 유지 메모/);
|
||||
assert.doesNotMatch(rebuiltContent, /셋째 요청/);
|
||||
assert.doesNotMatch(rebuiltContent, /## 수동 메모/);
|
||||
assert.doesNotMatch(rebuiltContent, /이전 응답 조각/);
|
||||
});
|
||||
|
||||
@@ -299,6 +295,282 @@ test('extractChatMessageParts strips link-card markers into structured parts', (
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts strips prompt markers into structured parts', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
[
|
||||
'단계형 선택지를 준비했습니다.',
|
||||
'[[prompt:{"title":"다음 단계 선택","description":"원하는 작업 흐름을 고르세요.","submitLabel":"계속","mode":"queue","options":[{"label":"요약 먼저","value":"summary-first","description":"현황 요약 후 구현합니다."},{"label":"바로 구현","value":"implement-now","description":"확인 없이 바로 수정합니다."}],"responseTemplate":"사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요."}]]',
|
||||
].join('\n'),
|
||||
),
|
||||
{
|
||||
strippedText: '단계형 선택지를 준비했습니다.',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '다음 단계 선택',
|
||||
description: '원하는 작업 흐름을 고르세요.',
|
||||
submitLabel: '계속',
|
||||
mode: 'queue',
|
||||
multiple: false,
|
||||
responseTemplate: '사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요.',
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: '요약 먼저',
|
||||
value: 'summary-first',
|
||||
description: '현황 요약 후 구현합니다.',
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: '바로 구현',
|
||||
value: 'implement-now',
|
||||
description: '확인 없이 바로 수정합니다.',
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps readonly auto-selected prompt state', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
[
|
||||
'시안 3개 중 자동 선택 결과입니다.',
|
||||
'[[prompt:{"title":"UI 시안 선택","description":"시간 안에 응답이 없어 자동 선택되었습니다.","readOnly":true,"selectedValues":["option-b"],"resolvedBy":"timeout","resultText":"B안이 기본 시안으로 채택되었습니다.","options":[{"label":"A안","value":"option-a","description":"카드 레이아웃 중심"},{"label":"B안","value":"option-b","description":"탭과 요약 헤더 중심"},{"label":"C안","value":"option-c","description":"하단 플로팅 액션 중심"}]}]]',
|
||||
].join('\n'),
|
||||
),
|
||||
{
|
||||
strippedText: '시안 3개 중 자동 선택 결과입니다.',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: 'UI 시안 선택',
|
||||
description: '시간 안에 응답이 없어 자동 선택되었습니다.',
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: true,
|
||||
selectedValues: ['option-b'],
|
||||
resolvedBy: 'timeout',
|
||||
resolvedAt: null,
|
||||
resultText: 'B안이 기본 시안으로 채택되었습니다.',
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: '카드 레이아웃 중심',
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: 'B안',
|
||||
value: 'option-b',
|
||||
description: '탭과 요약 헤더 중심',
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: 'C안',
|
||||
value: 'option-c',
|
||||
description: '하단 플로팅 액션 중심',
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
[
|
||||
'시안 미리보기 선택입니다.',
|
||||
'[[prompt:{"title":"시안 선택","options":[{"label":"이미지안","value":"image-a","preview":{"type":"image","url":"https://example.com/a.png","alt":"A"}},{"label":"마크다운안","value":"markdown-b","preview":{"type":"markdown","content":"## B안\\n- 설명"}},{"label":"HTML안","value":"html-c","preview":{"type":"html","content":"<section>C</section>","title":"HTML C"}},{"label":"리소스안","value":"resource-d","preview":{"type":"resource","url":"/api/chat/resources/sample.html","title":"리소스"}}]}]]',
|
||||
].join('\n'),
|
||||
),
|
||||
{
|
||||
strippedText: '시안 미리보기 선택입니다.',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: '이미지안',
|
||||
value: 'image-a',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'image',
|
||||
url: 'https://example.com/a.png',
|
||||
content: null,
|
||||
alt: 'A',
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '마크다운안',
|
||||
value: 'markdown-b',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'markdown',
|
||||
url: null,
|
||||
content: '## B안\n- 설명',
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'HTML안',
|
||||
value: 'html-c',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'html',
|
||||
url: null,
|
||||
content: '<section>C</section>',
|
||||
alt: null,
|
||||
title: 'HTML C',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '리소스안',
|
||||
value: 'resource-d',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/chat/resources/sample.html',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: '리소스',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts supports stepper prompt steps', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
[
|
||||
'단계형 stepper prompt입니다.',
|
||||
'[[prompt:{"title":"구현 흐름 선택","description":"단계별로 실행 범위를 고릅니다.","steps":[{"key":"layout","title":"시안 선택","options":[{"label":"A안","value":"layout-a","description":"기본 레이아웃"},{"label":"B안","value":"layout-b","description":"탭 중심 레이아웃"}]},{"key":"scope","title":"후속 범위","optional":true,"multiple":true,"freeTextLabel":"세부 요청","options":[{"label":"모바일 정리","value":"mobile-cleanup"},{"label":"상태 요약 추가","value":"summary-card"}]}],"responseTemplate":"{{step_summaries}}"}]]',
|
||||
].join('\n'),
|
||||
),
|
||||
{
|
||||
strippedText: '단계형 stepper prompt입니다.',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '구현 흐름 선택',
|
||||
description: '단계별로 실행 범위를 고릅니다.',
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: '{{step_summaries}}',
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
steps: [
|
||||
{
|
||||
key: 'layout',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
optional: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
selectedValues: [],
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'layout-a',
|
||||
description: '기본 레이아웃',
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: 'B안',
|
||||
value: 'layout-b',
|
||||
description: '탭 중심 레이아웃',
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
title: '후속 범위',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: true,
|
||||
optional: true,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: '세부 요청',
|
||||
freeTextPlaceholder: null,
|
||||
selectedValues: [],
|
||||
options: [
|
||||
{
|
||||
label: '모바일 정리',
|
||||
value: 'mobile-cleanup',
|
||||
description: null,
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: '상태 요약 추가',
|
||||
value: 'summary-card',
|
||||
description: null,
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts repairs malformed resource link-card urls and encoded action labels', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
@@ -317,6 +589,75 @@ test('extractChatMessageParts repairs malformed resource link-card urls and enco
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts canonicalizes prompt preview resource urls from public paths and absolute filesystem paths', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
'[[prompt:{"title":"시안 선택","options":[{"label":"공개경로","value":"public-path","preview":{"type":"resource","url":"public/.codex_chat/chat-room/resource/sample-a.html"}},{"label":"절대경로","value":"absolute-path","preview":{"type":"resource","url":"/home/how2ice/project/ai-code-app/public/.codex_chat/chat-room/resource/sample-b.html"}},{"label":"닷경로","value":"dot-path","preview":{"type":"resource","url":"/.codex_chat/chat-room/resource/sample-c.html"}}]}]]',
|
||||
),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: '공개경로',
|
||||
value: 'public-path',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-a.html',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '절대경로',
|
||||
value: 'absolute-path',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-b.html',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '닷경로',
|
||||
value: 'dot-path',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-c.html',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_CHAT_TYPES = void 0;
|
||||
exports.SEEDED_CUSTOM_CHAT_TYPES = exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = exports.DEFAULT_CHAT_TYPES = void 0;
|
||||
exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = {
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
description: '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
};
|
||||
exports.SEEDED_CUSTOM_CHAT_TYPES = [exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE];
|
||||
exports.DEFAULT_CHAT_TYPES = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'md-context-managed',
|
||||
name: 'MD 기준 관리',
|
||||
description: '## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'layout-editor-execution',
|
||||
|
||||
@@ -7,15 +7,38 @@ export type DefaultChatTypeRecord = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT =
|
||||
'## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
|
||||
|
||||
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'md-context-managed',
|
||||
name: 'MD 기준 관리',
|
||||
description:
|
||||
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'chat-maximized-bottom-safe',
|
||||
name: '채팅 최대화 하단 안전영역',
|
||||
description:
|
||||
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'layout-editor-execution',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
function normalizeCandidatePath(value: string | null | undefined) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized ? path.resolve(normalized) : null;
|
||||
}
|
||||
|
||||
function getCandidateScore(candidatePath: string) {
|
||||
let score = 0;
|
||||
|
||||
if (fs.existsSync(path.join(candidatePath, 'AGENTS.md'))) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(candidatePath, 'package.json'))) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(candidatePath, 'etc', 'servers', 'work-server'))) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function resolveMainProjectRoot() {
|
||||
const candidates = [
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT,
|
||||
env.PLAN_MAIN_PROJECT_REPO_PATH,
|
||||
env.PLAN_GIT_REPO_PATH,
|
||||
env.SERVER_COMMAND_PROJECT_ROOT,
|
||||
path.resolve(process.cwd(), '../../..'),
|
||||
process.cwd(),
|
||||
'/workspace/main-project',
|
||||
]
|
||||
.map((value) => normalizeCandidatePath(value))
|
||||
.filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
|
||||
|
||||
const existingCandidates = candidates.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (existingCandidates.length === 0) {
|
||||
return candidates[0] ?? path.resolve(process.cwd(), '../../..');
|
||||
}
|
||||
|
||||
return existingCandidates
|
||||
.slice()
|
||||
.sort((left, right) => getCandidateScore(right) - getCandidateScore(left))[0];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './resource-manager-service.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for resource manager html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
|
||||
});
|
||||
|
||||
test('resolveStaticContentType keeps markdown and text files unchanged', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.md'), 'text/markdown; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.log'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
@@ -86,7 +86,7 @@ const TEXT_FILE_EXTENSIONS = new Set([
|
||||
'.diff',
|
||||
]);
|
||||
|
||||
function resolveStaticContentType(filePath: string) {
|
||||
export function resolveStaticContentType(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
@@ -98,7 +98,6 @@ function resolveStaticContentType(filePath: string) {
|
||||
case '.cjs':
|
||||
case '.json':
|
||||
case '.css':
|
||||
case '.html':
|
||||
case '.txt':
|
||||
case '.diff':
|
||||
case '.log':
|
||||
@@ -107,6 +106,9 @@ function resolveStaticContentType(filePath: string) {
|
||||
case '.yml':
|
||||
case '.xml':
|
||||
return 'text/plain; charset=utf-8';
|
||||
case '.html':
|
||||
case '.htm':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'text/markdown; charset=utf-8';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { env } from '../config/env.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
import {
|
||||
getRuntimeWorkServerBuildInfo,
|
||||
readLatestWorkServerBuildInfo,
|
||||
@@ -243,7 +244,7 @@ async function findLatestSourceChangeInPath(rootPath: string, targetPath: string
|
||||
}
|
||||
|
||||
async function readLatestAppSourceChange() {
|
||||
const projectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const projectRoot = normalizePath(resolveMainProjectRoot());
|
||||
let latest: SourceChangeInfo | null = null;
|
||||
|
||||
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
|
||||
@@ -575,7 +576,7 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
|
||||
|
||||
function getServerDefinitions(): ServerDefinition[] {
|
||||
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const mainProjectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT);
|
||||
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { getAppConfigSnapshot } from './app-config-service.js';
|
||||
import { listBoardPosts, type BoardPostItem, type BoardPostRequestItem } from './board-service.js';
|
||||
import { getActiveChatService } from './chat-service.js';
|
||||
import { chatRuntimeService, type ChatRuntimeJobItem } from './chat-runtime-service.js';
|
||||
import { createNotificationMessage, deleteOlderNotificationMessagesBySource } from './notification-message-service.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
import {
|
||||
listServerCommands,
|
||||
restartServerCommand,
|
||||
type ServerCommandSnapshot,
|
||||
type ServerCommandKey,
|
||||
} from './server-command-service.js';
|
||||
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
|
||||
|
||||
@@ -19,9 +23,12 @@ const ACTIVE_CLIENT_WINDOW_MS = 3 * 60 * 1000;
|
||||
const TEST_TO_WORK_SERVER_DELAY_MS = 5_000;
|
||||
const RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
|
||||
const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservation';
|
||||
const RESERVED_RESTART_AUTO_FIX_SESSION_ID = 'server-restart-reservation';
|
||||
const RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS = 600;
|
||||
const RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS = 180;
|
||||
|
||||
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'completed' | 'cancelled' | 'failed';
|
||||
type RestartReservationTarget = 'all';
|
||||
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'recovering' | 'completed' | 'cancelled' | 'failed';
|
||||
type RestartReservationTarget = 'all' | 'test' | 'work-server';
|
||||
|
||||
type RestartReservationWorkloadSummary = {
|
||||
codexRunningCount: number;
|
||||
@@ -30,6 +37,31 @@ type RestartReservationWorkloadSummary = {
|
||||
automationQueuedCount: number;
|
||||
};
|
||||
|
||||
type RestartReservationWorkItem = {
|
||||
kind: 'codex' | 'automation';
|
||||
status: 'running' | 'queued' | 'waiting';
|
||||
title: string;
|
||||
detail: string | null;
|
||||
requestId: string | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
type RestartReservationAutoFixStatus = 'idle' | 'queued' | 'running' | 'completed' | 'failed';
|
||||
|
||||
type RestartReservationAutoFix = {
|
||||
enabled: boolean;
|
||||
targetKey: 'test' | 'work-server' | null;
|
||||
requestId: string | null;
|
||||
sessionId: string | null;
|
||||
status: RestartReservationAutoFixStatus;
|
||||
summary: string | null;
|
||||
detail: string | null;
|
||||
requestedAt: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
failedAt: string | null;
|
||||
};
|
||||
|
||||
type RestartReservationRow = {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
@@ -50,6 +82,7 @@ type RestartReservationRow = {
|
||||
auto_execute_at: string | null;
|
||||
auto_execute_delay_seconds: number | null;
|
||||
updated_at: string | null;
|
||||
auto_fix_json: RestartReservationAutoFix | string | null;
|
||||
};
|
||||
|
||||
export type ServerRestartReservationSnapshot = {
|
||||
@@ -72,6 +105,8 @@ export type ServerRestartReservationSnapshot = {
|
||||
autoExecuteAt: string | null;
|
||||
autoExecuteDelaySeconds: number;
|
||||
updatedAt: string | null;
|
||||
workItems: RestartReservationWorkItem[];
|
||||
autoFix: RestartReservationAutoFix;
|
||||
};
|
||||
|
||||
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
||||
@@ -83,6 +118,22 @@ function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultAutoFixState(): RestartReservationAutoFix {
|
||||
return {
|
||||
enabled: false,
|
||||
targetKey: null,
|
||||
requestId: null,
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
summary: null,
|
||||
detail: null,
|
||||
requestedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
failedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasAcceptedAutomationRequest(requestItem: Pick<BoardPostRequestItem, 'planItemId' | 'automationReceivedAt' | 'workflowState'>) {
|
||||
return Boolean(requestItem.planItemId) || Boolean(requestItem.automationReceivedAt) || requestItem.workflowState !== 'pending';
|
||||
}
|
||||
@@ -168,7 +219,48 @@ function buildNextCheckAt(row: RestartReservationRow | null | undefined) {
|
||||
return new Date(baseTime + SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS).toISOString();
|
||||
}
|
||||
|
||||
function mapReservationRow(row: RestartReservationRow | null | undefined): ServerRestartReservationSnapshot {
|
||||
function parseAutoFixState(rawValue: RestartReservationRow['auto_fix_json']): RestartReservationAutoFix {
|
||||
if (!rawValue) {
|
||||
return getDefaultAutoFixState();
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
try {
|
||||
return parseAutoFixState(JSON.parse(rawValue) as RestartReservationAutoFix);
|
||||
} catch {
|
||||
return getDefaultAutoFixState();
|
||||
}
|
||||
}
|
||||
|
||||
const value = rawValue as Partial<RestartReservationAutoFix>;
|
||||
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
targetKey: value.targetKey === 'test' || value.targetKey === 'work-server' ? value.targetKey : null,
|
||||
requestId: typeof value.requestId === 'string' ? value.requestId : null,
|
||||
sessionId: typeof value.sessionId === 'string' ? value.sessionId : null,
|
||||
status:
|
||||
value.status === 'queued'
|
||||
|| value.status === 'running'
|
||||
|| value.status === 'completed'
|
||||
|| value.status === 'failed'
|
||||
? value.status
|
||||
: 'idle',
|
||||
summary: typeof value.summary === 'string' ? value.summary : null,
|
||||
detail: typeof value.detail === 'string' ? value.detail : null,
|
||||
requestedAt: typeof value.requestedAt === 'string' ? value.requestedAt : null,
|
||||
startedAt: typeof value.startedAt === 'string' ? value.startedAt : null,
|
||||
completedAt: typeof value.completedAt === 'string' ? value.completedAt : null,
|
||||
failedAt: typeof value.failedAt === 'string' ? value.failedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapReservationRow(
|
||||
row: RestartReservationRow | null | undefined,
|
||||
options?: {
|
||||
workItems?: RestartReservationWorkItem[];
|
||||
},
|
||||
): ServerRestartReservationSnapshot {
|
||||
const rawSummary = row?.workload_summary_json;
|
||||
let workloadSummary = getDefaultWorkloadSummary();
|
||||
|
||||
@@ -193,6 +285,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
|
||||
};
|
||||
}
|
||||
|
||||
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
|
||||
|
||||
return {
|
||||
enabled: Boolean(row?.enabled),
|
||||
target: row?.target === 'all' ? 'all' : 'all',
|
||||
@@ -213,6 +307,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
|
||||
autoExecuteAt: row?.auto_execute_at ?? null,
|
||||
autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)),
|
||||
updatedAt: row?.updated_at ?? null,
|
||||
workItems: options?.workItems ?? [],
|
||||
autoFix,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,6 +335,7 @@ async function ensureServerRestartReservationTable() {
|
||||
table.string('app_origin', 255).nullable();
|
||||
table.timestamp('auto_execute_at', { useTz: true }).nullable();
|
||||
table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10);
|
||||
table.jsonb('auto_fix_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
@@ -247,6 +344,7 @@ async function ensureServerRestartReservationTable() {
|
||||
['app_origin', (table) => table.string('app_origin', 255).nullable()],
|
||||
['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()],
|
||||
['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)],
|
||||
['auto_fix_json', (table) => table.jsonb('auto_fix_json').notNullable().defaultTo('{}')],
|
||||
];
|
||||
|
||||
for (const [columnName, addColumn] of requiredColumns) {
|
||||
@@ -268,6 +366,7 @@ async function ensureServerRestartReservationTable() {
|
||||
status: 'idle',
|
||||
workload_summary_json: getDefaultWorkloadSummary(),
|
||||
active_client_count: 0,
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
@@ -295,6 +394,115 @@ async function countPendingAutomationWork() {
|
||||
return summarizeRestartReservationAutomationWork(await listBoardPosts());
|
||||
}
|
||||
|
||||
async function listRestartReservationWorkItems(): Promise<RestartReservationWorkItem[]> {
|
||||
const runtimeSnapshot = chatRuntimeService.getSnapshot();
|
||||
const codexRunningItems = runtimeSnapshot.running.slice(0, 4).map((item) => ({
|
||||
kind: 'codex' as const,
|
||||
status: 'running' as const,
|
||||
title: item.summary || 'Codex Live 요청',
|
||||
detail: item.startedAt ? `실행 시작 ${item.startedAt}` : null,
|
||||
requestId: item.requestId,
|
||||
sessionId: item.sessionId,
|
||||
}));
|
||||
const codexQueuedItems = runtimeSnapshot.queued.slice(0, 4).map((item) => ({
|
||||
kind: 'codex' as const,
|
||||
status: 'queued' as const,
|
||||
title: item.summary || 'Codex Live 요청',
|
||||
detail: item.enqueuedAt ? `대기 등록 ${item.enqueuedAt}` : null,
|
||||
requestId: item.requestId,
|
||||
sessionId: item.sessionId,
|
||||
}));
|
||||
|
||||
const boardPosts = await listBoardPosts();
|
||||
const automationItems = boardPosts.flatMap((post) =>
|
||||
post.requestItems
|
||||
.filter((requestItem) => requestItem.status === 'in_progress' || requestItem.status === 'queued' || requestItem.status === 'waiting')
|
||||
.slice(0, 4)
|
||||
.map((requestItem) => ({
|
||||
kind: 'automation' as const,
|
||||
status:
|
||||
requestItem.status === 'in_progress'
|
||||
? 'running' as const
|
||||
: requestItem.status === 'queued'
|
||||
? 'queued' as const
|
||||
: 'waiting' as const,
|
||||
title: requestItem.title.trim() || post.title.trim() || `자동화 요청 #${requestItem.id}`,
|
||||
detail: [post.title.trim() || null, requestItem.statusLabel.trim() || null].filter(Boolean).join(' · ') || null,
|
||||
requestId: requestItem.planItemId ? String(requestItem.planItemId) : null,
|
||||
sessionId: null,
|
||||
})),
|
||||
);
|
||||
|
||||
return [...codexRunningItems, ...codexQueuedItems, ...automationItems.slice(0, 6)];
|
||||
}
|
||||
|
||||
function normalizeRunnerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildCommandRunnerApiCandidates(requestPath: string) {
|
||||
const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health';
|
||||
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(configuredHealthUrl);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hostVariants =
|
||||
parsedUrl.hostname === 'host.docker.internal'
|
||||
? ['host.docker.internal', '127.0.0.1', 'localhost']
|
||||
: parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost'
|
||||
? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal']
|
||||
: [parsedUrl.hostname];
|
||||
|
||||
const deduped: string[] = [];
|
||||
|
||||
for (const hostname of hostVariants) {
|
||||
const candidate = new URL(parsedUrl.toString());
|
||||
candidate.hostname = hostname;
|
||||
candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
|
||||
candidate.search = '';
|
||||
candidate.hash = '';
|
||||
const serialized = normalizeRunnerUrl(candidate.toString());
|
||||
|
||||
if (!deduped.includes(serialized)) {
|
||||
deduped.push(serialized);
|
||||
}
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
async function requestCommandRunner(requestPath: string, init?: RequestInit) {
|
||||
const headers = new Headers(init?.headers);
|
||||
|
||||
if (init?.body != null && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const url of buildCommandRunnerApiCandidates(requestPath)) {
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
|
||||
}
|
||||
|
||||
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
||||
const reasons: string[] = [];
|
||||
|
||||
@@ -312,6 +520,224 @@ function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
||||
return reasons.length > 0 ? `${reasons.join(', ')} 진행 중이라 재기동을 대기합니다.` : null;
|
||||
}
|
||||
|
||||
function isRestartBuildFailure(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
return /(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message);
|
||||
}
|
||||
|
||||
function buildReservedRestartAutoFixPrompt(args: {
|
||||
targetKey: 'test' | 'work-server';
|
||||
failureMessage: string;
|
||||
}) {
|
||||
const repoPath = resolveMainProjectRoot();
|
||||
const targetLabel = args.targetKey === 'test' ? 'TEST 앱' : 'WORK-SERVER';
|
||||
|
||||
return [
|
||||
`당신은 ${repoPath} 저장소에서 ${targetLabel} 재기동 빌드 실패를 자동 복구하는 Codex 실행기입니다.`,
|
||||
'반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
|
||||
'목표는 현재 로컬 main 기준으로 재기동을 막는 빌드 오류를 직접 수정하는 것입니다.',
|
||||
'필요한 범위만 수정하고, 불필요한 Git 작업은 하지 마세요.',
|
||||
`현재 실패 대상: ${targetLabel}`,
|
||||
'실패 로그:',
|
||||
args.failureMessage,
|
||||
'작업 지시:',
|
||||
'1. 빌드 실패 원인을 확인합니다.',
|
||||
'2. 현재 저장소에서 직접 수정합니다.',
|
||||
'3. 대상 서버 재기동을 막는 빌드 오류가 해결되었는지 관련 빌드/검증 명령으로 확인합니다.',
|
||||
'4. 최종 답변은 한국어로 간결하게 작성합니다.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function updateReservationAutoFixState(patch: Partial<RestartReservationAutoFix>) {
|
||||
const row = await readReservationRow();
|
||||
const current = parseAutoFixState(row?.auto_fix_json ?? null);
|
||||
const nextState: RestartReservationAutoFix = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
|
||||
return updateReservationRow({
|
||||
auto_fix_json: nextState,
|
||||
});
|
||||
}
|
||||
|
||||
async function runReservedRestartAutoFix(
|
||||
logger: FastifyBaseLogger,
|
||||
args: {
|
||||
targetKey: 'test' | 'work-server';
|
||||
failureMessage: string;
|
||||
},
|
||||
) {
|
||||
const repoPath = resolveMainProjectRoot();
|
||||
const requestId = `server-restart-fix-${args.targetKey}-${Date.now().toString(36)}`;
|
||||
const sessionId = RESERVED_RESTART_AUTO_FIX_SESSION_ID;
|
||||
const prompt = buildReservedRestartAutoFixPrompt(args);
|
||||
|
||||
await updateReservationAutoFixState({
|
||||
enabled: true,
|
||||
targetKey: args.targetKey,
|
||||
requestId,
|
||||
sessionId,
|
||||
status: 'queued',
|
||||
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선 요청을 준비 중입니다.`,
|
||||
detail: args.failureMessage,
|
||||
requestedAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
failedAt: null,
|
||||
});
|
||||
|
||||
const response = await requestCommandRunner('/api/codex-live/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
sessionId,
|
||||
repoPath,
|
||||
prompt,
|
||||
resourceDir: path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
sessionId,
|
||||
'resource',
|
||||
),
|
||||
uploadDir: path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
sessionId,
|
||||
'resource',
|
||||
'uploads',
|
||||
),
|
||||
maxExecutionSeconds: RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS,
|
||||
idleTimeoutSeconds: RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const message = (await response.text().catch(() => '')) || 'Codex 자동 개선 요청을 시작하지 못했습니다.';
|
||||
await updateReservationAutoFixState({
|
||||
enabled: true,
|
||||
targetKey: args.targetKey,
|
||||
requestId,
|
||||
sessionId,
|
||||
status: 'failed',
|
||||
summary: `${args.targetKey.toUpperCase()} 자동 개선 요청 시작 실패`,
|
||||
detail: message,
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
let completedText = '';
|
||||
let remoteError = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
event = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = typeof event.type === 'string' ? event.type : '';
|
||||
|
||||
if (type === 'started') {
|
||||
await updateReservationAutoFixState({
|
||||
enabled: true,
|
||||
targetKey: args.targetKey,
|
||||
requestId,
|
||||
sessionId,
|
||||
status: 'running',
|
||||
summary: `${args.targetKey.toUpperCase()} 빌드 오류를 Codex가 분석 중입니다.`,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'activity' || type === 'stdout' || type === 'stderr') {
|
||||
const lineText = String(event.line ?? '').trim();
|
||||
|
||||
if (lineText) {
|
||||
await updateReservationAutoFixState({
|
||||
enabled: true,
|
||||
targetKey: args.targetKey,
|
||||
requestId,
|
||||
sessionId,
|
||||
status: 'running',
|
||||
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선 진행 중`,
|
||||
detail: lineText,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'completed') {
|
||||
completedText = String(event.text ?? '').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
remoteError = String(event.message ?? '').trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteError) {
|
||||
await updateReservationAutoFixState({
|
||||
enabled: true,
|
||||
targetKey: args.targetKey,
|
||||
requestId,
|
||||
sessionId,
|
||||
status: 'failed',
|
||||
summary: `${args.targetKey.toUpperCase()} 자동 개선 실패`,
|
||||
detail: remoteError,
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
throw new Error(remoteError);
|
||||
}
|
||||
|
||||
await updateReservationAutoFixState({
|
||||
enabled: true,
|
||||
targetKey: args.targetKey,
|
||||
requestId,
|
||||
sessionId,
|
||||
status: 'completed',
|
||||
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선을 마쳤습니다.`,
|
||||
detail: completedText || 'Codex 자동 개선이 완료되었습니다. 재기동을 다시 시도합니다.',
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.info({ requestId, targetKey: args.targetKey }, 'Reserved restart auto fix completed');
|
||||
|
||||
return {
|
||||
requestId,
|
||||
sessionId,
|
||||
completedText,
|
||||
};
|
||||
}
|
||||
|
||||
async function listActiveClients() {
|
||||
await ensureVisitorHistoryTables();
|
||||
const visitors = await listVisitorClients(50);
|
||||
@@ -452,6 +878,129 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
|
||||
return mapReservationRow(nextRow);
|
||||
}
|
||||
|
||||
async function restartReservedTargetWithRecovery(
|
||||
logger: FastifyBaseLogger,
|
||||
targetKey: 'test' | 'work-server',
|
||||
startMessage: string,
|
||||
) {
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
waiting_reason: startMessage,
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
await restartServerCommand(targetKey);
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '재기동에 실패했습니다.';
|
||||
|
||||
if (!isRestartBuildFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn({ err: error, targetKey }, 'Reserved restart build failure detected, requesting Codex auto fix');
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'recovering',
|
||||
waiting_reason: `${targetKey.toUpperCase()} 재기동 빌드 실패를 감지해 Codex 자동 개선을 시작합니다.`,
|
||||
last_checked_at: db.fn.now(),
|
||||
last_error: message,
|
||||
});
|
||||
|
||||
await runReservedRestartAutoFix(logger, {
|
||||
targetKey,
|
||||
failureMessage: message,
|
||||
});
|
||||
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
waiting_reason: `${targetKey.toUpperCase()} 빌드 오류를 수정해 재기동을 다시 시도합니다.`,
|
||||
last_checked_at: db.fn.now(),
|
||||
last_error: null,
|
||||
});
|
||||
|
||||
await restartServerCommand(targetKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeSingleServerRestart(targetKey: 'test' | 'work-server') {
|
||||
const nextRow = await updateReservationRow({
|
||||
enabled: false,
|
||||
target: targetKey,
|
||||
status: 'completed',
|
||||
completed_at: db.fn.now(),
|
||||
waiting_reason: null,
|
||||
workload_summary_json: getDefaultWorkloadSummary(),
|
||||
last_error: null,
|
||||
last_checked_at: db.fn.now(),
|
||||
auto_execute_at: null,
|
||||
});
|
||||
|
||||
return mapReservationRow(nextRow);
|
||||
}
|
||||
|
||||
let immediateRecoveryPromise: Promise<void> | null = null;
|
||||
|
||||
export async function requestImmediateRestartRecovery(
|
||||
logger: FastifyBaseLogger,
|
||||
targetKey: 'test' | 'work-server',
|
||||
failureMessage: string,
|
||||
) {
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
target: targetKey,
|
||||
status: 'recovering',
|
||||
requested_at: db.fn.now(),
|
||||
requested_by_client_id: null,
|
||||
waiting_reason: `${targetKey.toUpperCase()} 재기동 빌드 실패를 감지해 Codex 자동 개선을 시작합니다.`,
|
||||
workload_summary_json: getDefaultWorkloadSummary(),
|
||||
started_at: db.fn.now(),
|
||||
completed_at: null,
|
||||
cancelled_at: null,
|
||||
last_error: failureMessage,
|
||||
active_client_count: 0,
|
||||
notified_active_clients_at: null,
|
||||
app_origin: null,
|
||||
auto_execute_at: null,
|
||||
auto_execute_delay_seconds: 10,
|
||||
last_checked_at: db.fn.now(),
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
if (immediateRecoveryPromise) {
|
||||
return getServerRestartReservation();
|
||||
}
|
||||
|
||||
immediateRecoveryPromise = (async () => {
|
||||
try {
|
||||
await restartReservedTargetWithRecovery(
|
||||
logger,
|
||||
targetKey,
|
||||
`${targetKey.toUpperCase()} 빌드 오류를 자동 수정한 뒤 재기동을 다시 시도합니다.`,
|
||||
);
|
||||
await finalizeSingleServerRestart(targetKey);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '즉시 재기동 자동 복구에 실패했습니다.';
|
||||
logger.error({ err: error, targetKey }, 'Immediate restart recovery failed');
|
||||
await updateReservationRow({
|
||||
enabled: false,
|
||||
target: targetKey,
|
||||
status: 'failed',
|
||||
waiting_reason: null,
|
||||
last_error: message,
|
||||
last_checked_at: db.fn.now(),
|
||||
}).catch(() => undefined);
|
||||
} finally {
|
||||
immediateRecoveryPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return getServerRestartReservation();
|
||||
}
|
||||
|
||||
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
|
||||
const activeClients = await listActiveClients();
|
||||
await updateReservationRow({
|
||||
@@ -494,7 +1043,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
'Executing reserved restart',
|
||||
);
|
||||
|
||||
await restartServerCommand('test');
|
||||
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
||||
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
||||
|
||||
await updateReservationRow({
|
||||
@@ -504,11 +1053,21 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await restartServerCommand('work-server');
|
||||
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
||||
}
|
||||
|
||||
export async function getServerRestartReservation() {
|
||||
return mapReservationRow(await readReservationRow());
|
||||
const row = await readReservationRow();
|
||||
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
|
||||
const shouldExposeWorkItems =
|
||||
Boolean(row?.enabled)
|
||||
|| row?.status === 'waiting'
|
||||
|| row?.status === 'ready'
|
||||
|| row?.status === 'executing'
|
||||
|| row?.status === 'recovering'
|
||||
|| autoFix.enabled;
|
||||
const workItems = shouldExposeWorkItems ? await listRestartReservationWorkItems() : [];
|
||||
return mapReservationRow(row, { workItems });
|
||||
}
|
||||
|
||||
export async function scheduleServerRestartReservation(options?: {
|
||||
@@ -535,6 +1094,7 @@ export async function scheduleServerRestartReservation(options?: {
|
||||
app_origin: options?.appOrigin?.trim() || null,
|
||||
auto_execute_at: null,
|
||||
auto_execute_delay_seconds: autoExecuteDelaySeconds,
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
return mapReservationRow(row);
|
||||
@@ -550,6 +1110,7 @@ export async function cancelServerRestartReservation() {
|
||||
active_client_count: 0,
|
||||
last_error: null,
|
||||
auto_execute_at: null,
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
return mapReservationRow(row);
|
||||
@@ -580,6 +1141,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
|
||||
waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.',
|
||||
last_error: null,
|
||||
auto_execute_at: null,
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
if (!nextRow) {
|
||||
@@ -638,6 +1200,10 @@ export class ServerRestartReservationWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.status === 'recovering') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.status === 'executing' && row.started_at) {
|
||||
await finalizeReservedRestart(row);
|
||||
return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -205,7 +205,7 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a 300%+ volume jump over the previous snapshot', () => {
|
||||
test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks whose current jump beats the recent 5-record max by the configured ratio', () => {
|
||||
const items: StockAlertItem[] = [
|
||||
{
|
||||
id: '290550',
|
||||
@@ -300,6 +300,59 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
|
||||
},
|
||||
],
|
||||
]),
|
||||
new Map([
|
||||
[
|
||||
'290550',
|
||||
[
|
||||
{
|
||||
id: '290550:1',
|
||||
stockCode: '290550',
|
||||
stockName: '디케이티',
|
||||
baselineVolume: 100000,
|
||||
currentVolume: 160000,
|
||||
volumeIncreasePercent: 60,
|
||||
currentPrice: 25000,
|
||||
changeRate: 3,
|
||||
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'005930',
|
||||
[
|
||||
{
|
||||
id: '005930:1',
|
||||
stockCode: '005930',
|
||||
stockName: '삼성전자',
|
||||
baselineVolume: 130000,
|
||||
currentVolume: 200000,
|
||||
volumeIncreasePercent: 53.85,
|
||||
currentPrice: 205000,
|
||||
changeRate: 2,
|
||||
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'035420',
|
||||
[
|
||||
{
|
||||
id: '035420:1',
|
||||
stockCode: '035420',
|
||||
stockName: 'NAVER',
|
||||
baselineVolume: 120000,
|
||||
currentVolume: 190000,
|
||||
volumeIncreasePercent: 58.33,
|
||||
currentPrice: 240000,
|
||||
changeRate: -3,
|
||||
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
{
|
||||
thresholdPercent: 3,
|
||||
minVolumeIncreasePercent: 300,
|
||||
@@ -315,7 +368,8 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
|
||||
currentVolume: 400000,
|
||||
previousVolume: 100000,
|
||||
volumeIncreasePercent: 300,
|
||||
volumeAmplificationPercent: 300,
|
||||
recentMaxVolumeIncreasePercent: 60,
|
||||
volumeAmplificationGrowthPercent: 400,
|
||||
quotedAt: '2026-05-06T00:30:00.000Z',
|
||||
},
|
||||
]);
|
||||
@@ -358,6 +412,25 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
|
||||
},
|
||||
],
|
||||
]),
|
||||
new Map([
|
||||
[
|
||||
'290550',
|
||||
[
|
||||
{
|
||||
id: '290550:1',
|
||||
stockCode: '290550',
|
||||
stockName: '디케이티',
|
||||
baselineVolume: 100000,
|
||||
currentVolume: 150000,
|
||||
volumeIncreasePercent: 50,
|
||||
currentPrice: 25000,
|
||||
changeRate: 3,
|
||||
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
{
|
||||
thresholdPercent: 3,
|
||||
minVolumeIncreasePercent: 50,
|
||||
@@ -373,13 +446,14 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
|
||||
currentVolume: 350000,
|
||||
previousVolume: 200000,
|
||||
volumeIncreasePercent: 75,
|
||||
volumeAmplificationPercent: 50,
|
||||
recentMaxVolumeIncreasePercent: 50,
|
||||
volumeAmplificationGrowthPercent: 50,
|
||||
quotedAt: '2026-05-06T00:30:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day average volume when no previous snapshot exists', () => {
|
||||
test('buildChangeRateAndVolumeSpikeStockAlertCandidates skips stocks without a previous batch snapshot baseline', () => {
|
||||
const items: StockAlertItem[] = [
|
||||
{
|
||||
id: '290550',
|
||||
@@ -411,24 +485,35 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day
|
||||
},
|
||||
];
|
||||
|
||||
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(items, new Map(), {
|
||||
thresholdPercent: 3,
|
||||
minVolumeIncreasePercent: 300,
|
||||
});
|
||||
|
||||
assert.deepEqual(candidates, [
|
||||
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||
items,
|
||||
new Map(),
|
||||
new Map([
|
||||
[
|
||||
'290550',
|
||||
[
|
||||
{
|
||||
id: '290550:1',
|
||||
stockCode: '290550',
|
||||
stockName: '디케이티',
|
||||
baselineVolume: 100000,
|
||||
currentVolume: 175000,
|
||||
volumeIncreasePercent: 75,
|
||||
currentPrice: 25000,
|
||||
changeRate: 3,
|
||||
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
{
|
||||
stockCode: '290550',
|
||||
stockName: '디케이티',
|
||||
currentPrice: 26500,
|
||||
changeRate: 11.11,
|
||||
currentVolume: 400000,
|
||||
previousVolume: 100000,
|
||||
volumeIncreasePercent: 300,
|
||||
volumeAmplificationPercent: 300,
|
||||
quotedAt: '2026-05-06T00:30:00.000Z',
|
||||
thresholdPercent: 3,
|
||||
minVolumeIncreasePercent: 300,
|
||||
},
|
||||
]);
|
||||
);
|
||||
|
||||
assert.deepEqual(candidates, []);
|
||||
});
|
||||
|
||||
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
|
||||
|
||||
export const STOCK_ALERT_TABLE = 'stock_alerts';
|
||||
export const STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots';
|
||||
export const STOCK_ALERT_VOLUME_HISTORY_TABLE = 'stock_alert_volume_histories';
|
||||
export const STOCK_ALERT_LAYOUT_NAME = 'stock알림';
|
||||
|
||||
export const STOCK_ALERT_TYPE_OPTIONS = [
|
||||
@@ -73,6 +74,32 @@ export type StockAlertVolumeSnapshot = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type StockAlertVolumeHistoryRow = {
|
||||
id: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
baseline_volume: number | string | null;
|
||||
current_volume: number | string | null;
|
||||
volume_increase_percent: number | string | null;
|
||||
current_price: number | string | null;
|
||||
change_rate: number | string | null;
|
||||
quoted_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type StockAlertVolumeHistory = {
|
||||
id: string;
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
baselineVolume: number | null;
|
||||
currentVolume: number | null;
|
||||
volumeIncreasePercent: number | null;
|
||||
currentPrice: number | null;
|
||||
changeRate: number | null;
|
||||
quotedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type StockAlertVolumeSpikeCandidate = {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
@@ -81,7 +108,8 @@ export type StockAlertVolumeSpikeCandidate = {
|
||||
currentVolume: number | null;
|
||||
previousVolume: number | null;
|
||||
volumeIncreasePercent: number | null;
|
||||
volumeAmplificationPercent: number | null;
|
||||
recentMaxVolumeIncreasePercent: number | null;
|
||||
volumeAmplificationGrowthPercent: number | null;
|
||||
quotedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -345,50 +373,17 @@ function calculateVolumeIncreasePercent(currentVolume: number | null, previousVo
|
||||
return ((currentVolume - previousVolume) / previousVolume) * 100;
|
||||
}
|
||||
|
||||
function calculateVolumeAmplificationPercent(
|
||||
currentVolume: number | null,
|
||||
previousSnapshot: StockAlertVolumeSnapshot | null,
|
||||
fallbackPercent: number | null,
|
||||
) {
|
||||
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
|
||||
const previousCurrentVolume = normalizeNonNegativeVolume(previousSnapshot?.currentVolume);
|
||||
const previousBaselineVolume = normalizeNonNegativeVolume(previousSnapshot?.previousVolume);
|
||||
|
||||
function calculateVolumeAmplificationGrowthPercent(currentIncreasePercent: number | null, recentMaxIncreasePercent: number | null) {
|
||||
if (
|
||||
normalizedCurrentVolume === null
|
||||
|| previousCurrentVolume === null
|
||||
|| previousBaselineVolume === null
|
||||
|| previousCurrentVolume <= previousBaselineVolume
|
||||
|| normalizedCurrentVolume < previousCurrentVolume
|
||||
!isFiniteNumber(currentIncreasePercent)
|
||||
|| !isFiniteNumber(recentMaxIncreasePercent)
|
||||
|| recentMaxIncreasePercent <= 0
|
||||
|| currentIncreasePercent < recentMaxIncreasePercent
|
||||
) {
|
||||
return fallbackPercent;
|
||||
}
|
||||
|
||||
const previousRiseAmount = previousCurrentVolume - previousBaselineVolume;
|
||||
const currentRiseAmount = normalizedCurrentVolume - previousCurrentVolume;
|
||||
|
||||
if (previousRiseAmount <= 0) {
|
||||
return fallbackPercent;
|
||||
}
|
||||
|
||||
return ((currentRiseAmount - previousRiseAmount) / previousRiseAmount) * 100;
|
||||
}
|
||||
|
||||
function deriveVolumeBaselineFromRate5d(item: StockAlertItem) {
|
||||
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
|
||||
const volumeRate5d = isFiniteNumber(item.volumeRate5d) ? item.volumeRate5d : null;
|
||||
|
||||
if (currentVolume === null || volumeRate5d === null || volumeRate5d <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseline = currentVolume / (volumeRate5d / 100);
|
||||
|
||||
if (!Number.isFinite(baseline) || baseline <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(baseline));
|
||||
return ((currentIncreasePercent - recentMaxIncreasePercent) / recentMaxIncreasePercent) * 100;
|
||||
}
|
||||
|
||||
function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) {
|
||||
@@ -398,7 +393,7 @@ function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot:
|
||||
return snapshotBaseline;
|
||||
}
|
||||
|
||||
return deriveVolumeBaselineFromRate5d(item);
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow): StockAlertVolumeSnapshot {
|
||||
@@ -416,6 +411,21 @@ function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow):
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStockAlertVolumeHistoryRow(row: StockAlertVolumeHistoryRow): StockAlertVolumeHistory {
|
||||
return {
|
||||
id: String(row.id ?? '').trim(),
|
||||
stockCode: normalizeStockCode(row.stock_code),
|
||||
stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code),
|
||||
baselineVolume: normalizeNonNegativeVolume(row.baseline_volume),
|
||||
currentVolume: normalizeNonNegativeVolume(row.current_volume),
|
||||
volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent),
|
||||
currentPrice: parseLooseNumber(row.current_price),
|
||||
changeRate: parseLooseNumber(row.change_rate),
|
||||
quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null,
|
||||
createdAt: normalizeTimestamp(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStockAlertVolumeSnapshotRecord(
|
||||
item: StockAlertItem,
|
||||
currentVolume: number | null,
|
||||
@@ -451,6 +461,30 @@ function buildStockAlertVolumeSnapshotRecord(
|
||||
} satisfies Omit<StockAlertVolumeSnapshotRow, 'quoted_at'> & { quoted_at: string | null };
|
||||
}
|
||||
|
||||
function buildStockAlertVolumeHistoryRecord(
|
||||
item: StockAlertItem,
|
||||
baselineVolume: number | null,
|
||||
currentVolume: number | null,
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const normalizedBaselineVolume = normalizeNonNegativeVolume(baselineVolume);
|
||||
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
|
||||
const quotedAt = item.quotedAt ?? now;
|
||||
|
||||
return {
|
||||
id: `${item.stockCode}:${quotedAt}`,
|
||||
stock_code: item.stockCode,
|
||||
stock_name: item.stockName,
|
||||
baseline_volume: normalizedBaselineVolume,
|
||||
current_volume: normalizedCurrentVolume,
|
||||
volume_increase_percent: calculateVolumeIncreasePercent(normalizedCurrentVolume, normalizedBaselineVolume),
|
||||
current_price: item.currentPrice,
|
||||
change_rate: item.changeRate,
|
||||
quoted_at: item.quotedAt,
|
||||
created_at: now,
|
||||
} satisfies Omit<StockAlertVolumeHistoryRow, 'quoted_at'> & { quoted_at: string | null };
|
||||
}
|
||||
|
||||
function buildStockSymbols(stockCode: string) {
|
||||
const normalizedCode = normalizeStockCode(stockCode);
|
||||
|
||||
@@ -532,6 +566,27 @@ async function ensureStockAlertVolumeSnapshotTable() {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureStockAlertVolumeHistoryTable() {
|
||||
const exists = await db.schema.hasTable(STOCK_ALERT_VOLUME_HISTORY_TABLE);
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.schema.createTable(STOCK_ALERT_VOLUME_HISTORY_TABLE, (table) => {
|
||||
table.text('id').primary();
|
||||
table.text('stock_code').notNullable();
|
||||
table.text('stock_name').notNullable();
|
||||
table.bigInteger('baseline_volume').nullable();
|
||||
table.bigInteger('current_volume').nullable();
|
||||
table.decimal('volume_increase_percent', 10, 2).nullable();
|
||||
table.decimal('current_price', 14, 2).nullable();
|
||||
table.decimal('change_rate', 10, 4).nullable();
|
||||
table.timestamp('quoted_at', { useTz: true }).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: URL, init?: RequestInit) {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
@@ -1494,6 +1549,34 @@ async function listStockAlertVolumeSnapshots() {
|
||||
);
|
||||
}
|
||||
|
||||
async function listRecentStockAlertVolumeHistories(limitPerStock = 5) {
|
||||
await ensureStockAlertVolumeHistoryTable();
|
||||
|
||||
const rows = (await db<StockAlertVolumeHistoryRow>(STOCK_ALERT_VOLUME_HISTORY_TABLE)
|
||||
.select('*')
|
||||
.orderBy('created_at', 'desc')) as StockAlertVolumeHistoryRow[];
|
||||
const historyMap = new Map<string, StockAlertVolumeHistory[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const normalized = normalizeStockAlertVolumeHistoryRow(row);
|
||||
|
||||
if (!normalized.stockCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = historyMap.get(normalized.stockCode) ?? [];
|
||||
|
||||
if (items.length >= limitPerStock) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.push(normalized);
|
||||
historyMap.set(normalized.stockCode, items);
|
||||
});
|
||||
|
||||
return historyMap;
|
||||
}
|
||||
|
||||
async function upsertStockAlertVolumeSnapshots(
|
||||
items: StockAlertItem[],
|
||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||
@@ -1523,9 +1606,35 @@ async function upsertStockAlertVolumeSnapshots(
|
||||
});
|
||||
}
|
||||
|
||||
async function insertStockAlertVolumeHistories(
|
||||
items: StockAlertItem[],
|
||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||
) {
|
||||
await ensureStockAlertVolumeHistoryTable();
|
||||
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = items
|
||||
.map((item) => {
|
||||
const previousSnapshot = previousSnapshots.get(item.stockCode) ?? null;
|
||||
const baselineVolume = resolveComparableVolumeBaseline(item, previousSnapshot);
|
||||
return buildStockAlertVolumeHistoryRecord(item, baselineVolume, item.currentVolume);
|
||||
})
|
||||
.filter((record) => record.current_volume !== null);
|
||||
|
||||
if (!records.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(STOCK_ALERT_VOLUME_HISTORY_TABLE).insert(records).onConflict('id').ignore();
|
||||
}
|
||||
|
||||
export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||
items: StockAlertItem[],
|
||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||
recentHistories: Map<string, StockAlertVolumeHistory[]>,
|
||||
options: {
|
||||
thresholdPercent: number;
|
||||
minVolumeIncreasePercent: number;
|
||||
@@ -1541,16 +1650,23 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||
const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null);
|
||||
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
|
||||
const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume);
|
||||
const volumeAmplificationPercent = calculateVolumeAmplificationPercent(
|
||||
currentVolume,
|
||||
previousSnapshot ?? null,
|
||||
const recentMaxVolumeIncreasePercent = Math.max(
|
||||
...((recentHistories.get(item.stockCode) ?? [])
|
||||
.map((history) => history.volumeIncreasePercent)
|
||||
.filter((value): value is number => isFiniteNumber(value))),
|
||||
);
|
||||
const normalizedRecentMaxVolumeIncreasePercent = Number.isFinite(recentMaxVolumeIncreasePercent)
|
||||
? recentMaxVolumeIncreasePercent
|
||||
: null;
|
||||
const volumeAmplificationGrowthPercent = calculateVolumeAmplificationGrowthPercent(
|
||||
volumeIncreasePercent,
|
||||
normalizedRecentMaxVolumeIncreasePercent,
|
||||
);
|
||||
|
||||
if (
|
||||
volumeAmplificationPercent === null ||
|
||||
volumeAmplificationGrowthPercent === null ||
|
||||
Math.abs(item.changeRate ?? 0) < options.thresholdPercent ||
|
||||
volumeAmplificationPercent < options.minVolumeIncreasePercent
|
||||
volumeAmplificationGrowthPercent < options.minVolumeIncreasePercent
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
@@ -1564,7 +1680,8 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||
currentVolume,
|
||||
previousVolume,
|
||||
volumeIncreasePercent,
|
||||
volumeAmplificationPercent,
|
||||
recentMaxVolumeIncreasePercent: normalizedRecentMaxVolumeIncreasePercent,
|
||||
volumeAmplificationGrowthPercent,
|
||||
quotedAt: item.quotedAt,
|
||||
} satisfies StockAlertVolumeSpikeCandidate,
|
||||
];
|
||||
@@ -1576,7 +1693,7 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||
return changeRateGap;
|
||||
}
|
||||
|
||||
const volumeGap = (right.volumeAmplificationPercent ?? 0) - (left.volumeAmplificationPercent ?? 0);
|
||||
const volumeGap = (right.volumeAmplificationGrowthPercent ?? 0) - (left.volumeAmplificationGrowthPercent ?? 0);
|
||||
|
||||
if (volumeGap !== 0) {
|
||||
return volumeGap;
|
||||
@@ -1589,12 +1706,13 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||
export function buildChangeRateAndVolumeSpikeStockAlertLines(
|
||||
items: StockAlertItem[],
|
||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||
recentHistories: Map<string, StockAlertVolumeHistory[]>,
|
||||
options: {
|
||||
thresholdPercent: number;
|
||||
minVolumeIncreasePercent: number;
|
||||
},
|
||||
) {
|
||||
return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map(
|
||||
return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, recentHistories, options).map(
|
||||
(item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`,
|
||||
);
|
||||
}
|
||||
@@ -1664,12 +1782,14 @@ export async function sendManagedStockAlertWebPush(options: {
|
||||
const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all');
|
||||
const previousSnapshots =
|
||||
options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map<string, StockAlertVolumeSnapshot>();
|
||||
const recentHistories =
|
||||
options.mode === 'change-threshold-volume-spike' ? await listRecentStockAlertVolumeHistories() : new Map<string, StockAlertVolumeHistory[]>();
|
||||
const lines =
|
||||
options.mode === 'price'
|
||||
? buildCurrentPriceStockAlertLines(items)
|
||||
: options.mode === 'change-threshold'
|
||||
? buildChangeRateThresholdStockAlertLines(items, thresholdPercent)
|
||||
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, {
|
||||
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, {
|
||||
thresholdPercent,
|
||||
minVolumeIncreasePercent,
|
||||
});
|
||||
@@ -1681,6 +1801,12 @@ export async function sendManagedStockAlertWebPush(options: {
|
||||
const previousSnapshot = previousSnapshots.get(item.stockCode);
|
||||
return resolveComparableVolumeBaseline(item, previousSnapshot ?? null) !== null;
|
||||
});
|
||||
const hasRecentVolumeHistory =
|
||||
options.mode !== 'change-threshold-volume-spike'
|
||||
? false
|
||||
: items.some((item) =>
|
||||
(recentHistories.get(item.stockCode) ?? []).some((history) => isFiniteNumber(history.volumeIncreasePercent) && history.volumeIncreasePercent > 0),
|
||||
);
|
||||
const skippedReason =
|
||||
options.mode === 'price'
|
||||
? hasRegisteredTargets
|
||||
@@ -1692,13 +1818,16 @@ export async function sendManagedStockAlertWebPush(options: {
|
||||
: '등록된 종목이 없습니다.'
|
||||
: !hasRegisteredTargets
|
||||
? '등록된 종목이 없습니다.'
|
||||
: !hasComparableVolumeBaseline
|
||||
? '이전 거래량 또는 5영업일 평균 거래량 비교 기준이 없어 스냅샷만 갱신했습니다.'
|
||||
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 직전 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
|
||||
: !hasComparableVolumeBaseline
|
||||
? '이전 배치 실행 row 기준 거래량이 없어 스냅샷만 갱신했습니다.'
|
||||
: !hasRecentVolumeHistory
|
||||
? '배치 실행 거래량 히스토리 row 5건 비교 기준이 없어 히스토리만 적재했습니다.'
|
||||
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 최근 배치 실행 row 5건 최대값 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
|
||||
const skippedResult = createSkippedNotificationResult(skippedReason);
|
||||
|
||||
if (options.mode === 'change-threshold-volume-spike') {
|
||||
await upsertStockAlertVolumeSnapshots(items, previousSnapshots);
|
||||
await insertStockAlertVolumeHistories(items, previousSnapshots);
|
||||
}
|
||||
|
||||
if (!lines.length) {
|
||||
@@ -1810,7 +1939,7 @@ export async function updateStockAlertLayoutFeatureDescription() {
|
||||
'알림유형의 경우 멀티선택 가능하게 해주세요.',
|
||||
].join('\n');
|
||||
const nextNotes =
|
||||
'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.';
|
||||
'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량 비교는 배치 실행 때마다 적재되는 종목별 거래량 스냅샷과 최근 히스토리 row 5건 기준으로 계산해 제공합니다.';
|
||||
|
||||
if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) {
|
||||
changed = true;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { env } from '../config/env.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
|
||||
export type WorkServerBuildInfo = {
|
||||
version: string;
|
||||
@@ -26,7 +27,7 @@ function normalizeRootPath(value: string | null | undefined) {
|
||||
|
||||
function resolveSourceTargetRoots() {
|
||||
const roots = [WORK_SERVER_ROOT_PATH];
|
||||
const mainProjectRoot = normalizeRootPath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const mainProjectRoot = normalizeRootPath(resolveMainProjectRoot());
|
||||
|
||||
if (mainProjectRoot) {
|
||||
const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server');
|
||||
@@ -51,7 +52,7 @@ export function resolveWorkServerBuildInfoFilePaths(options?: {
|
||||
const workServerRootPath = path.resolve(options?.workServerRootPath ?? WORK_SERVER_ROOT_PATH);
|
||||
const configuredDistDir = String(options?.configuredDistDir ?? env.WORK_SERVER_DIST_DIR ?? 'dist').trim() || 'dist';
|
||||
const mainProjectRoot = normalizeRootPath(
|
||||
options?.mainProjectRoot ?? env.SERVER_COMMAND_MAIN_PROJECT_ROOT ?? env.SERVER_COMMAND_PROJECT_ROOT,
|
||||
options?.mainProjectRoot ?? resolveMainProjectRoot(),
|
||||
);
|
||||
const candidates = [
|
||||
path.join(resolveBuildInfoDirectoryPath(workServerRootPath, configuredDistDir), 'build-info.json'),
|
||||
|
||||
Reference in New Issue
Block a user