feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ playwright-report/
test-results/ test-results/
.cache/ .cache/
tmp/ tmp/
.tmp-*/
node_modules.root-owned-backup/ node_modules.root-owned-backup/
.env .env

View File

@@ -100,6 +100,9 @@ src/components
- 위치: `src/features/layout` - 위치: `src/features/layout`
- 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃 - 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃
- 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, Docs markdown preview, Plan 게시판 - 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, Docs markdown preview, Plan 게시판
- `Layout Editor`의 기능 명세는 위젯 기본 스펙 정의가 아니라, 현재 레이아웃에 배치된 컴포넌트의 화면 역할과 상호작용을 기술하는 데이터로 취급
- 따라서 컴포넌트 간 이벤트/상태 연결과 API 연결 흐름도 `Layout Editor` 기능 명세에서 구현 가능한 범위로 본다
- 따라서 샘플 메타나 위젯 기본 기능을 `Layout Editor` 기능 명세에 자동 주입하지 않음
프로젝트 종속 기능 규칙: 프로젝트 종속 기능 규칙:
@@ -135,6 +138,13 @@ src/components
- alpha 버전 배포는 `npm publish --tag alpha` - alpha 버전 배포는 `npm publish --tag alpha`
- Nexus 인증은 `~/.npmrc``username / _password(base64) / email` 방식으로 확인 - Nexus 인증은 `~/.npmrc``username / _password(base64) / email` 방식으로 확인
## 8-1. 웹푸쉬 작업 메모
- 동일한 웹푸쉬를 새 알림으로 교체하려면 DB에서 이전 알림을 지우지 말고 `POST /api/notifications/send` 호출 시 `data.notificationKey` 또는 `threadId`를 고정값으로 보냅니다.
- 서비스워커는 같은 `notificationKey``tag`로 사용하므로 같은 브라우저의 이전 알림이 자동으로 대체됩니다.
- 특정 브라우저 클라이언트에만 보내려면 같은 API payload에 `targetClientIds: ['클라이언트ID']`를 넣습니다.
- 대상 클라이언트 ID가 필요하면 `web_push_subscriptions.device_id`를 조회하고, raw SQL 대신 `/api/crud/web_push_subscriptions/select` 같은 기존 CRUD API를 우선 사용합니다.
## 9. etc 운영 기준 ## 9. etc 운영 기준
- 부가 서버/DB/타언어 프로젝트는 `etc` 아래에서 분리 관리 - 부가 서버/DB/타언어 프로젝트는 `etc` 아래에서 분리 관리

View File

@@ -22,16 +22,22 @@
## 입력 항목 ## 입력 항목
- `workId`: 반복 등록할 작업 ID - `workId`: 반복 등록할 작업 ID
- 스케줄이 실제 자동화 접수로 Plan을 만들 때 이 값을 베이스 ID로 사용합니다.
- 생성되는 Plan 작업 ID는 `workId-1`부터 `workId-999` 범위의 suffix를 붙여 유니크하게 관리합니다.
- `note`: 매번 생성될 요청 메모 - `note`: 매번 생성될 요청 메모
- `automationType`: 자동화 유형 - `automationType`: 자동화 유형
- `plan`: Markdown 스타일 Plan 문서 등록/접수 - 자동화 유형 관리를 통해 등록된 항목을 그대로 선택합니다.
- `auto_worker`: 실제 자동 작업 실행 - 스케줄 실행 시 선택한 자동화 유형 ID를 유지한 채 자동화 작업메모를 등록하고 즉시 접수합니다.
- `command_execution`, `non_source_work`: 기존 분류 유지
- `releaseTarget`: 반영 대상 브랜치 - `releaseTarget`: 반영 대상 브랜치
- `jangsingProcessingRequired`: 기능동작확인 필요 여부 - `jangsingProcessingRequired`: 기능동작확인 필요 여부
- `autoDeployToMain`: main 자동 반영 대상 여부 - `autoDeployToMain`: main 자동 반영 대상 여부
- `enabled`: 스케줄 사용 여부 - `enabled`: 스케줄 사용 여부
- `immediateRunEnabled`: 생성 직후 바로 등록 허용 여부 - `immediateRunEnabled`: 생성 직후 바로 등록 허용 여부
- `refreshContextSnapshotOnNextRun`: 다음 자동 실행 1회에 한해 프로젝트 구조/관련 소스를 다시 읽고 `.auto_codex/schedule/<id>/` 아래 Markdown 문서를 재정리할지 여부
- `executionMode`: `codex` 또는 `managed-service`
- `managed-service`를 선택하면 스케줄 PK가 포함된 `.auto_codex/schedule/<id>/managed-service/...` 경로에 서비스 패키지 번들을 생성해 구분합니다.
- 스케줄 삭제 시 해당 디렉터리도 함께 삭제합니다.
- `recreateManagedServiceOnNextSave`: 관리형 서비스 패키지를 저장 시 다시 생성할지 여부
## 스케줄 모드 ## 스케줄 모드
@@ -75,6 +81,8 @@
- 자주 반복되는 운영 작업은 고정 `workId`로 등록 - 자주 반복되는 운영 작업은 고정 `workId`로 등록
- 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인 - 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인
- 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작 - 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작
- 기존 스케줄 참조 문서를 다시 만들고 싶으면 `refreshContextSnapshotOnNextRun`을 켠 뒤 저장하면 다음 실행 1회 후 자동 해제
- 외부 프로그램으로 확장할 예정인 작업은 `managed-service`로 분리해 두면 스케줄 PK 기준 서비스 키와 패키지 경로를 고정할 수 있음
- 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤 - 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤
## API 경로 메모 ## API 경로 메모

View File

@@ -8,17 +8,4 @@ COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
cd "$REPO_ROOT" cd "$REPO_ROOT"
if docker inspect work-server >/dev/null 2>&1; then exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false')
SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '')
case "$SUPERVISOR_CMD" in
*work-server-supervisor*)
if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then
echo "work-server reload requested"
exit 0
fi
;;
esac
fi
exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server

View File

@@ -103,6 +103,7 @@ npm run server-command:runner
- `DELETE /api/plan/items/:id` - `DELETE /api/plan/items/:id`
- `POST /api/notifications/setup` - `POST /api/notifications/setup`
- `GET /api/notifications/tokens` - `GET /api/notifications/tokens`
- `GET /api/notifications/subscriptions/web`
- `PUT /api/notifications/tokens/ios` - `PUT /api/notifications/tokens/ios`
- `DELETE /api/notifications/tokens/ios` - `DELETE /api/notifications/tokens/ios`
- `POST /api/notifications/send-test` - `POST /api/notifications/send-test`
@@ -112,3 +113,12 @@ npm run server-command:runner
- 프론트에서 알림 `On``PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다. - 프론트에서 알림 `On``PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다. - 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
- Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다. - Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다.
## 웹푸쉬 호출 메모
- `POST /api/notifications/send``title`, `body`, `data`, `threadId` 외에 `targetClientIds`도 받을 수 있습니다.
- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다.
- 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다.
- `POST /api/notifications/send``targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다.
- `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다.
- 같은 알림을 교체하려면 DB 삭제 대신 `data.notificationKey` 또는 `threadId`를 고정값으로 보내세요. 서비스워커가 이 값을 브라우저 알림 `tag`로 사용해 이전 알림을 대체합니다.

View File

@@ -12,6 +12,7 @@ import { registerNotificationRoutes } from './routes/notification.js';
import { registerPlanRoutes } from './routes/plan.js'; import { registerPlanRoutes } from './routes/plan.js';
import { registerServerCommandRoutes } from './routes/server-command.js'; import { registerServerCommandRoutes } from './routes/server-command.js';
import { registerSchemaRoutes } from './routes/schema.js'; import { registerSchemaRoutes } from './routes/schema.js';
import { registerStockAlertRoutes } from './routes/stock-alert.js';
import { registerTextMemoRoutes } from './routes/text-memo.js'; import { registerTextMemoRoutes } from './routes/text-memo.js';
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js'; import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
import { shouldPersistNotFoundErrorLog } from './not-found.js'; import { shouldPersistNotFoundErrorLog } from './not-found.js';
@@ -34,6 +35,7 @@ export function createApp() {
app.register(registerSchemaRoutes); app.register(registerSchemaRoutes);
app.register(registerDdlRoutes); app.register(registerDdlRoutes);
app.register(registerCrudRoutes); app.register(registerCrudRoutes);
app.register(registerStockAlertRoutes);
app.register(registerErrorLogRoutes); app.register(registerErrorLogRoutes);
app.register(registerNotificationRoutes); app.register(registerNotificationRoutes);
app.register(registerPlanRoutes); app.register(registerPlanRoutes);

View File

@@ -7,6 +7,10 @@ import {
upsertAppConfig, upsertAppConfig,
upsertChatTypesConfig, upsertChatTypesConfig,
} from '../services/app-config-service.js'; } from '../services/app-config-service.js';
import {
getAutomationContextsConfig,
upsertAutomationContextsConfig,
} from '../services/automation-context-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js'; import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) { export async function registerAppConfigRoutes(app: FastifyInstance) {
@@ -37,6 +41,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}; };
}); });
app.get('/api/automation-contexts', async () => {
const automationContexts = await getAutomationContextsConfig();
return {
ok: true,
automationContexts,
};
});
app.put('/api/chat-types', async (request, reply) => { app.put('/api/chat-types', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};
@@ -95,6 +108,35 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
} }
}); });
app.put('/api/automation-contexts', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
automationContexts: z.array(z.unknown()),
}).parse(payload ?? {});
const savedAutomationContexts = await upsertAutomationContextsConfig(parsed.automationContexts);
return {
ok: true,
automationContexts: savedAutomationContexts,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '자동화 Context 저장에 실패했습니다.',
});
}
});
app.put('/api/app-config', async (request, reply) => { app.put('/api/app-config', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};

View File

@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { import {
listIosNotificationTokens, listIosNotificationTokens,
listWebPushSubscriptions,
getAutomationNotificationPreference, getAutomationNotificationPreference,
getWebPushConfig, getWebPushConfig,
registerIosNotificationToken, registerIosNotificationToken,
@@ -51,6 +52,10 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
items: await listIosNotificationTokens(), items: await listIosNotificationTokens(),
})); }));
app.get('/api/notifications/subscriptions/web', async () => ({
items: await listWebPushSubscriptions(),
}));
app.get('/api/notifications/webpush/config', async () => getWebPushConfig()); app.get('/api/notifications/webpush/config', async () => getWebPushConfig());
app.get('/api/notifications/messages', async (request) => { app.get('/api/notifications/messages', async (request) => {

View File

@@ -164,11 +164,22 @@ export async function registerPlanRoutes(app: FastifyInstance) {
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {}); const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
const row = await createPlanScheduledTask(payload); const row = await createPlanScheduledTask(payload);
const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null; const ignoreScheduleDueForImmediateRegistration = !payload.repeatWindowStartTime;
const immediateRegistration =
payload.executionMode === 'managed-service' && payload.recreateManagedServiceOnNextSave
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
forceManagedServiceGeneration: true,
})
: payload.enabled && payload.immediateRunEnabled
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
ignoreScheduleDue: ignoreScheduleDueForImmediateRegistration,
})
: null;
const latestRow = await getPlanScheduledTaskById(Number(row.id));
return { return {
ok: true, ok: true,
item: mapPlanScheduledTaskRow(row), item: mapPlanScheduledTaskRow(latestRow ?? row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null, registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [], registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
}; };
@@ -210,16 +221,34 @@ export async function registerPlanRoutes(app: FastifyInstance) {
}); });
} }
const shouldTriggerImmediateRegistration = const shouldTriggerImmediateRegistration = (
row && row
Boolean(row.enabled ?? true) && && String(row.execution_mode ?? '') === 'managed-service'
Boolean(row.immediate_run_enabled ?? true) && && payload.recreateManagedServiceOnNextSave === true
payload.enabled !== false; ) || (
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null; row
&& Boolean(row.enabled ?? true)
&& Boolean(row.immediate_run_enabled ?? true)
&& payload.enabled !== false
);
const effectiveRepeatWindowStartTime =
payload.repeatWindowStartTime !== undefined
? payload.repeatWindowStartTime
: (typeof row?.repeat_window_start_time === 'string' ? row.repeat_window_start_time : null);
const immediateRegistration = shouldTriggerImmediateRegistration
? await registerPlanScheduledTaskNow(id, new Date(), {
ignoreScheduleDue:
payload.recreateManagedServiceOnNextSave === true
? false
: !effectiveRepeatWindowStartTime,
forceManagedServiceGeneration: payload.recreateManagedServiceOnNextSave === true,
})
: null;
const latestRow = await getPlanScheduledTaskById(Number(id));
return { return {
ok: true, ok: true,
item: mapPlanScheduledTaskRow(row), item: mapPlanScheduledTaskRow(latestRow ?? row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null, registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [], registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
}; };

View File

@@ -0,0 +1,146 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
STOCK_ALERT_TYPE_OPTIONS,
createStockAlert,
deleteStockAlert,
listStockAlerts,
sendCurrentPriceStockAlertWebPush,
searchStockAlertCandidates,
saveStockAlerts,
updateStockAlert,
updateStockAlertLayoutFeatureDescription,
} from '../services/stock-alert-service.js';
const filterTypeSchema = z.enum(STOCK_ALERT_TYPE_OPTIONS.map((option) => option.value) as [string, ...string[]]).default('all');
const stockAlertMutationSchema = z
.object({
id: z.string().trim().optional(),
stockCode: z.string().trim().optional(),
stockName: z.string().trim().optional(),
alertType: z.string().trim().optional(),
alertTypes: z.array(z.string().trim()).optional(),
})
.transform((value) => ({
id: value.id,
stockCode: value.stockCode,
stockName: value.stockName,
alertTypes: value.alertTypes?.length
? value.alertTypes
: value.alertType?.trim()
? [value.alertType.trim()]
: [],
}));
const stockAlertMutationBodySchema = z
.object({
stockCode: z.string().trim().optional(),
stockName: z.string().trim().optional(),
alertType: z.string().trim().optional(),
alertTypes: z.array(z.string().trim()).optional(),
})
.transform((value) => ({
stockCode: value.stockCode,
stockName: value.stockName,
alertTypes: value.alertTypes?.length
? value.alertTypes
: value.alertType?.trim()
? [value.alertType.trim()]
: [],
}));
export async function registerStockAlertRoutes(app: FastifyInstance) {
app.get('/api/stock-alerts/search', async (request) => {
const query = z
.object({
query: z.string().trim().min(1),
limit: z.coerce.number().int().min(1).max(50).optional(),
})
.parse(request.query ?? {});
const items = await searchStockAlertCandidates(query.query, query.limit ?? 20);
return {
ok: true,
items,
};
});
app.get('/api/stock-alerts', async (request) => {
const query = z
.object({
alertType: filterTypeSchema.optional(),
})
.parse(request.query ?? {});
const alertType = (query.alertType ?? 'all') as 'all' | 'price' | 'top3';
await updateStockAlertLayoutFeatureDescription().catch(() => false);
const items = await listStockAlerts(alertType);
return {
ok: true,
items,
};
});
app.post('/api/stock-alerts/notify-current-price', async () => {
const result = await sendCurrentPriceStockAlertWebPush();
return {
ok: result.ok,
skipped: Boolean(result.web?.skipped),
title: result.title,
body: result.body,
itemCount: result.itemCount,
lines: result.lines,
ios: result.ios,
web: result.web,
};
});
app.post('/api/stock-alerts', async (request) => {
const payload = stockAlertMutationSchema.parse(request.body ?? {});
const item = await createStockAlert(payload);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
item,
};
});
app.patch('/api/stock-alerts/:id', async (request) => {
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const payload = stockAlertMutationBodySchema.parse(request.body ?? {});
const item = await updateStockAlert(params.id, payload);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
item,
};
});
app.put('/api/stock-alerts/batch', async (request) => {
const payload = z.object({ items: z.array(stockAlertMutationSchema).default([]) }).parse(request.body ?? {});
const items = await saveStockAlerts(payload.items);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
items,
};
});
app.delete('/api/stock-alerts/:id', async (request) => {
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
await deleteStockAlert(params.id);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
id: params.id,
};
});
}

View File

@@ -8,6 +8,7 @@ import {
textMemoNoteCreateSchema, textMemoNoteCreateSchema,
textMemoNoteImportSchema, textMemoNoteImportSchema,
textMemoNoteUpdateSchema, textMemoNoteUpdateSchema,
updateTextMemoLayoutFeatureDescription,
updateTextMemoNote, updateTextMemoNote,
} from '../services/text-memo-service.js'; } from '../services/text-memo-service.js';
@@ -17,6 +18,7 @@ function resolveClientId(headers: Record<string, unknown>) {
export async function registerTextMemoRoutes(app: FastifyInstance) { export async function registerTextMemoRoutes(app: FastifyInstance) {
app.get('/api/text-memo/notes', async (request) => { app.get('/api/text-memo/notes', async (request) => {
await updateTextMemoLayoutFeatureDescription().catch(() => false);
const items = await listTextMemoNotes(resolveClientId(request.headers)); const items = await listTextMemoNotes(resolveClientId(request.headers));
return { return {
ok: true, ok: true,
@@ -27,6 +29,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
app.post('/api/text-memo/notes', async (request) => { app.post('/api/text-memo/notes', async (request) => {
const payload = textMemoNoteCreateSchema.parse(request.body ?? {}); const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
const item = await createTextMemoNote(resolveClientId(request.headers), payload); const item = await createTextMemoNote(resolveClientId(request.headers), payload);
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return { return {
ok: true, ok: true,
@@ -37,6 +40,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
app.post('/api/text-memo/notes/import', async (request) => { app.post('/api/text-memo/notes/import', async (request) => {
const payload = textMemoNoteImportSchema.parse(request.body ?? {}); const payload = textMemoNoteImportSchema.parse(request.body ?? {});
const items = await importTextMemoNotes(resolveClientId(request.headers), payload); const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return { return {
ok: true, ok: true,
@@ -55,6 +59,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
}); });
} }
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return { return {
ok: true, ok: true,
item, item,
@@ -71,6 +77,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
}); });
} }
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return { return {
ok: true, ok: true,
}; };

View File

@@ -21,10 +21,29 @@ test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () =
assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']); assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']);
}); });
test('mergeDefaultChatTypes preserves saved edits for layout editor execution', () => {
const merged = mergeDefaultChatTypes([
{
id: 'layout-editor-execution',
name: 'Layout editor 실행',
description: '호출 가능한 API 요청만 처리합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-27T00:00:00.000Z',
},
]);
const layoutEditorExecution = merged.find((item) => item.id === 'layout-editor-execution');
assert.ok(layoutEditorExecution);
assert.equal(layoutEditorExecution.description, '호출 가능한 API 요청만 처리합니다.');
});
test('mergeDefaultChatTypes still appends missing built-in chat types', () => { test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
const merged = mergeDefaultChatTypes([]); const merged = mergeDefaultChatTypes([]);
assert.ok(merged.some((item) => item.id === 'general-request')); assert.ok(merged.some((item) => item.id === 'general-request'));
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 === 'api-request-template'));
assert.ok(merged.some((item) => item.id === 'general-inquiry')); assert.ok(merged.some((item) => item.id === 'general-inquiry'));
}); });

View File

@@ -2,6 +2,9 @@ import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs'; export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
const LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
const DEFAULT_CHAT_APP_CONFIG = { const DEFAULT_CHAT_APP_CONFIG = {
maxContextMessages: 12, maxContextMessages: 12,
maxContextChars: 3200, maxContextChars: 3200,
@@ -31,6 +34,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
}, },
{
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
name: 'Layout editor 실행',
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-28T23:55:00.000Z',
},
{ {
id: 'api-request-template', id: 'api-request-template',
name: 'API요청', name: 'API요청',
@@ -203,7 +214,8 @@ export function mergeDefaultChatTypes(items: unknown[]) {
const byId = new Map(savedItems.map((item) => [item.id, item] as const)); const byId = new Map(savedItems.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_CHAT_TYPES) { for (const defaultItem of DEFAULT_CHAT_TYPES) {
if (!byId.has(defaultItem.id)) { const savedItem = byId.get(defaultItem.id);
if (!savedItem) {
byId.set(defaultItem.id, defaultItem); byId.set(defaultItem.id, defaultItem);
} }
} }

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_AUTOMATION_CONTEXTS,
resolveAutomationContexts,
sanitizeAutomationContexts,
} from './automation-context-config-service.js';
test('default automation contexts include base handling context', () => {
assert.ok(DEFAULT_AUTOMATION_CONTEXTS.some((item) => item.id === 'none-default'));
});
test('sanitizeAutomationContexts falls back to defaults', () => {
const items = sanitizeAutomationContexts([]);
assert.ok(items.some((item) => item.id === 'auto-worker-default'));
});
test('resolveAutomationContexts returns only explicitly selected contexts', () => {
const contexts = resolveAutomationContexts(
[
{
id: 'ctx-1',
title: 'A',
content: 'A',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'ctx-2',
title: 'B',
content: 'B',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
['ctx-2'],
);
assert.deepEqual(
contexts.map((item) => item.id),
['ctx-2'],
);
});

View File

@@ -0,0 +1,360 @@
import { db } from '../db/client.js';
const AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
export type AutomationContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
];
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeEnabled(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
return false;
}
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
return true;
}
}
return value !== false;
}
function buildContextTitleKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const rawId = normalizeText(record.id);
const normalizedId =
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
title: title || 'Context',
content,
enabled: normalizeEnabled(record.enabled),
defaultSelected: normalizeEnabled(record.defaultSelected),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationContextRecord>();
const bySemanticKey = new Map<string, AutomationContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = buildContextTitleKey(item.title);
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
}
async function ensureAutomationContextsTable() {
const hasTable = await db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE);
if (!hasTable) {
await db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
table.string('id').primary();
table.string('title').notNullable();
table.text('content').notNullable().defaultTo('');
table.boolean('enabled').notNullable().defaultTo(true);
table.boolean('default_selected').notNullable().defaultTo(false);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['title', (table) => table.string('title').notNullable().defaultTo('')],
['content', (table) => table.text('content').notNullable().defaultTo('')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['default_selected', (table) => table.boolean('default_selected').notNullable().defaultTo(false)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
createColumn(table);
});
}
}
}
function parseContextsFromLegacyValue(value: unknown) {
if (typeof value !== 'string') {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toAutomationContextRecord(row: Record<string, unknown>) {
return normalizeAutomationContext({
id: typeof row.id === 'string' ? row.id : undefined,
title: typeof row.title === 'string' ? row.title : undefined,
content: typeof row.content === 'string' ? row.content : undefined,
enabled: normalizeEnabled(row.enabled),
defaultSelected: normalizeEnabled(row.default_selected),
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
});
}
async function replaceAutomationContextsInTable(items: AutomationContextRecord[]) {
await ensureAutomationContextsTable();
const nextItems = sanitizeAutomationContexts(items);
await db.transaction(async (trx) => {
await trx(AUTOMATION_CONTEXTS_TABLE).del();
await trx(AUTOMATION_CONTEXTS_TABLE).insert(
nextItems.map((item) => ({
id: item.id,
title: item.title,
content: item.content,
enabled: item.enabled,
default_selected: item.defaultSelected,
updated_at: item.updatedAt,
})),
);
});
return nextItems;
}
async function seedAutomationContextsFromLegacySources() {
const seededItems: Partial<AutomationContextRecord>[] = [...DEFAULT_AUTOMATION_CONTEXTS];
const hasAutomationTypesTable = await db.schema.hasTable('automation_types');
if (hasAutomationTypesTable) {
const rows = await db('automation_types').select('contexts_json');
for (const row of rows) {
seededItems.push(...parseContextsFromLegacyValue((row as Record<string, unknown>).contexts_json));
}
}
return replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems));
}
function isSameAutomationContextList(left: AutomationContextRecord[], right: AutomationContextRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.title === target.title &&
item.content === target.content &&
item.enabled === target.enabled &&
item.defaultSelected === target.defaultSelected &&
item.updatedAt === target.updatedAt
);
});
}
function mergeDefaultAutomationContexts(items: AutomationContextRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_CONTEXTS) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
title: defaultItem.title,
content: existingItem.content || defaultItem.content,
});
}
return sanitizeAutomationContexts(Array.from(byId.values()));
}
async function readAutomationContextsFromTable() {
await ensureAutomationContextsTable();
const rows = await db(AUTOMATION_CONTEXTS_TABLE)
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
.orderBy('title', 'asc');
const savedItems = rows
.map((row) => toAutomationContextRecord(row as Record<string, unknown>))
.filter((item): item is AutomationContextRecord => Boolean(item));
return sanitizeAutomationContexts(savedItems);
}
export async function getAutomationContextsConfig() {
const savedContexts = await readAutomationContextsFromTable();
if (savedContexts.length === 0 || savedContexts === DEFAULT_AUTOMATION_CONTEXTS) {
return seedAutomationContextsFromLegacySources();
}
const mergedContexts = mergeDefaultAutomationContexts(savedContexts);
if (!isSameAutomationContextList(savedContexts, mergedContexts)) {
await replaceAutomationContextsInTable(mergedContexts);
}
return mergedContexts;
}
export async function upsertAutomationContextsConfig(items: unknown[]) {
const nextContexts = mergeDefaultAutomationContexts(
sanitizeAutomationContexts(Array.isArray(items) ? (items as Partial<AutomationContextRecord>[]) : []),
);
return replaceAutomationContextsInTable(nextContexts);
}
export function normalizeAutomationContextSelection(value: unknown) {
const rawValues = Array.isArray(value)
? value
: typeof value === 'string'
? value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
}
export function resolveAutomationContexts(
contexts: AutomationContextRecord[] | null | undefined,
selectedContextIds?: unknown,
) {
const normalizedContexts = sanitizeAutomationContexts(contexts);
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
return [];
}
if (requestedIds.length === 0) {
return normalizedContexts.filter((item) => item.enabled && item.defaultSelected);
}
const requestedIdSet = new Set(requestedIds);
return normalizedContexts.filter((item) => requestedIdSet.has(item.id));
}

View File

@@ -0,0 +1,187 @@
import path from 'node:path';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { getEnv } from '../config/env.js';
import {
type AutomationContextRecord,
getAutomationContextsConfig,
normalizeAutomationContextSelection,
resolveAutomationContexts,
} from './automation-context-config-service.js';
import type { AutomationTypeRecord } from './automation-type-config-service.js';
export function stringifyAutomationContextIds(value: unknown) {
return JSON.stringify(normalizeAutomationContextSelection(value));
}
export function parseAutomationContextIds(value: unknown) {
if (Array.isArray(value)) {
return normalizeAutomationContextSelection(value);
}
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
if (!trimmed) {
return [];
}
try {
const parsed = JSON.parse(trimmed);
return normalizeAutomationContextSelection(parsed);
} catch {
return normalizeAutomationContextSelection(trimmed);
}
}
export function buildAutomationContextMarkdown(
contexts: Awaited<ReturnType<typeof getAutomationContextsConfig>> | null | undefined,
selectedContextIds?: unknown,
) {
const resolvedContexts = resolveAutomationContexts(contexts, selectedContextIds);
if (resolvedContexts.length === 0) {
return '선택된 자동화 Context 없음';
}
return resolvedContexts
.map((item) => [`### ${item.title}`, item.content.trim() || '(내용 없음)'].join('\n'))
.join('\n\n');
}
export async function buildAutomationNoteSections(options: {
title?: string;
sourceLabel: string;
requestContent: string;
attachments?: string[];
automationType?: Pick<AutomationTypeRecord, 'name'> | null;
availableContexts?: AutomationContextRecord[];
selectedContextIds?: unknown;
extraSections?: string[];
}) {
const availableContexts = options.availableContexts ?? (await getAutomationContextsConfig());
const lines = [
'# 자동화 작업메모',
'',
options.title?.trim() ? `- 게시판 제목: ${options.title.trim()}` : null,
`- 메모 출처: ${options.sourceLabel}`,
options.automationType?.name?.trim() ? `- 선택 자동화 유형: ${options.automationType.name.trim()}` : null,
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
'',
'## 자동화 Context',
buildAutomationContextMarkdown(availableContexts, options.selectedContextIds),
'',
...(options.extraSections ?? []),
'## 요청 본문',
options.requestContent.trim(),
...(options.attachments?.length ? ['', '## 첨부 파일', ...options.attachments] : []),
];
return lines.filter((line): line is string => line !== null && line !== undefined).join('\n');
}
function extractRequestedPaths(note: string) {
const matches = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g) ?? [];
return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))];
}
async function tryReadFile(filePath: string) {
try {
return await readFile(filePath, 'utf8');
} catch {
return null;
}
}
function limitText(value: string, maxChars = 12000) {
const normalized = value.trim();
return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars).trimEnd()}\n\n...`;
}
function getScheduleRepoRoot() {
const env = getEnv();
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
}
export async function ensureSchedulePromptSnapshot(options: {
scheduleId: number;
workId: string;
note: string;
forceRefresh?: boolean;
}) {
const repoRoot = getScheduleRepoRoot();
const scheduleDir = path.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId));
await mkdir(scheduleDir, { recursive: true });
const requestPath = path.join(scheduleDir, 'request.md');
const contextPath = path.join(scheduleDir, 'context.md');
const manifestPath = path.join(scheduleDir, 'manifest.json');
const requestedPaths = extractRequestedPaths(options.note);
const candidatePaths = [
'AGENTS.md',
'docs/README.md',
...requestedPaths.filter((item) => !item.startsWith('http://') && !item.startsWith('https://')),
];
const uniqueRelativePaths = [...new Set(candidatePaths)];
const references: string[] = [];
for (const relativePath of uniqueRelativePaths) {
const absolutePath = path.resolve(repoRoot, relativePath);
const content = await tryReadFile(absolutePath);
if (!content) {
continue;
}
references.push(`## ${relativePath}\n\n\`\`\`\n${limitText(content)}\n\`\`\``);
}
const requestMarkdown = [
'# 스케줄 요청 원문',
'',
`- 스케줄 ID: ${options.scheduleId}`,
`- 작업 ID: ${options.workId}`,
'',
'## 원본 메모',
options.note.trim() || '(비어 있음)',
].join('\n');
const contextMarkdown = [
'# 스케줄 전용 참조',
'',
'- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.',
'- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.',
'',
...(references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']),
].join('\n\n');
await writeFile(requestPath, `${requestMarkdown}\n`, 'utf8');
await writeFile(contextPath, `${contextMarkdown}\n`, 'utf8');
await writeFile(
manifestPath,
JSON.stringify(
{
scheduleId: options.scheduleId,
workId: options.workId,
refreshedAt: new Date().toISOString(),
forceRefresh: Boolean(options.forceRefresh),
sourcePaths: uniqueRelativePaths,
},
null,
2,
),
'utf8',
);
const relativeDir = path.relative(repoRoot, scheduleDir).replace(/\\/g, '/');
return {
directory: relativeDir,
requestPath: `${relativeDir}/request.md`,
contextPath: `${relativeDir}/context.md`,
manifestPath: `${relativeDir}/manifest.json`,
};
}

View File

@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_AUTOMATION_TYPES,
resolveStoredAutomationTypeId,
sanitizeAutomationTypes,
} from './automation-type-config-service.js';
test('default automation types include general inquiry', () => {
const generalInquiry = DEFAULT_AUTOMATION_TYPES.find((item) => item.id === 'general-inquiry');
assert.ok(generalInquiry);
assert.equal(generalInquiry.name, '일반 문의');
assert.equal(generalInquiry.behaviorType, 'command_execution');
});
test('sanitizeAutomationTypes falls back to general inquiry in defaults', () => {
const items = sanitizeAutomationTypes([]);
assert.ok(items.some((item) => item.id === 'general-inquiry'));
});
test('resolveStoredAutomationTypeId remaps legacy stock-alert id to general inquiry', () => {
assert.equal(resolveStoredAutomationTypeId({ automation_type_id: 'stock-alert' }), 'general-inquiry');
});

View File

@@ -14,21 +14,59 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number]; export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export type AutomationTypeRecord = { export type AutomationTypeRecord = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
contexts: AutomationTypeContextRecord[];
behaviorType: AutomationBehaviorType; behaviorType: AutomationBehaviorType;
enabled: boolean; enabled: boolean;
updatedAt: string; updatedAt: string;
}; };
export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'general-inquiry',
name: '일반 문의',
description: '일반 문의/확인 요청으로 처리합니다.',
contexts: [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{ {
id: 'none', id: 'none',
name: '기본유형', name: '기본유형',
description: description: '기본 자동화 처리용 유형입니다.',
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.', contexts: [
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'none', behaviorType: 'none',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -37,6 +75,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'plan', id: 'plan',
name: '작업 요청 등록', name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.', description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
contexts: [
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'plan', behaviorType: 'plan',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -45,6 +93,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'command_execution', id: 'command_execution',
name: 'Command 실행', name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.', description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
contexts: [
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution', behaviorType: 'command_execution',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -53,6 +111,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'non_source_work', id: 'non_source_work',
name: '비 소스작업', name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.', description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
contexts: [
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'non_source_work', behaviorType: 'non_source_work',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -60,55 +128,24 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{ {
id: 'auto_worker', id: 'auto_worker',
name: 'autoWorker', name: 'autoWorker',
description: description: '자동화 작업메모로 처리합니다.',
'자동화 작업메모로 처리합니다.\n\n## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.', contexts: [
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'auto_worker', behaviorType: 'auto_worker',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
}, },
]; ];
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: existingItem.description || defaultItem.description,
behaviorType: defaultItem.behaviorType,
});
}
return sanitizeAutomationTypes(Array.from(byId.values()));
}
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.name === target.name &&
item.description === target.description &&
item.behaviorType === target.behaviorType &&
item.enabled === target.enabled &&
item.updatedAt === target.updatedAt
);
});
}
function normalizeText(value: unknown) { function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
@@ -137,38 +174,14 @@ function normalizeEnabled(value: unknown) {
return value !== false; return value !== false;
} }
async function ensureAutomationTypesTable() { function normalizeLegacyAutomationTypeId(value: unknown) {
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE); const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
if (!hasTable) { if (normalizedValue === 'stock-alert') {
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => { return 'general-inquiry';
table.string('id').primary();
table.string('name').notNullable();
table.text('description').notNullable().defaultTo('');
table.string('behavior_type').notNullable();
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
} }
const requiredColumns: Array<[string, (table: any) => void]> = [ return normalizedValue;
['name', (table) => table.string('name').notNullable().defaultTo('')],
['description', (table) => table.text('description').notNullable().defaultTo('')],
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
createColumn(table);
});
}
}
} }
function normalizeBehaviorType(value: unknown): AutomationBehaviorType { function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
@@ -196,6 +209,69 @@ function buildNameKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
} }
function buildContextTitleKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const rawId = normalizeText(record.id);
const normalizedId =
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
title: title || 'Context',
content,
enabled: normalizeEnabled(record.enabled),
defaultSelected: normalizeEnabled(record.defaultSelected),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationTypeContextRecord>();
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = buildContextTitleKey(item.title);
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
}
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null { function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
const name = normalizeText(record.name); const name = normalizeText(record.name);
@@ -205,13 +281,13 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
const rawId = normalizeText(record.id); const rawId = normalizeText(record.id);
const normalizedId = const normalizedId =
rawId || rawId || `automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return { return {
id: normalizedId, id: normalizedId,
name, name,
description: normalizeText(record.description), description: normalizeText(record.description),
contexts: sanitizeAutomationContexts(record.contexts),
behaviorType: normalizeBehaviorType(record.behaviorType), behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: normalizeEnabled(record.enabled), enabled: normalizeEnabled(record.enabled),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
@@ -265,6 +341,85 @@ export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] |
return dedupeAutomationTypes(normalized); return dedupeAutomationTypes(normalized);
} }
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: existingItem.description || defaultItem.description,
contexts: sanitizeAutomationContexts(existingItem.contexts?.length ? existingItem.contexts : defaultItem.contexts),
behaviorType: defaultItem.behaviorType,
});
}
return sanitizeAutomationTypes(Array.from(byId.values()));
}
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.name === target.name &&
item.description === target.description &&
JSON.stringify(item.contexts) === JSON.stringify(target.contexts) &&
item.behaviorType === target.behaviorType &&
item.enabled === target.enabled &&
item.updatedAt === target.updatedAt
);
});
}
async function ensureAutomationTypesTable() {
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
if (!hasTable) {
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
table.string('id').primary();
table.string('name').notNullable();
table.text('description').notNullable().defaultTo('');
table.text('contexts_json').notNullable().defaultTo('[]');
table.string('behavior_type').notNullable();
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['name', (table) => table.string('name').notNullable().defaultTo('')],
['description', (table) => table.text('description').notNullable().defaultTo('')],
['contexts_json', (table) => table.text('contexts_json').notNullable().defaultTo('[]')],
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
createColumn(table);
});
}
}
}
function normalizeConfigRecord(value: unknown) { function normalizeConfigRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>; return {} as Record<string, unknown>;
@@ -273,11 +428,27 @@ function normalizeConfigRecord(value: unknown) {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
function parseContextsFromRow(row: Record<string, unknown>) {
const rawValue = row.contexts_json;
if (typeof rawValue !== 'string') {
return [];
}
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toAutomationTypeRecord(row: Record<string, unknown>) { function toAutomationTypeRecord(row: Record<string, unknown>) {
return normalizeAutomationType({ return normalizeAutomationType({
id: typeof row.id === 'string' ? row.id : undefined, id: typeof row.id === 'string' ? row.id : undefined,
name: typeof row.name === 'string' ? row.name : undefined, name: typeof row.name === 'string' ? row.name : undefined,
description: typeof row.description === 'string' ? row.description : undefined, description: typeof row.description === 'string' ? row.description : undefined,
contexts: parseContextsFromRow(row),
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType, behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
enabled: normalizeEnabled(row.enabled), enabled: normalizeEnabled(row.enabled),
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined, updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
@@ -303,7 +474,7 @@ async function readAutomationTypesFromTable() {
await ensureAutomationTypesTable(); await ensureAutomationTypesTable();
const rows = await db(AUTOMATION_TYPES_TABLE) const rows = await db(AUTOMATION_TYPES_TABLE)
.select('id', 'name', 'description', 'behavior_type', 'enabled', 'updated_at') .select('id', 'name', 'description', 'contexts_json', 'behavior_type', 'enabled', 'updated_at')
.orderBy('name', 'asc'); .orderBy('name', 'asc');
const savedItems = rows const savedItems = rows
@@ -326,6 +497,7 @@ async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
id: item.id, id: item.id,
name: item.name, name: item.name,
description: item.description, description: item.description,
contexts_json: JSON.stringify(item.contexts ?? []),
behavior_type: item.behaviorType, behavior_type: item.behaviorType,
enabled: item.enabled, enabled: item.enabled,
updated_at: item.updatedAt, updated_at: item.updatedAt,
@@ -359,7 +531,7 @@ export async function upsertAutomationTypesConfig(items: unknown[]) {
} }
export async function resolveAutomationType(input: unknown) { export async function resolveAutomationType(input: unknown) {
const requestedId = normalizeLegacyAutomationBehaviorType(input); const requestedId = normalizeLegacyAutomationTypeId(input);
const automationTypes = await getAutomationTypesConfig(); const automationTypes = await getAutomationTypesConfig();
const matched = automationTypes.find((item) => item.id === requestedId); const matched = automationTypes.find((item) => item.id === requestedId);
@@ -383,8 +555,40 @@ export function resolveStoredAutomationTypeId(row: Record<string, unknown>) {
const automationTypeId = normalizeText(row.automation_type_id); const automationTypeId = normalizeText(row.automation_type_id);
if (automationTypeId) { if (automationTypeId) {
return normalizeLegacyAutomationBehaviorType(automationTypeId); return normalizeLegacyAutomationTypeId(automationTypeId);
} }
return normalizeLegacyAutomationBehaviorType(row.automation_type) || 'none'; return normalizeLegacyAutomationTypeId(row.automation_type) || 'none';
}
export function normalizeAutomationContextSelection(value: unknown) {
const rawValues = Array.isArray(value)
? value
: typeof value === 'string'
? value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
}
export function resolveAutomationTypeContexts(
automationType: Pick<AutomationTypeRecord, 'contexts'> | null | undefined,
selectedContextIds?: unknown,
) {
const contexts = sanitizeAutomationContexts(automationType?.contexts);
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
return [];
}
if (requestedIds.length === 0) {
return contexts.filter((item) => item.enabled && item.defaultSelected);
}
const requestedIdSet = new Set(requestedIds);
return contexts.filter((item) => requestedIdSet.has(item.id));
} }

View File

@@ -1,17 +1,31 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-service.js'; import {
BoardPostAutomationLockedError,
buildBoardPostPlanNote,
resolveSequencedPlanWorkId,
} from './board-service.js';
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => { test('buildBoardPostPlanNote formats automation work memo with clear sections', async () => {
assert.equal( assert.equal(
buildBoardPostPlanNote( await buildBoardPostPlanNote(
' 알림 개선 ', ' 알림 개선 ',
'본문 첫 줄\n본문 둘째 줄\n', '본문 첫 줄\n본문 둘째 줄\n',
[], [],
{ {
name: '자동화 메모', name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
}, },
['ctx-1'],
[
{
id: 'ctx-1',
title: '처리 기준',
content: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
), ),
[ [
'# 자동화 작업메모', '# 자동화 작업메모',
@@ -19,10 +33,10 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
'- 게시판 제목: 알림 개선', '- 게시판 제목: 알림 개선',
'- 메모 출처: board_posts 자동화 접수', '- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 자동화 메모', '- 선택 자동화 유형: 자동화 메모',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.', '- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조니다.',
'', '',
'## 자동화 유형 context', '## 자동화 Context',
'## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.', '### 처리 기준\n## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
'', '',
'## 요청 본문', '## 요청 본문',
'본문 첫 줄\n본문 둘째 줄', '본문 첫 줄\n본문 둘째 줄',
@@ -30,16 +44,17 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
); );
}); });
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => { test('buildBoardPostPlanNote keeps context section even when selected context is empty', async () => {
assert.equal( assert.equal(
buildBoardPostPlanNote( await buildBoardPostPlanNote(
'작업', '작업',
'본문', '본문',
[], [],
{ {
name: '빈 context 유형', name: '빈 context 유형',
description: ' ',
}, },
[],
[],
), ),
[ [
'# 자동화 작업메모', '# 자동화 작업메모',
@@ -47,10 +62,10 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
'- 게시판 제목: 작업', '- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수', '- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 빈 context 유형', '- 선택 자동화 유형: 빈 context 유형',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.', '- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조니다.',
'', '',
'## 자동화 유형 context', '## 자동화 Context',
'선택된 자동화 유형 context 없음', '선택된 자동화 Context 없음',
'', '',
'## 요청 본문', '## 요청 본문',
'본문', '본문',
@@ -58,9 +73,9 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
); );
}); });
test('buildBoardPostPlanNote appends attachment lines when files exist', () => { test('buildBoardPostPlanNote appends attachment lines when files exist', async () => {
assert.equal( assert.equal(
buildBoardPostPlanNote( await buildBoardPostPlanNote(
'작업', '작업',
'본문', '본문',
[ [
@@ -74,16 +89,18 @@ test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
}, },
], ],
null, null,
[],
[],
), ),
[ [
'# 자동화 작업메모', '# 자동화 작업메모',
'', '',
'- 게시판 제목: 작업', '- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수', '- 메모 출처: board_posts 자동화 접수',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.', '- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조니다.',
'', '',
'## 자동화 유형 context', '## 자동화 Context',
'선택된 자동화 유형 context 없음', '선택된 자동화 Context 없음',
'', '',
'## 요청 본문', '## 요청 본문',
'본문', '본문',
@@ -98,3 +115,28 @@ test('BoardPostAutomationLockedError keeps user-facing message by action', () =>
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
}); });
test('resolveSequencedPlanWorkId uses -1 suffix first and finds the next available suffix', () => {
assert.equal(resolveSequencedPlanWorkId('정기-점검', []), '정기-점검-1');
assert.equal(resolveSequencedPlanWorkId('정기-점검', ['정기-점검-1', '정기-점검-2', '다른작업-1']), '정기-점검-3');
});
test('resolveSequencedPlanWorkId supports custom suffix labels such as service-{seq}', () => {
assert.equal(resolveSequencedPlanWorkId('schedule-10-반복-정리', [], 'service'), 'schedule-10-반복-정리-service-1');
assert.equal(
resolveSequencedPlanWorkId(
'schedule-10-반복-정리',
['schedule-10-반복-정리-service-1', 'schedule-10-반복-정리-service-2'],
'service',
),
'schedule-10-반복-정리-service-3',
);
});
test('resolveSequencedPlanWorkId truncates the base to preserve the suffix within 120 chars', () => {
const longBase = 'a'.repeat(120);
const workId = resolveSequencedPlanWorkId(longBase, []);
assert.equal(workId.length, 120);
assert.ok(workId.endsWith('-1'));
});

View File

@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
import { import {
ensurePlanTable, ensurePlanTable,
normalizePlanAutomationType, normalizePlanAutomationType,
normalizePlanWorkId,
PLAN_TABLE, PLAN_TABLE,
planAutomationTypeSchema, planAutomationTypeSchema,
} from './plan-service.js'; } from './plan-service.js';
@@ -11,6 +12,12 @@ import {
resolveStoredAutomationTypeId, resolveStoredAutomationTypeId,
type AutomationTypeRecord, type AutomationTypeRecord,
} from './automation-type-config-service.js'; } from './automation-type-config-service.js';
import {
buildAutomationNoteSections,
parseAutomationContextIds,
stringifyAutomationContextIds,
} from './automation-context-service.js';
import type { AutomationContextRecord } from './automation-context-config-service.js';
export const BOARD_POSTS_TABLE = 'board_posts'; export const BOARD_POSTS_TABLE = 'board_posts';
@@ -28,6 +35,7 @@ export const boardPostPayloadSchema = z.object({
}), }),
).max(20).default([]), ).max(20).default([]),
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
}); });
export type BoardPostItem = { export type BoardPostItem = {
@@ -39,6 +47,7 @@ export type BoardPostItem = {
automationType: z.infer<typeof boardPostPayloadSchema>['automationType']; automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
automationPlanItemId: number | null; automationPlanItemId: number | null;
automationReceivedAt: string | null; automationReceivedAt: string | null;
automationContextIds: string[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -54,6 +63,9 @@ export class BoardPostAutomationLockedError extends Error {
} }
} }
const MAX_SEQUENCED_PLAN_WORK_ID = 999;
const PLAN_WORK_ID_MAX_LENGTH = 120;
function createPreview(content: string) { function createPreview(content: string) {
const normalized = content const normalized = content
.replace(/```[\s\S]*?```/g, ' ') .replace(/```[\s\S]*?```/g, ' ')
@@ -91,6 +103,7 @@ function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined
? null ? null
: String(row.automation_received_at), : String(row.automation_received_at),
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
createdAt: String(row.created_at ?? ''), createdAt: String(row.created_at ?? ''),
updatedAt: String(row.updated_at ?? ''), updatedAt: String(row.updated_at ?? ''),
}; };
@@ -121,34 +134,52 @@ function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloa
return ['## 첨부 파일', ...lines]; return ['## 첨부 파일', ...lines];
} }
export function buildBoardPostPlanNote( export async function buildBoardPostPlanNote(
title: string, title: string,
content: string, content: string,
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [], attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null, automationType?: Pick<AutomationTypeRecord, 'name'> | null,
automationContextIds?: string[],
availableContexts?: AutomationContextRecord[],
) { ) {
const normalizedTitle = title.trim(); return buildAutomationNoteSections({
const normalizedContent = content.trim(); title: title.trim(),
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim(); sourceLabel: 'board_posts 자동화 접수',
const normalizedAutomationContext = String(automationType?.description ?? '').trim(); requestContent: content.trim(),
attachments: attachments.length ? buildBoardAttachmentSection(attachments).slice(1) : [],
automationType,
availableContexts,
selectedContextIds: automationContextIds,
});
}
return [ function buildSequencedPlanWorkId(baseWorkId: string, sequence: number, suffixLabel?: string | null) {
'# 자동화 작업메모', const normalizedBaseWorkId = normalizePlanWorkId(baseWorkId);
'', const normalizedSuffixLabel = String(suffixLabel ?? '').trim().replace(/^-+|-+$/g, '');
`- 게시판 제목: ${normalizedTitle}`, const suffix = normalizedSuffixLabel ? `-${normalizedSuffixLabel}-${sequence}` : `-${sequence}`;
'- 메모 출처: board_posts 자동화 접수', const maxBaseLength = Math.max(1, PLAN_WORK_ID_MAX_LENGTH - suffix.length);
normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null, const trimmedBaseWorkId = normalizedBaseWorkId.slice(0, maxBaseLength).trimEnd() || '작업ID';
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.', return `${trimmedBaseWorkId}${suffix}`;
'', }
'## 자동화 유형 context',
normalizedAutomationContext || '선택된 자동화 유형 context 없음', export function resolveSequencedPlanWorkId(
'', baseWorkId: string,
'## 요청 본문', existingWorkIds: Iterable<string>,
normalizedContent, suffixLabel?: string | null,
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []), ) {
] const existingWorkIdSet = new Set(
.filter((line): line is string => line !== null) Array.from(existingWorkIds, (value) => String(value ?? '').trim()).filter(Boolean),
.join('\n'); );
for (let sequence = 1; sequence <= MAX_SEQUENCED_PLAN_WORK_ID; sequence += 1) {
const candidate = buildSequencedPlanWorkId(baseWorkId, sequence, suffixLabel);
if (!existingWorkIdSet.has(candidate)) {
return candidate;
}
}
throw new Error(`자동화 접수 ID suffix를 더 이상 생성할 수 없습니다. (${MAX_SEQUENCED_PLAN_WORK_ID}개 초과)`);
} }
function resolveInsertedId(result: unknown): number | null { function resolveInsertedId(result: unknown): number | null {
@@ -223,6 +254,7 @@ export async function ensureBoardPostsTable() {
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')], ['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()], ['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')], ['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
['automation_context_ids_json', (table) => table.text('automation_context_ids_json').notNullable().defaultTo('[]')],
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()], ['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()], ['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -282,6 +314,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
attachments_json: JSON.stringify(parsedPayload.attachments), attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType, automation_type: automationType.behaviorType,
automation_type_id: automationType.id, automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
created_at: db.fn.now(), created_at: db.fn.now(),
updated_at: db.fn.now(), updated_at: db.fn.now(),
}); });
@@ -302,7 +335,14 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
return mapBoardPostRow(row); return mapBoardPostRow(row);
} }
export async function receiveBoardPostAutomation(id: number) { export async function receiveBoardPostAutomation(
id: number,
options?: {
planWorkIdBase?: string | null;
planWorkIdSuffixLabel?: string | null;
suppressWebPush?: boolean;
},
) {
await ensureBoardPostsTable(); await ensureBoardPostsTable();
await ensurePlanTable(); await ensurePlanTable();
@@ -327,17 +367,26 @@ export async function receiveBoardPostAutomation(id: number) {
const title = String(currentRow.title ?? '').trim(); const title = String(currentRow.title ?? '').trim();
const content = String(currentRow.content ?? '').trim(); const content = String(currentRow.content ?? '').trim();
const attachments = mapBoardPostRow(currentRow).attachments; const attachments = mapBoardPostRow(currentRow).attachments;
const workId = `board-post-${id}`; const automationContextIds = mapBoardPostRow(currentRow).automationContextIds;
const workId = options?.planWorkIdBase?.trim()
? resolveSequencedPlanWorkId(
options.planWorkIdBase,
(await trx(PLAN_TABLE).select('work_id')).map((row) => String(row.work_id ?? '')),
options.planWorkIdSuffixLabel,
)
: `board-post-${id}`;
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type); const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
const insertQuery = trx(PLAN_TABLE).insert({ const insertQuery = trx(PLAN_TABLE).insert({
work_id: workId, work_id: workId,
note: buildBoardPostPlanNote(title, content, attachments, automationType), note: await buildBoardPostPlanNote(title, content, attachments, automationType, automationContextIds),
automation_type: normalizePlanAutomationType(currentRow.automation_type), automation_type: normalizePlanAutomationType(currentRow.automation_type),
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type, automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
automation_context_ids_json: stringifyAutomationContextIds(automationContextIds),
status: '등록', status: '등록',
release_target: 'release', release_target: 'release',
jangsing_processing_required: true, jangsing_processing_required: true,
auto_deploy_to_main: false, auto_deploy_to_main: false,
suppress_web_push: options?.suppressWebPush ?? false,
worker_status: '대기', worker_status: '대기',
last_error: null, last_error: null,
updated_at: trx.fn.now(), updated_at: trx.fn.now(),
@@ -398,6 +447,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
attachments_json: JSON.stringify(parsedPayload.attachments), attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType, automation_type: automationType.behaviorType,
automation_type_id: automationType.id, automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
updated_at: trx.fn.now(), updated_at: trx.fn.now(),
}); });

View File

@@ -0,0 +1,216 @@
export type ChatMessagePart =
| {
type: 'link_card';
title: string;
url: string;
actionLabel?: string | null;
};
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\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;
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
return '';
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return false;
}
if (/^https?:\/\//i.test(normalized)) {
return !hasKnownFileExtension(normalized);
}
return !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
try {
const parsed = new URL(url, 'https://local.invalid');
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return lastSegment || parsed.hostname || normalizeText(url);
} catch {
return normalizeText(url);
}
}
function normalizeStandaloneTitle(value: string) {
return value
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
.replace(/[`'"]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
if (candidate) {
return candidate;
}
}
return buildFallbackLinkTitle(url);
}
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const segments = rawBody
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length < 2) {
return null;
}
const [rawTitle, rawUrl, rawActionLabel] = segments;
const title = normalizeText(rawTitle);
const url = normalizeUrl(rawUrl);
const actionLabel = normalizeText(rawActionLabel) || null;
if (!title || !url) {
return null;
}
return {
type: 'link_card',
title,
url,
actionLabel,
};
}
export function extractChatMessageParts(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
const parts: ChatMessagePart[] = [];
const seenLinkKeys = new Set<string>();
const pushPart = (nextPart: ChatMessagePart | null) => {
if (!nextPart) {
return false;
}
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
if (seenLinkKeys.has(dedupeKey)) {
return true;
}
seenLinkKeys.add(dedupeKey);
parts.push(nextPart);
return true;
};
for (const line of lines) {
const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) {
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
if (markdownLinkMatch) {
const [, rawTitle, rawUrl] = markdownLinkMatch;
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
continue;
}
}
}
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
if (standaloneUrlMatch) {
const rawUrl = standaloneUrlMatch[1] ?? '';
if (isStructuredLinkCardCandidate(rawUrl)) {
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
continue;
}
}
}
keptLines.push(line);
continue;
}
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
keptLines.push(line);
}
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
parts,
};
}
export function stringifyChatMessageParts(parts: ChatMessagePart[] | null | undefined) {
return JSON.stringify(Array.isArray(parts) ? parts : []);
}
export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
if (Array.isArray(value)) {
return value
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const record = item as Record<string, unknown>;
if (record.type !== 'link_card') {
return null;
}
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,
};
})
.filter(Boolean) as ChatMessagePart[];
}
if (typeof value === 'string') {
try {
return parseChatMessageParts(JSON.parse(value));
} catch {
return [];
}
}
return [];
}

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import { chatRuntimeService } from './chat-runtime-service.js'; import { chatRuntimeService } from './chat-runtime-service.js';
import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
export const CHAT_CONVERSATION_TABLE = 'chat_conversations'; export const CHAT_CONVERSATION_TABLE = 'chat_conversations';
export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages'; export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages';
@@ -28,6 +29,7 @@ const conversationMessagePayloadSchema = z.object({
text: z.string().max(200000), text: z.string().max(200000),
timestamp: z.string().trim().max(40), timestamp: z.string().trim().max(40),
clientRequestId: z.string().trim().max(120).nullable().optional(), clientRequestId: z.string().trim().max(120).nullable().optional(),
parts: z.array(z.custom<ChatMessagePart>()).optional(),
}); });
export type ChatConversationItem = { export type ChatConversationItem = {
@@ -57,6 +59,7 @@ export type StoredChatMessage = {
text: string; text: string;
timestamp: string; timestamp: string;
clientRequestId?: string | null; clientRequestId?: string | null;
parts?: ChatMessagePart[];
}; };
export type ChatConversationRequestStatus = export type ChatConversationRequestStatus =
@@ -199,6 +202,7 @@ function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
text: String(row.text ?? ''), text: String(row.text ?? ''),
timestamp: String(row.display_timestamp ?? ''), timestamp: String(row.display_timestamp ?? ''),
clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), clientRequestId: row.client_request_id == null ? null : String(row.client_request_id),
parts: parseChatMessageParts(row.parts_json),
}; };
} }
@@ -800,6 +804,7 @@ export async function ensureChatConversationTables() {
table.bigInteger('message_id').notNullable(); table.bigInteger('message_id').notNullable();
table.string('author', 20).notNullable(); table.string('author', 20).notNullable();
table.text('text').notNullable(); table.text('text').notNullable();
table.text('parts_json').notNullable().defaultTo('[]');
table.string('display_timestamp', 40).notNullable().defaultTo(''); table.string('display_timestamp', 40).notNullable().defaultTo('');
table.string('client_request_id', 120).nullable(); table.string('client_request_id', 120).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
@@ -812,6 +817,7 @@ export async function ensureChatConversationTables() {
['message_id', (table) => table.bigInteger('message_id').notNullable()], ['message_id', (table) => table.bigInteger('message_id').notNullable()],
['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')], ['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')],
['text', (table) => table.text('text').notNullable().defaultTo('')], ['text', (table) => table.text('text').notNullable().defaultTo('')],
['parts_json', (table) => table.text('parts_json').notNullable().defaultTo('[]')],
['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')], ['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')],
['client_request_id', (table) => table.string('client_request_id', 120).nullable()], ['client_request_id', (table) => table.string('client_request_id', 120).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -1608,6 +1614,7 @@ export async function appendChatConversationMessage(
message_id: message.messageId, message_id: message.messageId,
author: message.author, author: message.author,
text: message.text, text: message.text,
parts_json: stringifyChatMessageParts(message.parts),
display_timestamp: message.timestamp, display_timestamp: message.timestamp,
client_request_id: resolvedClientRequestId, client_request_id: resolvedClientRequestId,
created_at: db.fn.now(), created_at: db.fn.now(),
@@ -1616,6 +1623,7 @@ export async function appendChatConversationMessage(
.merge({ .merge({
author: message.author, author: message.author,
text: message.text, text: message.text,
parts_json: stringifyChatMessageParts(message.parts),
display_timestamp: message.timestamp, display_timestamp: message.timestamp,
client_request_id: resolvedClientRequestId, client_request_id: resolvedClientRequestId,
}); });
@@ -1971,7 +1979,7 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
.orderBy('request_id', 'asc'); .orderBy('request_id', 'asc');
const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
.where({ session_id: currentSessionId }) .where({ session_id: currentSessionId })
.select('id', 'message_id', 'author', 'text', 'client_request_id', 'created_at') .select('id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'created_at')
.orderBy('created_at', 'asc') .orderBy('created_at', 'asc')
.orderBy('message_id', 'asc') .orderBy('message_id', 'asc')
.orderBy('id', 'asc'); .orderBy('id', 'asc');

View File

@@ -18,6 +18,7 @@ import {
shouldUseTemplateMacroReply, shouldUseTemplateMacroReply,
validateAgenticCodexRuntime, validateAgenticCodexRuntime,
} from './chat-service.js'; } from './chat-service.js';
import { extractChatMessageParts } from './chat-message-parts.js';
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => { test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
assert.deepEqual( assert.deepEqual(
@@ -116,9 +117,67 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/); assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
assert.match(prompt, /### 반드시 지킬 context 원문/); assert.match(prompt, /### 반드시 지킬 context 원문/);
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./); assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)')); assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
}); });
test('extractChatMessageParts strips link-card markers into structured parts', () => {
assert.deepEqual(
extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://test.sm-home.cloud/chat/live|열기]]'].join('\n')),
{
strippedText: '결과 본문',
parts: [
{
type: 'link_card',
title: '미리보기',
url: 'https://test.sm-home.cloud/chat/live',
actionLabel: '열기',
},
],
},
);
});
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
assert.deepEqual(
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
{
strippedText: '결과 본문',
parts: [
{
type: 'link_card',
title: '판매글 열기',
url: 'https://www.daangn.com/kr/buy-sell/mac-studio',
actionLabel: null,
},
],
},
);
});
test('extractChatMessageParts promotes standalone urls with the previous line as the card title', () => {
assert.deepEqual(
extractChatMessageParts(
[
'수원 인근 공개 항목',
'- 동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원',
'https://www.daangn.com/kr/buy-sell/mac-studio-m2-max-12core-64gb-512g',
].join('\n'),
),
{
strippedText: ['수원 인근 공개 항목', '- 동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원'].join('\n'),
parts: [
{
type: 'link_card',
title: '동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원',
url: 'https://www.daangn.com/kr/buy-sell/mac-studio-m2-max-12core-64gb-512g',
actionLabel: null,
},
],
},
);
});
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => { test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`); const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);

View File

@@ -25,6 +25,7 @@ import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot
import { hasErrorLogViewAccessToken } from './error-log-service.js'; import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js'; import { createNotificationMessage } from './notification-message-service.js';
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { import {
findLatestPlanItem, findLatestPlanItem,
findPlanItemByPreviewUrl, findPlanItemByPreviewUrl,
@@ -47,6 +48,7 @@ type ChatMessage = {
text: string; text: string;
timestamp: string; timestamp: string;
clientRequestId?: string | null; clientRequestId?: string | null;
parts?: ChatMessagePart[];
}; };
type ChatContext = { type ChatContext = {
@@ -392,13 +394,14 @@ function createChatMessageId() {
return Date.now() * 1_000 + chatMessageSequence; return Date.now() * 1_000 + chatMessageSequence;
} }
function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null): ChatMessage { function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage {
return { return {
id: createChatMessageId(), id: createChatMessageId(),
author, author,
text, text,
timestamp: formatTime(new Date()), timestamp: formatTime(new Date()),
clientRequestId: clientRequestId?.trim() || null, clientRequestId: clientRequestId?.trim() || null,
parts: Array.isArray(parts) ? parts : [],
}; };
} }
@@ -1511,6 +1514,7 @@ export function buildAgenticCodexPrompt(
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.', '- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 보여줘야 하면 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.', '- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.', '- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.', '- 한국어로 간결하게 답하세요.',
@@ -2405,6 +2409,7 @@ export class ChatService {
text: message.text, text: message.text,
timestamp: message.timestamp, timestamp: message.timestamp,
clientRequestId: message.clientRequestId ?? null, clientRequestId: message.clientRequestId ?? null,
parts: message.parts ?? [],
}, },
), ),
); );
@@ -3402,7 +3407,13 @@ export class ChatService {
const finalCodexReplyMessage = { const finalCodexReplyMessage = {
...codexReplyMessage, ...codexReplyMessage,
text: reply, ...(() => {
const extracted = extractChatMessageParts(reply);
return {
text: extracted.strippedText,
parts: extracted.parts,
};
})(),
timestamp: resolveResponseTimestamp(request.requestedAtMs), timestamp: resolveResponseTimestamp(request.requestedAtMs),
}; };
@@ -3455,9 +3466,11 @@ export class ChatService {
error instanceof ChatRuntimeExecutionError ? error.responseText : ''; error instanceof ChatRuntimeExecutionError ? error.responseText : '';
if (failureResponseText) { if (failureResponseText) {
const extractedFailureReply = extractChatMessageParts(failureResponseText);
const failedCodexReplyMessage = { const failedCodexReplyMessage = {
...codexReplyMessage, ...codexReplyMessage,
text: failureResponseText, text: extractedFailureReply.strippedText,
parts: extractedFailureReply.parts,
timestamp: resolveResponseTimestamp(request.requestedAtMs), timestamp: resolveResponseTimestamp(request.requestedAtMs),
}; };

View File

@@ -439,6 +439,7 @@ export async function registerErrorLogBoardPosts(args?: {
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd), content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
attachments: [], attachments: [],
automationType: 'none', automationType: 'none',
automationContextIds: [],
}); });
createdPosts.push({ createdPosts.push({

View File

@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildManagedScheduleServiceMetadata } from './managed-schedule-service.js';
test('buildManagedScheduleServiceMetadata keeps schedule service artifacts in the strict schedule root', () => {
const metadata = buildManagedScheduleServiceMetadata(10, '반복-정리');
assert.equal(metadata.serviceKey, 'schedule-10-service');
assert.equal(metadata.packageName, 'service');
assert.equal(metadata.relativeDirectory, '.auto_codex/schedule/10');
assert.equal(metadata.readmePath, '.auto_codex/schedule/10/README.md');
assert.equal(metadata.sourcePath, '.auto_codex/schedule/10/service.ts');
assert.equal(metadata.runtimePath, '.auto_codex/schedule/10/service.mjs');
assert.equal(metadata.manifestPath, '.auto_codex/schedule/10/service-manifest.json');
});
test('buildManagedScheduleServiceMetadata appends the service suffix once for work ids', () => {
const metadata = buildManagedScheduleServiceMetadata(2, 'stock-alert-high-service');
assert.equal(metadata.serviceKey, 'schedule-2-stock-alert-high-service');
assert.equal(metadata.packageName, 'stock-alert-high-service');
});

View File

@@ -0,0 +1,168 @@
import path from 'node:path';
import { access, mkdir, rm } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { getEnv } from '../config/env.js';
import { sendManagedStockAlertWebPush } from './stock-alert-service.js';
export type ManagedScheduleServiceResult = {
ok: boolean;
skipped: boolean;
title: string;
body: string;
itemCount: number;
lines: string[];
ios: {
ok: boolean;
skipped: boolean;
reason?: string;
sentCount: number;
failedCount: number;
};
web: {
ok: boolean;
skipped: boolean;
reason?: string;
sentCount: number;
failedCount: number;
};
};
export type ManagedScheduleServiceMetadata = {
scheduleId: number;
serviceKey: string;
packageName: string;
relativeDirectory: string;
manifestPath: string;
readmePath: string;
sourcePath: string;
runtimePath: string;
};
function sanitizeManagedServiceToken(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
function appendScheduleSuffixOnce(value: string, suffix: string) {
const normalizedValue = value.trim();
const normalizedSuffix = suffix.trim().toLowerCase();
if (!normalizedValue || !normalizedSuffix) {
return normalizedValue;
}
return normalizedValue.toLowerCase().endsWith(`-${normalizedSuffix}`)
? normalizedValue
: `${normalizedValue}-${suffix.trim()}`;
}
function getScheduleRepoRoot() {
const env = getEnv();
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
}
export function buildManagedScheduleServiceMetadata(scheduleId: number, workId: string): ManagedScheduleServiceMetadata {
const workToken = sanitizeManagedServiceToken(appendScheduleSuffixOnce(workId, 'service')) || 'task-service';
const serviceKey = `schedule-${scheduleId}-${workToken}`;
const relativeDirectory = `.auto_codex/schedule/${scheduleId}`;
const packageName = workToken || `schedule-${scheduleId}-service`;
return {
scheduleId,
serviceKey,
packageName,
relativeDirectory,
manifestPath: `${relativeDirectory}/service-manifest.json`,
readmePath: `${relativeDirectory}/README.md`,
sourcePath: `${relativeDirectory}/service.ts`,
runtimePath: `${relativeDirectory}/service.mjs`,
};
}
export async function prepareManagedScheduleServiceDirectory(scheduleId: number) {
const repoRoot = getScheduleRepoRoot();
const absoluteDirectory = path.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId));
await mkdir(absoluteDirectory, { recursive: true });
await rm(path.join(absoluteDirectory, 'managed-service'), { recursive: true, force: true });
}
export async function removeManagedScheduleServiceArtifacts(scheduleId: number) {
const repoRoot = getScheduleRepoRoot();
const scheduleDirectory = path.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId));
await rm(path.join(scheduleDirectory, 'managed-service'), { recursive: true, force: true });
await rm(path.join(scheduleDirectory, 'README.md'), { force: true });
await rm(path.join(scheduleDirectory, 'service-manifest.json'), { force: true });
await rm(path.join(scheduleDirectory, 'service.ts'), { force: true });
await rm(path.join(scheduleDirectory, 'service.mjs'), { force: true });
}
export async function hasManagedScheduleServicePackage(relativeDirectory: string | null | undefined) {
const trimmedDirectory = String(relativeDirectory ?? '').trim();
if (!trimmedDirectory) {
return false;
}
const repoRoot = getScheduleRepoRoot();
const runtimePath = path.join(repoRoot, trimmedDirectory, 'service.mjs');
const manifestPath = path.join(repoRoot, trimmedDirectory, 'service-manifest.json');
try {
await Promise.all([access(runtimePath), access(manifestPath)]);
return true;
} catch {
return false;
}
}
export async function runManagedScheduleService(relativeDirectory: string) {
const repoRoot = getScheduleRepoRoot();
const runtimePath = path.join(repoRoot, relativeDirectory, 'service.mjs');
const importedModule = await import(`${pathToFileURL(runtimePath).href}?t=${Date.now()}`);
const run =
typeof importedModule.run === 'function'
? importedModule.run
: typeof importedModule.default?.run === 'function'
? importedModule.default.run
: null;
if (!run) {
throw new Error(`스케줄 서비스 실행 함수를 찾을 수 없습니다: ${relativeDirectory}/service.mjs`);
}
return run({
scheduleRoot: path.join(repoRoot, trimmedRelativeDirectory(relativeDirectory)),
scheduleDirectory: trimmedRelativeDirectory(relativeDirectory),
repoRoot,
now: new Date().toISOString(),
runCurrentPriceStockAlertService(definition: { scheduleId: number; serviceKey: string; title: string }) {
return sendManagedStockAlertWebPush({
scheduleId: definition.scheduleId,
serviceKey: definition.serviceKey,
title: definition.title,
mode: 'price',
});
},
runChangeRateThresholdStockAlertService(
definition: { scheduleId: number; serviceKey: string; title: string; thresholdPercent: number },
) {
return sendManagedStockAlertWebPush({
scheduleId: definition.scheduleId,
serviceKey: definition.serviceKey,
title: definition.title,
mode: 'change-threshold',
thresholdPercent: definition.thresholdPercent,
});
},
}) as Promise<ManagedScheduleServiceResult>;
}
function trimmedRelativeDirectory(relativeDirectory: string) {
return String(relativeDirectory ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}

View File

@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveNotificationAggregateResult } from './notification-service.js';
test('resolveNotificationAggregateResult marks managed-service web failures as failed when iOS is disabled', () => {
const result = resolveNotificationAggregateResult(
{
ios: { ok: true, skipped: true },
web: { ok: false, skipped: false },
},
{
disableIos: true,
},
);
assert.deepEqual(result, {
ok: false,
skipped: false,
});
});
test('resolveNotificationAggregateResult treats fully skipped enabled channels as skipped success', () => {
const result = resolveNotificationAggregateResult(
{
ios: { ok: true, skipped: true },
web: { ok: true, skipped: true },
},
{
disableIos: true,
},
);
assert.deepEqual(result, {
ok: true,
skipped: true,
});
});

View File

@@ -32,6 +32,8 @@ export const registerAutomationNotificationPreferenceSchema = z.object({
export const registerIosTokenSchema = z.object({ export const registerIosTokenSchema = z.object({
token: z.string().trim().min(1), token: z.string().trim().min(1),
deviceId: z.string().trim().min(1).max(200).optional(), deviceId: z.string().trim().min(1).max(200).optional(),
appOrigin: z.string().trim().url().max(500).optional(),
appDomain: z.string().trim().min(1).max(255).optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
}); });
@@ -50,6 +52,8 @@ export const registerWebPushSubscriptionSchema = z.object({
}), }),
deviceId: z.string().trim().min(1).max(200).optional(), deviceId: z.string().trim().min(1).max(200).optional(),
userAgent: z.string().trim().max(500).optional(), userAgent: z.string().trim().max(500).optional(),
appOrigin: z.string().trim().url().max(500).optional(),
appDomain: z.string().trim().min(1).max(255).optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
}); });
@@ -62,6 +66,9 @@ export const sendIosNotificationSchema = z.object({
body: z.string().trim().min(1), body: z.string().trim().min(1),
data: z.record(z.string(), z.string()).default({}), data: z.record(z.string(), z.string()).default({}),
threadId: z.string().trim().min(1).optional(), threadId: z.string().trim().min(1).optional(),
targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(),
targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(),
}); });
type IosNotificationPayload = z.infer<typeof sendIosNotificationSchema>; type IosNotificationPayload = z.infer<typeof sendIosNotificationSchema>;
@@ -73,6 +80,84 @@ type NotificationPreferenceTarget = {
id: string; id: string;
}; };
function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeTargetAppOrigins(targetAppOrigins: string[] | undefined) {
return [...new Set((targetAppOrigins ?? []).map((value) => normalizeAppOrigin(value)).filter(Boolean))];
}
function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
if (targetClientIds.length === 0) {
return true;
}
return Boolean(deviceId) && targetClientIds.includes(deviceId);
}
function normalizeAppOrigin(value: unknown) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
try {
const url = new URL(normalized);
return url.origin;
} catch {
return '';
}
}
function normalizeAppDomain(value: unknown) {
return String(value ?? '').trim().toLowerCase();
}
function resolveAppDomainFromOrigin(origin: string) {
if (!origin) {
return '';
}
try {
return new URL(origin).hostname.trim().toLowerCase();
} catch {
return '';
}
}
function isAllowedAppTarget(
item: {
appOrigin?: string;
appDomain?: string;
},
targetAppOrigins: string[],
targetAppDomains: string[],
) {
if (targetAppOrigins.length === 0 && targetAppDomains.length === 0) {
return true;
}
if (targetAppOrigins.length > 0) {
if (!item.appOrigin || !targetAppOrigins.includes(item.appOrigin)) {
return false;
}
}
if (targetAppDomains.length > 0) {
if (!item.appDomain || !targetAppDomains.includes(item.appDomain)) {
return false;
}
}
return true;
}
type WebPushFailureDetail = { type WebPushFailureDetail = {
endpoint: string; endpoint: string;
statusCode?: number; statusCode?: number;
@@ -244,6 +329,8 @@ async function ensureNotificationTokenTable() {
table.string('platform', 20).notNullable().defaultTo('ios'); table.string('platform', 20).notNullable().defaultTo('ios');
table.string('device_token', 255).notNullable().unique(); table.string('device_token', 255).notNullable().unique();
table.string('device_id', 200).nullable(); table.string('device_id', 200).nullable();
table.string('app_origin', 500).nullable();
table.string('app_domain', 255).nullable();
table.boolean('is_enabled').notNullable().defaultTo(true); table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
@@ -257,6 +344,8 @@ async function ensureNotificationTokenTable() {
['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')], ['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')],
['device_token', (table) => table.string('device_token', 255).notNullable()], ['device_token', (table) => table.string('device_token', 255).notNullable()],
['device_id', (table) => table.string('device_id', 200).nullable()], ['device_id', (table) => table.string('device_id', 200).nullable()],
['app_origin', (table) => table.string('app_origin', 500).nullable()],
['app_domain', (table) => table.string('app_domain', 255).nullable()],
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)], ['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
[ [
'last_registered_at', 'last_registered_at',
@@ -287,6 +376,8 @@ async function ensureWebPushSubscriptionTable() {
table.jsonb('subscription_json').notNullable(); table.jsonb('subscription_json').notNullable();
table.string('device_id', 200).nullable(); table.string('device_id', 200).nullable();
table.text('user_agent').nullable(); table.text('user_agent').nullable();
table.string('app_origin', 500).nullable();
table.string('app_domain', 255).nullable();
table.boolean('is_enabled').notNullable().defaultTo(true); table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
@@ -301,6 +392,8 @@ async function ensureWebPushSubscriptionTable() {
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')], ['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
['device_id', (table) => table.string('device_id', 200).nullable()], ['device_id', (table) => table.string('device_id', 200).nullable()],
['user_agent', (table) => table.text('user_agent').nullable()], ['user_agent', (table) => table.text('user_agent').nullable()],
['app_origin', (table) => table.string('app_origin', 500).nullable()],
['app_domain', (table) => table.string('app_domain', 255).nullable()],
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)], ['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
[ [
'last_registered_at', 'last_registered_at',
@@ -389,6 +482,8 @@ export async function listIosNotificationTokens() {
platform: row.platform, platform: row.platform,
token: row.device_token, token: row.device_token,
deviceId: row.device_id, deviceId: row.device_id,
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
enabled: row.is_enabled, enabled: row.is_enabled,
lastRegisteredAt: row.last_registered_at, lastRegisteredAt: row.last_registered_at,
createdAt: row.created_at, createdAt: row.created_at,
@@ -396,8 +491,29 @@ export async function listIosNotificationTokens() {
})); }));
} }
export async function listWebPushSubscriptions() {
await ensureWebPushSubscriptionTable();
const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE).orderBy('updated_at', 'desc');
return rows.map((row) => ({
id: row.id,
endpoint: String(row.endpoint ?? ''),
deviceId: row.device_id ? String(row.device_id) : '',
userAgent: row.user_agent ? String(row.user_agent) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
enabled: Boolean(row.is_enabled),
lastRegisteredAt: row.last_registered_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function registerIosNotificationToken(payload: z.infer<typeof registerIosTokenSchema>) { export async function registerIosNotificationToken(payload: z.infer<typeof registerIosTokenSchema>) {
await ensureNotificationTokenTable(); await ensureNotificationTokenTable();
const appOrigin = normalizeAppOrigin(payload.appOrigin);
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
if (!payload.enabled) { if (!payload.enabled) {
await unregisterIosNotificationToken(payload.token); await unregisterIosNotificationToken(payload.token);
@@ -414,6 +530,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
platform: 'ios', platform: 'ios',
device_token: payload.token, device_token: payload.token,
device_id: payload.deviceId ?? null, device_id: payload.deviceId ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true, is_enabled: true,
last_registered_at: db.fn.now(), last_registered_at: db.fn.now(),
updated_at: db.fn.now(), updated_at: db.fn.now(),
@@ -422,6 +540,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
.merge({ .merge({
platform: 'ios', platform: 'ios',
device_id: payload.deviceId ?? null, device_id: payload.deviceId ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true, is_enabled: true,
last_registered_at: db.fn.now(), last_registered_at: db.fn.now(),
updated_at: db.fn.now(), updated_at: db.fn.now(),
@@ -519,6 +639,8 @@ export async function registerWebPushSubscription(
payload: z.infer<typeof registerWebPushSubscriptionSchema>, payload: z.infer<typeof registerWebPushSubscriptionSchema>,
) { ) {
await ensureWebPushSubscriptionTable(); await ensureWebPushSubscriptionTable();
const appOrigin = normalizeAppOrigin(payload.appOrigin);
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
if (!payload.enabled) { if (!payload.enabled) {
await unregisterWebPushSubscription(payload.subscription.endpoint); await unregisterWebPushSubscription(payload.subscription.endpoint);
@@ -536,6 +658,8 @@ export async function registerWebPushSubscription(
subscription_json: payload.subscription, subscription_json: payload.subscription,
device_id: payload.deviceId ?? null, device_id: payload.deviceId ?? null,
user_agent: payload.userAgent ?? null, user_agent: payload.userAgent ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true, is_enabled: true,
last_registered_at: db.fn.now(), last_registered_at: db.fn.now(),
updated_at: db.fn.now(), updated_at: db.fn.now(),
@@ -545,6 +669,8 @@ export async function registerWebPushSubscription(
subscription_json: payload.subscription, subscription_json: payload.subscription,
device_id: payload.deviceId ?? null, device_id: payload.deviceId ?? null,
user_agent: payload.userAgent ?? null, user_agent: payload.userAgent ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true, is_enabled: true,
last_registered_at: db.fn.now(), last_registered_at: db.fn.now(),
updated_at: db.fn.now(), updated_at: db.fn.now(),
@@ -583,11 +709,13 @@ async function getEnabledIosTokens() {
platform: 'ios', platform: 'ios',
is_enabled: true, is_enabled: true,
}) })
.select('device_token', 'device_id'); .select('device_token', 'device_id', 'app_origin', 'app_domain');
return rows.map((row) => ({ return rows.map((row) => ({
token: String(row.device_token), token: String(row.device_token),
deviceId: row.device_id ? String(row.device_id) : '', deviceId: row.device_id ? String(row.device_id) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
})); }));
} }
@@ -598,12 +726,14 @@ async function getEnabledWebPushSubscriptions() {
.where({ .where({
is_enabled: true, is_enabled: true,
}) })
.select('endpoint', 'subscription_json', 'device_id'); .select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
return rows.map((row) => ({ return rows.map((row) => ({
endpoint: String(row.endpoint), endpoint: String(row.endpoint),
subscription: row.subscription_json as WebPushSubscriptionPayload, subscription: row.subscription_json as WebPushSubscriptionPayload,
deviceId: row.device_id ? String(row.device_id) : '', deviceId: row.device_id ? String(row.device_id) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
})); }));
} }
@@ -704,6 +834,9 @@ async function isNotificationRecipientAllowed(
export async function sendIosNotifications(payload: IosNotificationPayload) { export async function sendIosNotifications(payload: IosNotificationPayload) {
const env = getEnv(); const env = getEnv();
const provider = await getProvider(); const provider = await getProvider();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!provider || !env.APNS_BUNDLE_ID) { if (!provider || !env.APNS_BUNDLE_ID) {
return { return {
@@ -720,6 +853,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
await Promise.all( await Promise.all(
tokenRows.map(async (row) => ({ tokenRows.map(async (row) => ({
token: row.token, token: row.token,
deviceId: row.deviceId,
appOrigin: row.appOrigin,
appDomain: row.appDomain,
allowed: await isNotificationRecipientAllowed( allowed: await isNotificationRecipientAllowed(
[ [
{ kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) }, { kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) },
@@ -731,7 +867,12 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
})), })),
) )
) )
.filter((row) => row.allowed) .filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token); .map((row) => row.token);
if (!tokens.length) { if (!tokens.length) {
@@ -778,6 +919,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) { async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv(); const env = getEnv();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) { if (!ensureWebPushConfigured(env)) {
return { return {
ok: false, ok: false,
@@ -801,7 +945,12 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
), ),
})), })),
) )
).filter((row) => row.allowed); ).filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
if (!subscriptions.length) { if (!subscriptions.length) {
return { return {
@@ -880,19 +1029,73 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
}; };
} }
export async function sendNotifications(payload: IosNotificationPayload) { export async function sendNotifications(
payload: IosNotificationPayload,
options?: {
disableIos?: boolean;
disableWebPush?: boolean;
},
) {
const [ios, web] = await Promise.all([ const [ios, web] = await Promise.all([
sendIosNotifications(payload), options?.disableIos
sendWebPushNotifications(payload), ? Promise.resolve({
ok: true,
skipped: true,
reason: '요청 설정에 따라 iOS 알림 발송을 건너뛰었습니다.',
sentCount: 0,
failedCount: 0,
invalidTokens: [],
})
: sendIosNotifications(payload),
options?.disableWebPush
? Promise.resolve({
ok: true,
skipped: true,
reason: '요청 설정에 따라 Web Push 발송을 건너뛰었습니다.',
sentCount: 0,
failedCount: 0,
invalidEndpoints: [],
})
: sendWebPushNotifications(payload),
]); ]);
const aggregate = resolveNotificationAggregateResult(
{
ios,
web,
},
options,
);
return { return {
ok: ios.ok || web.ok, ok: aggregate.ok,
skipped: aggregate.skipped,
ios, ios,
web, web,
}; };
} }
export function resolveNotificationAggregateResult(
result: {
ios: { ok: boolean; skipped: boolean };
web: { ok: boolean; skipped: boolean };
},
options?: {
disableIos?: boolean;
disableWebPush?: boolean;
},
) {
const enabledResults = [
options?.disableIos ? null : result.ios,
options?.disableWebPush ? null : result.web,
].filter((entry): entry is { ok: boolean; skipped: boolean } => Boolean(entry));
return {
ok: enabledResults.length === 0 ? true : enabledResults.every((entry) => entry.ok),
skipped: enabledResults.length === 0 ? true : enabledResults.every((entry) => entry.skipped),
};
}
export async function shutdownNotificationProvider() { export async function shutdownNotificationProvider() {
const provider = await getProvider(); const provider = await getProvider();
provider?.shutdown(); provider?.shutdown();

View File

@@ -110,5 +110,7 @@ export async function notifyPlanEvent(
body, body,
threadId: `plan-${planId}`, threadId: `plan-${planId}`,
data: buildPlanNotificationData(planId, String(item.workId), eventType), data: buildPlanNotificationData(planId, String(item.workId), eventType),
}, {
disableWebPush: Boolean(item.suppressWebPush),
}); });
} }

View File

@@ -0,0 +1,247 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildScheduledBoardPostTitle,
buildScheduledPlanWorkIdBase,
isPlanScheduledTaskDue,
mapPlanScheduledTaskRow,
shouldCreatePlanForScheduleExecution,
updatePlanScheduledTaskSchema,
} from './plan-schedule-service.js';
test('buildScheduledBoardPostTitle prefers the first memo line when present', () => {
assert.equal(
buildScheduledBoardPostTitle({
work_id: '반복작업',
note: ' 첫 줄 제목 \n둘째 줄 설명',
}),
'첫 줄 제목',
);
});
test('buildScheduledBoardPostTitle falls back to normalized work id when memo is empty', () => {
assert.equal(
buildScheduledBoardPostTitle({
work_id: ' 작업 ID ',
note: ' \n ',
}),
'반복작업',
);
});
test('buildScheduledPlanWorkIdBase uses the schedule work id for automation registration', () => {
assert.equal(
buildScheduledPlanWorkIdBase({
work_id: ' 반복-정리 ',
}),
'반복-정리',
);
});
test('buildScheduledPlanWorkIdBase keeps managed-service runs on the schedule work id with schedule pk prefix', () => {
assert.equal(
buildScheduledPlanWorkIdBase({
id: 10,
work_id: ' 반복-정리 ',
execution_mode: 'managed-service',
}),
'schedule-10-반복-정리',
);
assert.equal(
buildScheduledPlanWorkIdBase({
id: 10,
work_id: '반복-정리-service',
execution_mode: 'managed-service',
}),
'schedule-10-반복-정리-service',
);
});
test('buildScheduledPlanWorkIdBase falls back when managed-service schedule id is missing', () => {
assert.equal(
buildScheduledPlanWorkIdBase({
work_id: '반복-정리-service',
execution_mode: 'managed-service',
}),
'반복-정리-service',
);
});
test('shouldCreatePlanForScheduleExecution only returns true for managed-service schedules', () => {
assert.equal(
shouldCreatePlanForScheduleExecution({
execution_mode: 'codex',
}),
false,
);
assert.equal(
shouldCreatePlanForScheduleExecution({
execution_mode: 'managed-service',
}),
true,
);
});
test('updatePlanScheduledTaskSchema keeps partial updates partial', () => {
assert.deepEqual(updatePlanScheduledTaskSchema.parse({ recreateManagedServiceOnNextSave: true }), {
recreateManagedServiceOnNextSave: true,
});
});
test('updatePlanScheduledTaskSchema accepts second-based interval updates', () => {
assert.deepEqual(
updatePlanScheduledTaskSchema.parse({
repeatIntervalValue: 10,
repeatIntervalUnit: 'second',
repeatIntervalSeconds: 10,
}),
{
repeatIntervalValue: 10,
repeatIntervalUnit: 'second',
repeatIntervalSeconds: 10,
},
);
});
test('interval schedule with start time waits until start time when immediate run is enabled', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: true,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T08:59:00+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: true,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T09:00:00+09:00'),
),
true,
);
});
test('interval schedule with start time waits one interval after the start when immediate run is disabled', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T09:59:00+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T10:00:00+09:00'),
),
true,
);
});
test('interval schedule supports second-based due calculation', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'second',
repeat_interval_value: 10,
repeat_interval_seconds: 10,
created_at: '2026-04-30T09:00:00+09:00',
},
new Date('2026-04-30T09:00:09+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'second',
repeat_interval_value: 10,
repeat_interval_seconds: 10,
created_at: '2026-04-30T09:00:00+09:00',
},
new Date('2026-04-30T09:00:10+09:00'),
),
true,
);
});
test('interval schedule uses repeat interval value and unit when stored seconds are stale', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'minute',
repeat_interval_value: 10,
repeat_interval_seconds: 3600,
created_at: '2026-04-30T09:50:00+09:00',
},
new Date('2026-04-30T09:59:59+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'minute',
repeat_interval_value: 10,
repeat_interval_seconds: 3600,
created_at: '2026-04-30T09:50:00+09:00',
},
new Date('2026-04-30T10:00:00+09:00'),
),
true,
);
});
test('mapPlanScheduledTaskRow normalizes stale stored repeat interval seconds', () => {
const mapped = mapPlanScheduledTaskRow({
id: 2,
work_id: 'stock-alert',
schedule_mode: 'interval',
repeat_interval_unit: 'minute',
repeat_interval_value: 10,
repeat_interval_seconds: 3600,
repeat_interval_minutes: 60,
});
assert.equal(mapped.repeatIntervalValue, 10);
assert.equal(mapped.repeatIntervalUnit, 'minute');
assert.equal(mapped.repeatIntervalSeconds, 600);
assert.equal(mapped.repeatIntervalMinutes, 10);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { getEnv } from '../config/env.js'; import { getEnv } from '../config/env.js';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import {
parseAutomationContextIds,
stringifyAutomationContextIds,
} from './automation-context-service.js';
import { import {
normalizeLegacyAutomationBehaviorType, normalizeLegacyAutomationBehaviorType,
resolveAutomationType, resolveAutomationType,
@@ -78,9 +82,11 @@ export const createPlanSchema = z.object({
workId: z.string().trim().optional().default('작업ID'), workId: z.string().trim().optional().default('작업ID'),
note: z.string().default(''), note: z.string().default(''),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')), automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')),
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
releaseTarget: z.string().trim().min(1).default('release'), releaseTarget: z.string().trim().min(1).default('release'),
jangsingProcessingRequired: z.boolean().default(true), jangsingProcessingRequired: z.boolean().default(true),
autoDeployToMain: z.boolean().default(true), autoDeployToMain: z.boolean().default(true),
suppressWebPush: z.boolean().default(false),
repeatRequestEnabled: z.boolean().default(false), repeatRequestEnabled: z.boolean().default(false),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60), repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60),
}); });
@@ -89,9 +95,11 @@ export const updatePlanSchema = z.object({
workId: z.string().trim().optional(), workId: z.string().trim().optional(),
note: z.string().optional(), note: z.string().optional(),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()), automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()),
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional(),
releaseTarget: z.string().trim().min(1).optional(), releaseTarget: z.string().trim().min(1).optional(),
jangsingProcessingRequired: z.boolean().optional(), jangsingProcessingRequired: z.boolean().optional(),
autoDeployToMain: z.boolean().optional(), autoDeployToMain: z.boolean().optional(),
suppressWebPush: z.boolean().optional(),
repeatRequestEnabled: z.boolean().optional(), repeatRequestEnabled: z.boolean().optional(),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(), repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(),
}); });
@@ -168,7 +176,7 @@ function sanitizeBranchToken(value: string) {
.slice(0, 48); .slice(0, 48);
} }
function normalizePlanWorkId(value?: string | null) { export function normalizePlanWorkId(value?: string | null) {
const workId = String(value ?? '').trim(); const workId = String(value ?? '').trim();
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
@@ -273,6 +281,7 @@ export function mapPlanRow(
note: options?.maskNote ? maskPlanNote(row.note) : row.note, note: options?.maskNote ? maskPlanNote(row.note) : row.note,
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type), automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
automationBehaviorType: normalizePlanAutomationType(row.automation_type), automationBehaviorType: normalizePlanAutomationType(row.automation_type),
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
releaseReviewNote: options?.releaseReviewNote ?? '', releaseReviewNote: options?.releaseReviewNote ?? '',
noteMasked: Boolean(options?.noteMasked), noteMasked: Boolean(options?.noteMasked),
status: row.status, status: row.status,
@@ -281,6 +290,7 @@ export function mapPlanRow(
? row.jangsing_processing_required ? row.jangsing_processing_required
: row.normal_processing_level === '상', : row.normal_processing_level === '상',
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
suppressWebPush: Boolean(row.suppress_web_push ?? false),
repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false), repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false),
repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60), repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60),
assignedBranch: row.assigned_branch, assignedBranch: row.assigned_branch,
@@ -819,9 +829,15 @@ async function syncPlanColumns() {
await ensureColumn('automation_type_id', (table) => { await ensureColumn('automation_type_id', (table) => {
table.string('automation_type_id', 120).nullable(); table.string('automation_type_id', 120).nullable();
}); });
await ensureColumn('automation_context_ids_json', (table) => {
table.text('automation_context_ids_json').notNullable().defaultTo('[]');
});
await ensureColumn('auto_deploy_to_main', (table) => { await ensureColumn('auto_deploy_to_main', (table) => {
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
}); });
await ensureColumn('suppress_web_push', (table) => {
table.boolean('suppress_web_push').notNullable().defaultTo(false);
});
await ensureColumn('repeat_request_enabled', (table) => { await ensureColumn('repeat_request_enabled', (table) => {
table.boolean('repeat_request_enabled').notNullable().defaultTo(false); table.boolean('repeat_request_enabled').notNullable().defaultTo(false);
}); });
@@ -1024,6 +1040,7 @@ export async function ensurePlanTable() {
table.string('status', 40).notNullable().defaultTo('등록'); table.string('status', 40).notNullable().defaultTo('등록');
table.boolean('jangsing_processing_required').notNullable().defaultTo(true); table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
table.boolean('suppress_web_push').notNullable().defaultTo(false);
table.string('assigned_branch', 200).nullable(); table.string('assigned_branch', 200).nullable();
table.string('release_target', 120).notNullable().defaultTo('release'); table.string('release_target', 120).notNullable().defaultTo('release');
table.string('worker_status', 80).nullable(); table.string('worker_status', 80).nullable();
@@ -1056,6 +1073,11 @@ export async function ensurePlanTable() {
.update({ .update({
jangsing_processing_required: true, jangsing_processing_required: true,
}); });
await db(PLAN_TABLE)
.whereNull('suppress_web_push')
.update({
suppress_web_push: false,
});
await db(PLAN_TABLE) await db(PLAN_TABLE)
.whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[]) .whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[])
.whereNull('completed_at') .whereNull('completed_at')
@@ -1098,9 +1120,11 @@ export async function createPlanItem(payload: z.infer<typeof createPlanSchema>)
status: '등록', status: '등록',
automation_type: automationType.behaviorType, automation_type: automationType.behaviorType,
automation_type_id: automationType.id, automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
release_target: payload.releaseTarget, release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired, jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain, auto_deploy_to_main: payload.autoDeployToMain,
suppress_web_push: payload.suppressWebPush,
repeat_request_enabled: payload.repeatRequestEnabled, repeat_request_enabled: payload.repeatRequestEnabled,
repeat_interval_minutes: payload.repeatIntervalMinutes, repeat_interval_minutes: payload.repeatIntervalMinutes,
worker_status: '대기', worker_status: '대기',
@@ -1124,9 +1148,11 @@ export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeo
status: '완료', status: '완료',
automation_type: automationType.behaviorType, automation_type: automationType.behaviorType,
automation_type_id: automationType.id, automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
release_target: payload.releaseTarget, release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired, jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain, auto_deploy_to_main: payload.autoDeployToMain,
suppress_web_push: payload.suppressWebPush,
repeat_request_enabled: payload.repeatRequestEnabled, repeat_request_enabled: payload.repeatRequestEnabled,
repeat_interval_minutes: payload.repeatIntervalMinutes, repeat_interval_minutes: payload.repeatIntervalMinutes,
worker_status: '자동완료', worker_status: '자동완료',
@@ -1150,7 +1176,9 @@ export async function upsertAutoPlanItem(args: {
releaseTarget: string; releaseTarget: string;
jangsingProcessingRequired: boolean; jangsingProcessingRequired: boolean;
autoDeployToMain: boolean; autoDeployToMain: boolean;
suppressWebPush?: boolean;
automationType?: PlanAutomationType; automationType?: PlanAutomationType;
automationContextIds?: string[];
requeue: boolean; requeue: boolean;
}) { }) {
await ensurePlanTable(); await ensurePlanTable();
@@ -1168,9 +1196,11 @@ export async function upsertAutoPlanItem(args: {
workId, workId,
note: args.note, note: args.note,
automationType: automationType.id, automationType: automationType.id,
automationContextIds: args.automationContextIds ?? [],
releaseTarget: args.releaseTarget, releaseTarget: args.releaseTarget,
jangsingProcessingRequired: args.jangsingProcessingRequired, jangsingProcessingRequired: args.jangsingProcessingRequired,
autoDeployToMain: args.autoDeployToMain, autoDeployToMain: args.autoDeployToMain,
suppressWebPush: args.suppressWebPush ?? false,
repeatRequestEnabled: false, repeatRequestEnabled: false,
repeatIntervalMinutes: 60, repeatIntervalMinutes: 60,
}), }),
@@ -1182,7 +1212,9 @@ export async function upsertAutoPlanItem(args: {
const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type); const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type);
const nextJangsingProcessingRequired = args.jangsingProcessingRequired; const nextJangsingProcessingRequired = args.jangsingProcessingRequired;
const nextAutoDeployToMain = args.autoDeployToMain; const nextAutoDeployToMain = args.autoDeployToMain;
const nextSuppressWebPush = args.suppressWebPush ?? Boolean(existingRow.suppress_web_push ?? false);
const nextNote = args.note; const nextNote = args.note;
const nextAutomationContextIds = args.automationContextIds ?? parseAutomationContextIds(existingRow.automation_context_ids_json);
const currentJangsingProcessingRequired = const currentJangsingProcessingRequired =
typeof existingRow.jangsing_processing_required === 'boolean' typeof existingRow.jangsing_processing_required === 'boolean'
? existingRow.jangsing_processing_required ? existingRow.jangsing_processing_required
@@ -1192,6 +1224,8 @@ export async function upsertAutoPlanItem(args: {
String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id || String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
(existingRow.release_target ?? 'release') !== nextReleaseTarget || (existingRow.release_target ?? 'release') !== nextReleaseTarget ||
Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain || Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain ||
Boolean(existingRow.suppress_web_push ?? false) !== nextSuppressWebPush ||
JSON.stringify(parseAutomationContextIds(existingRow.automation_context_ids_json)) !== JSON.stringify(nextAutomationContextIds) ||
Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired; Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired;
if (!args.requeue) { if (!args.requeue) {
@@ -1208,9 +1242,11 @@ export async function upsertAutoPlanItem(args: {
note: nextNote, note: nextNote,
automation_type: nextAutomationType.behaviorType, automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id, automation_type_id: nextAutomationType.id,
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
release_target: nextReleaseTarget, release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired, jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain, auto_deploy_to_main: nextAutoDeployToMain,
suppress_web_push: nextSuppressWebPush,
updated_at: db.fn.now(), updated_at: db.fn.now(),
}) })
.returning('*'); .returning('*');
@@ -1230,9 +1266,11 @@ export async function upsertAutoPlanItem(args: {
assigned_branch: null, assigned_branch: null,
automation_type: nextAutomationType.behaviorType, automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id, automation_type_id: nextAutomationType.id,
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
release_target: nextReleaseTarget, release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired, jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain, auto_deploy_to_main: nextAutoDeployToMain,
suppress_web_push: nextSuppressWebPush,
worker_status: '대기', worker_status: '대기',
last_error: null, last_error: null,
locked_by: null, locked_by: null,
@@ -1278,17 +1316,22 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
? currentRow.jangsing_processing_required ? currentRow.jangsing_processing_required
: currentRow.normal_processing_level === '상'); : currentRow.normal_processing_level === '상');
const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true; const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true;
const nextSuppressWebPush = payload.suppressWebPush ?? currentRow.suppress_web_push ?? false;
const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false; const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false;
const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60; const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60;
const nextNote = payload.note ?? currentRow.note; const nextNote = payload.note ?? currentRow.note;
const nextAutomationContextIds =
payload.automationContextIds ?? parseAutomationContextIds(currentRow.automation_context_ids_json);
const isOnlyJangsingUpdate = const isOnlyJangsingUpdate =
nextWorkId === currentRow.work_id && nextWorkId === currentRow.work_id &&
nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') && nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
nextReleaseTarget === (currentRow.release_target ?? 'release') && nextReleaseTarget === (currentRow.release_target ?? 'release') &&
nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) && nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
nextSuppressWebPush === (currentRow.suppress_web_push ?? false) &&
nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) && nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) && nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) &&
JSON.stringify(parseAutomationContextIds(currentRow.automation_context_ids_json)) === JSON.stringify(nextAutomationContextIds) &&
nextNote === currentRow.note; nextNote === currentRow.note;
if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) { if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) {
@@ -1307,9 +1350,11 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
status: currentRow.status, status: currentRow.status,
automation_type: nextAutomationType.behaviorType, automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id, automation_type_id: nextAutomationType.id,
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
release_target: nextReleaseTarget, release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired, jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain, auto_deploy_to_main: nextAutoDeployToMain,
suppress_web_push: nextSuppressWebPush,
repeat_request_enabled: nextRepeatRequestEnabled, repeat_request_enabled: nextRepeatRequestEnabled,
repeat_interval_minutes: nextRepeatIntervalMinutes, repeat_interval_minutes: nextRepeatIntervalMinutes,
worker_status: currentRow.worker_status, worker_status: currentRow.worker_status,

View File

@@ -1,8 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { writeFile } from 'node:fs/promises'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { import {
buildHealthCheckUrls, buildHealthCheckUrls,
@@ -88,6 +90,7 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
workServerScript, workServerScript,
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/, /docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
); );
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/); assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
}); });
@@ -271,3 +274,65 @@ test('listServerCommands marks command-runner online when localhost fallback res
assert.equal(runnerCommand.httpStatus, 200); assert.equal(runnerCommand.httpStatus, 200);
assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/); assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/);
}); });
test('listServerCommands ignores public codex chat resources when checking app source updates', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-app-source-scan-'));
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
const buildTargetPath = '/tmp/ai-code-test-app-dist/index.html';
const previousBuildContents = fs.existsSync(buildTargetPath) ? await fs.promises.readFile(buildTargetPath) : null;
const previousBuildStat = fs.existsSync(buildTargetPath) ? await fs.promises.stat(buildTargetPath) : null;
try {
const staleDate = new Date('2026-04-19T00:00:00.000Z');
await mkdir(path.join(tempRoot, 'src'), { recursive: true });
await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.app.json'), '{}\n', 'utf8');
await writeFile(path.join(tempRoot, 'vite.config.ts'), 'export default {};\n', 'utf8');
await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
await Promise.all([
fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.app.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'vite.config.ts'), staleDate, staleDate),
]);
await mkdir(path.dirname(buildTargetPath), { recursive: true });
await writeFile(buildTargetPath, '<!doctype html>\n', 'utf8');
const buildDate = new Date('2026-04-20T00:00:00.000Z');
await fs.promises.utimes(buildTargetPath, buildDate, buildDate);
const resourceDate = new Date('2026-04-28T00:00:00.000Z');
await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
const commands = await listServerCommands();
const testCommand = commands.find((item) => item.key === 'test');
assert.ok(testCommand);
assert.equal(testCommand.buildRequired, false);
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
if (previousBuildContents) {
await writeFile(buildTargetPath, previousBuildContents, 'utf8');
if (previousBuildStat) {
await fs.promises.utimes(buildTargetPath, previousBuildStat.atime, previousBuildStat.mtime);
}
} else {
await rm(buildTargetPath, { force: true });
}
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -134,6 +134,7 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
'/tmp/ai-code-test-app-dist/manifest.webmanifest', '/tmp/ai-code-test-app-dist/manifest.webmanifest',
'/tmp/ai-code-test-app-dist/assets', '/tmp/ai-code-test-app-dist/assets',
] as const; ] as const;
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) { export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
const allowLocal = options?.allowLocal ?? false; const allowLocal = options?.allowLocal ?? false;
@@ -191,8 +192,17 @@ type SourceChangeInfo = {
path: string; path: string;
}; };
function isExcludedAppSourcePath(rootPath: string, targetPath: string) {
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
return APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
}
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> { async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
try { try {
if (isExcludedAppSourcePath(rootPath, targetPath)) {
return null;
}
const targetStat = await stat(targetPath); const targetStat = await stat(targetPath);
if (targetStat.isFile()) { if (targetStat.isFile()) {

View File

@@ -0,0 +1,382 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildChangeRateThresholdStockAlertLines,
buildCurrentPriceStockAlertLines,
buildStockAlertNotificationIdentity,
resolveLatestQuoteFromMeta,
resolveLatestQuoteFromNaverRealtime,
type StockAlertItem,
} from './stock-alert-service.js';
test('resolveLatestQuoteFromMeta prefers post-market quote outside regular session', () => {
const quote = resolveLatestQuoteFromMeta({
regularMarketPrice: 210000,
regularMarketTime: 1714377600,
regularMarketChangePercent: 1.23,
previousClose: 207450,
postMarketPrice: 211500,
postMarketTime: 1714381200,
postMarketChangePercent: 1.95,
marketState: 'POST',
shortName: '삼성전자',
});
assert.equal(quote.currentPrice, 211500);
assert.equal(quote.changeRate, 1.95);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2024-04-29T09:00:00.000Z');
});
test('buildCurrentPriceStockAlertLines formats current-price stock alert lines', () => {
const items: StockAlertItem[] = [
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 210000,
changeRate: 1.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '000660',
stockCode: '000660',
stockName: 'SK하이닉스',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 198000,
changeRate: -2.34,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildCurrentPriceStockAlertLines(items), [
'삼성전자 210,000₩ (+1.23% ▲)',
'SK하이닉스 198,000₩ (-2.34% ▼)',
]);
});
test('buildCurrentPriceStockAlertLines skips rows without resolved current-price quote', () => {
const items: StockAlertItem[] = [
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 210000,
changeRate: 1.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '035420',
stockCode: '035420',
stockName: 'NAVER',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildCurrentPriceStockAlertLines(items), ['삼성전자 210,000₩ (+1.23% ▲)']);
});
test('buildCurrentPriceStockAlertLines excludes stocks not registered for current-price alerts', () => {
const items: StockAlertItem[] = [
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price', 'top3'],
alertTypeLabels: ['현재가', '등락폭이 큰 상위3종목'],
currentPrice: 210000,
changeRate: 1.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '035420',
stockCode: '035420',
stockName: 'NAVER',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 230000,
changeRate: 3.45,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildCurrentPriceStockAlertLines(items), ['삼성전자 210,000₩ (+1.23% ▲)']);
});
test('buildChangeRateThresholdStockAlertLines includes every registered stock that crosses the threshold and sorts by absolute rate', () => {
const items: StockAlertItem[] = [
{
id: '290550',
stockCode: '290550',
stockName: '디케이티',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 26500,
changeRate: 11.11,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 210000,
changeRate: 6.1,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '066570',
stockCode: '066570',
stockName: 'LG전자',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 137400,
changeRate: -1.86,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '035420',
stockCode: '035420',
stockName: 'NAVER',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 230000,
changeRate: -7.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildChangeRateThresholdStockAlertLines(items, 5), [
'디케이티 26,500₩ (+11.11% ▲)',
'NAVER 230,000₩ (-7.23% ▼)',
'삼성전자 210,000₩ (+6.10% ▲)',
]);
});
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {
assert.deepEqual(
buildStockAlertNotificationIdentity({
scheduleId: 2,
serviceKey: 'schedule-2-stock-alert-service',
mode: 'price',
}),
{
threadId: 'schedule-stock-alert:2',
notificationKey: 'schedule-stock-alert:2',
notificationScope: 'schedule-stock-alert:2',
notificationAliases: [
'schedule-2-stock-alert-service',
'schedule-2-stock-alert-service:current-price',
'schedule-2-stock-alert',
'schedule-2-stock-alert:current-price',
],
},
);
assert.deepEqual(
buildStockAlertNotificationIdentity({
scheduleId: 10,
serviceKey: 'schedule-10-stock-high-service',
mode: 'change-threshold',
}),
{
threadId: 'schedule-stock-alert:10',
notificationKey: 'schedule-stock-alert:10',
notificationScope: 'schedule-stock-alert:10',
notificationAliases: [
'schedule-10-stock-high-service',
'schedule-10-stock-high-service:current-price',
'schedule-10-stock-high-service:change-threshold',
],
},
);
});
test('resolveLatestQuoteFromNaverRealtime prefers extended-hours quote when available', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
nxtOverMarketPriceInfo: {
tradingSessionType: 'AFTER_MARKET',
overMarketStatus: 'OPEN',
overPrice: '225,500',
fluctuationsRatio: '1.58',
localTradedAt: '2026-04-29T19:05:12.465411+09:00',
},
},
1777457112465,
);
assert.equal(quote.currentPrice, 225500);
assert.equal(quote.changeRate, 1.58);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-29T19:05:12.465411+09:00');
});
test('resolveLatestQuoteFromNaverRealtime keeps after-hours quote even after session close', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '066570',
nm: 'LG전자',
nv: 135800,
cr: 3,
nxtOverMarketPriceInfo: {
tradingSessionType: 'AFTER_MARKET',
overMarketStatus: 'CLOSE',
overPrice: '137,400',
compareToPreviousClosePrice: '-2,600',
fluctuationsRatio: '-1.86',
localTradedAt: '2026-04-29T20:00:00.000000+09:00',
},
},
1777461821319,
);
assert.equal(quote.currentPrice, 137400);
assert.equal(quote.changeRate, -1.86);
assert.equal(quote.stockName, 'LG전자');
assert.equal(quote.quotedAt, '2026-04-29T20:00:00.000000+09:00');
});
test('resolveLatestQuoteFromNaverRealtime resets domestic quote to previous close from 5AM before premarket starts', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
ms: 'CLOSE',
pcv: 222000,
},
'2026-04-30T05:30:00+09:00',
);
assert.equal(quote.currentPrice, 222000);
assert.equal(quote.changeRate, 0);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-29T20:30:00.000Z');
});
test('resolveLatestQuoteFromNaverRealtime keeps before-market quote when available in the morning reset window', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
ms: 'CLOSE',
pcv: 222000,
nxtOverMarketPriceInfo: {
tradingSessionType: 'BEFORE_MARKET',
overMarketStatus: 'OPEN',
overPrice: '223,500',
fluctuationsRatio: '0.68',
compareToPreviousClosePrice: '1,500',
localTradedAt: '2026-04-30T08:10:00+09:00',
},
},
'2026-04-30T08:10:05+09:00',
);
assert.equal(quote.currentPrice, 223500);
assert.equal(quote.changeRate, 0.68);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-30T08:10:00+09:00');
});
test('resolveLatestQuoteFromNaverRealtime restores negative sign when ratio arrives unsigned', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '066570',
nm: 'LG전자',
nv: 135800,
cr: 3,
nxtOverMarketPriceInfo: {
tradingSessionType: 'AFTER_MARKET',
overMarketStatus: 'CLOSE',
overPrice: '137,400',
compareToPreviousClosePrice: '-2,600',
fluctuationsRatio: '1.86',
compareToPreviousPrice: {
code: '5',
name: 'FALLING',
},
localTradedAt: '2026-04-29T20:00:00.000000+09:00',
},
},
1777461821319,
);
assert.equal(quote.currentPrice, 137400);
assert.equal(quote.changeRate, -1.86);
assert.equal(quote.stockName, 'LG전자');
});
test('resolveLatestQuoteFromNaverRealtime falls back to regular quote when extended-hours quote is unavailable', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
},
1777457112465,
);
assert.equal(quote.currentPrice, 226000);
assert.equal(quote.changeRate, 1.8);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-29T10:05:12.465Z');
});
test('resolveLatestQuoteFromNaverRealtime restores negative sign for regular-session quotes from rf direction', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '024950',
nm: '삼천리자전거',
nv: 5130,
cr: 2.47,
rf: '5',
pcv: 5260,
},
'2026-04-30T14:13:05+09:00',
);
assert.equal(quote.currentPrice, 5130);
assert.equal(quote.changeRate, -2.47);
assert.equal(quote.stockName, '삼천리자전거');
assert.equal(quote.quotedAt, '2026-04-30T05:13:05.000Z');
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { z } from 'zod';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
const TEXT_MEMO_TABLE = 'text_memo_notes'; const TEXT_MEMO_TABLE = 'text_memo_notes';
const MEMO_LAYOUT_NAME = '메모';
const MAX_NOTE_COUNT = 12; const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200; const MAX_BODY_LENGTH = 1200;
const CLIENT_ID_MAX_LENGTH = 120; const CLIENT_ID_MAX_LENGTH = 120;
@@ -284,3 +285,103 @@ export async function importTextMemoNotes(
return listTextMemoNotes(clientId); return listTextMemoNotes(clientId);
} }
export async function updateTextMemoLayoutFeatureDescription() {
const layoutRecord = await db('play_layouts').select('id', 'tree').where({ name: MEMO_LAYOUT_NAME }).first();
if (!layoutRecord || typeof layoutRecord !== 'object') {
return false;
}
const tree = layoutRecord.tree as
| {
root?: unknown;
interactions?: Array<Record<string, unknown>>;
interactionMode?: string;
}
| null;
if (!tree || typeof tree !== 'object') {
return false;
}
const nextInteractions = [
{
id: 'memo-sync-first-line',
sourceLeafId: 'layout-node-2',
targetLeafId: 'layout-node-1',
sourceComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
targetComponent: {
label: 'Base Input · Base',
optionId: 'component:input:input-base',
keywords: ['input', 'base'],
},
title: '메모 첫 줄 연결',
description: '선택한 메모와 작성 중 본문의 첫 줄을 Primary Pane Base Input 값으로 동기화합니다.',
implementationNotes:
'메모 선택, 본문 입력, 저장 완료 시 첫 줄을 추출해 Base Input에 반영합니다. 빈 본문이면 빈 문자열을 유지합니다.',
},
{
id: 'memo-title-commit',
sourceLeafId: 'layout-node-1',
targetLeafId: 'layout-node-2',
sourceComponent: {
label: 'Base Input · Base',
optionId: 'component:input:input-base',
keywords: ['input', 'base'],
},
targetComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
title: '제목 확정 반영',
description: 'Primary Pane Base Input에서 Enter 또는 blur로 확정한 값을 메모 첫 줄로 반영합니다.',
implementationNotes:
'InputUI 확정 이벤트를 사용해 현재 편집 중 본문의 첫 줄만 교체하고 나머지 줄바꿈/본문은 유지합니다.',
},
{
id: 'memo-crud-api',
sourceLeafId: 'layout-node-2',
targetLeafId: 'layout-node-2',
sourceComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
targetComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
title: '메모 저장/조회 API',
description: '메모 목록 조회와 저장, 수정, 삭제를 text-memo API로 처리합니다.',
implementationNotes:
'GET /api/text-memo/notes, POST /api/text-memo/notes, PUT /api/text-memo/notes/:noteId, DELETE /api/text-memo/notes/:noteId 를 사용합니다. clientId 헤더 기준으로 사용자별 최근 12개 메모를 유지합니다.',
},
];
const previousInteractions = Array.isArray(tree.interactions) ? JSON.stringify(tree.interactions) : '';
const nextInteractionsJson = JSON.stringify(nextInteractions);
const nextInteractionMode = 'scoped-v2';
if (previousInteractions === nextInteractionsJson && tree.interactionMode === nextInteractionMode) {
return false;
}
await db('play_layouts')
.where({ id: layoutRecord.id as string })
.update({
tree: {
...tree,
interactions: nextInteractions,
interactionMode: nextInteractionMode,
},
});
return true;
}

View File

@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { summarizeFailureOutput } from './plan-worker.js';
test('summarizeFailureOutput ignores structured zero-failure diff lines', () => {
const output = [
'```diff',
'+ failedCount: 0,',
'+ webFailed=0',
'+ iosFailed=0,',
'```',
'actual failure: service file missing',
].join('\n');
assert.equal(summarizeFailureOutput(output, 'fallback'), 'actual failure: service file missing');
});
test('summarizeFailureOutput falls back when output only contains zero-failure noise', () => {
const output = [
'```diff',
'+ failedCount: 0,',
'+ webFailed=0',
'+ failedCount: number;',
'```',
].join('\n');
assert.equal(summarizeFailureOutput(output, 'fallback failure'), 'fallback failure');
});
test('summarizeFailureOutput ignores managed service success stub lines', () => {
const output = [
"return Promise.resolve({ ok: true, skipped: false, title: definition.title, body: '', itemCount: 0, lines: [], ios: { ok: true, skipped: true, sentCount: 0, failedCount: 0 }, web: { ok: true, skipped: true, sentCount: 0, failedCount: 0 }, definition });",
].join('\n');
assert.equal(summarizeFailureOutput(output, 'fallback success'), 'fallback success');
});

View File

@@ -21,6 +21,7 @@ import {
claimNextPlanForMerge, claimNextPlanForMerge,
claimNextPlanForMainMerge, claimNextPlanForMainMerge,
formatPlanNotificationLabel, formatPlanNotificationLabel,
getPlanItemById,
isPlanLockedByWorker, isPlanLockedByWorker,
mapPlanRow, mapPlanRow,
markPlanAsCompleted, markPlanAsCompleted,
@@ -50,9 +51,11 @@ const REPEATED_PROGRESS_NOTIFICATION_MS = 180_000;
const STARTED_REQUEST_SUMMARY_LIMIT = 72; const STARTED_REQUEST_SUMMARY_LIMIT = 72;
const ERROR_SUMMARY_MAX_LENGTH = 500; const ERROR_SUMMARY_MAX_LENGTH = 500;
const ERROR_SUMMARY_LINE_PATTERN = const ERROR_SUMMARY_LINE_PATTERN =
/(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i; /(ERROR:|\bfailed\b|\bfailure\b|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
const ERROR_SUMMARY_NOISE_PATTERN = const ERROR_SUMMARY_NOISE_PATTERN =
/^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i; /^(exec|codex|tokens used|succeeded in \d+ms:?|```|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
const ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN =
/^(?:(?:[+\-]\s*)?(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=)\s*(?:\[\s*\]|0)\s*[,;]?|[+\-]\s*(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=).+|return\s+Promise\.resolve\(\{\s*ok:\s*true,\s*skipped:\s*(?:true|false),[\s\S]*failedCount:\s*0[\s\S]*\}\);?)$/i;
const PLAN_CODEX_RUNNER_MAX_ATTEMPTS = 2; const PLAN_CODEX_RUNNER_MAX_ATTEMPTS = 2;
const PLAN_CODEX_RUNNER_RETRY_DELAY_MS = 5000; const PLAN_CODEX_RUNNER_RETRY_DELAY_MS = 5000;
const PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN = const PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN =
@@ -133,12 +136,13 @@ function normalizeErrorSummaryLine(line: string) {
.trim(); .trim();
} }
function summarizeFailureOutput(output: string, fallback: string) { export function summarizeFailureOutput(output: string, fallback: string) {
const normalizedLines = output const normalizedLines = output
.split('\n') .split('\n')
.map((line) => normalizeErrorSummaryLine(line)) .map((line) => normalizeErrorSummaryLine(line))
.filter(Boolean) .filter(Boolean)
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line)); .filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line))
.filter((line) => !ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN.test(line));
const bestLine = const bestLine =
normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ?? normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
@@ -288,11 +292,16 @@ export class PlanWorker {
return; return;
} }
const item = await getPlanItemById(planId);
const disableWebPush = Boolean(item?.suppressWebPush);
const result = await sendNotifications({ const result = await sendNotifications({
title, title,
body, body,
threadId: `plan-${planId}`, threadId: `plan-${planId}`,
data: buildPlanNotificationData(planId, workId, eventType), data: buildPlanNotificationData(planId, workId, eventType),
}, {
disableWebPush,
}); });
this.logger.info( this.logger.info(
@@ -300,6 +309,7 @@ export class PlanWorker {
planId, planId,
workId, workId,
eventType, eventType,
disableWebPush,
ios: { ios: {
skipped: result.ios.skipped, skipped: result.ios.skipped,
sentCount: result.ios.sentCount, sentCount: result.ios.sentCount,

70
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "0.1.0-alpha.0", "version": "0.1.0-alpha.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.1", "@ant-design/icons": "^6.0.1",
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"antd": "^5.27.0", "antd": "^5.27.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -2595,6 +2597,35 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/ag-charts-types": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.2.1.tgz",
"integrity": "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "35.2.1",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.2.1.tgz",
"integrity": "sha512-ycmGI+1EbUT7i3eg/Kgi1owwnkdHXRufo10Xm6cfSsVPM3TMpvlbLgi28KIPt9DGHZWHq9fOBn7nxMNdv1Yaow==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "13.2.1"
}
},
"node_modules/ag-grid-react": {
"version": "35.2.1",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.2.1.tgz",
"integrity": "sha512-UzdU15R6fyGJB+lBKEC458xacGoZged3Ra6Plqa7LvrJ/Mg0tWn1NH01UnuKyGEKPWMEAGvdXruOtOUywsPElA==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "35.2.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -4502,7 +4533,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@@ -4862,6 +4892,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4951,6 +4993,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5183,6 +5234,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -50,6 +50,8 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.1", "@ant-design/icons": "^6.0.1",
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"antd": "^5.27.0", "antd": "^5.27.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",

View File

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
<defs>
<linearGradient id="bg" x1="0%" x2="100%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#165dff" />
<stop offset="100%" stop-color="#28c76f" />
</linearGradient>
</defs>
<rect width="180" height="180" rx="44" fill="url(#bg)" />
<path
d="M54 54h72v18H72v30h48v18H72v6h54v18H54z"
fill="#fff"
/>
</svg>

Before

Width:  |  Height:  |  Size: 411 B

View File

@@ -26,11 +26,29 @@ const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024;
const ERROR_SUMMARY_MAX_LENGTH = 500; const ERROR_SUMMARY_MAX_LENGTH = 500;
const SOURCE_SNAPSHOT_TEXT_PATTERN = const SOURCE_SNAPSHOT_TEXT_PATTERN =
/\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i; /\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i;
const NAVER_STOCK_ITEM_URL = 'https://finance.naver.com/item/main.naver?code=';
const DEFAULT_STOCK_ALERT_TITLE = '현재 주가';
const STOCK_ALERT_COMPANIES = [
{
code: '005930',
labels: ['삼성전자'],
pattern: /삼성전자/i,
},
{
code: '000660',
labels: ['SK하이닉스', '하이닉스'],
pattern: /(?:sk\s*)?하이닉스|hynix/i,
},
];
const STOCK_ALERT_REGISTERED_CURRENT_PRICE_PATTERN =
/stock알림|현재가로 등록된 종목|등록된 알림유형이 현재가|현재가 알림.*등록된 종목/i;
const ERROR_SUMMARY_LINE_PATTERN = const ERROR_SUMMARY_LINE_PATTERN =
/(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i; /(ERROR:|\bfailed\b|\bfailure\b|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
const ERROR_SUMMARY_NOISE_PATTERN = const ERROR_SUMMARY_NOISE_PATTERN =
/^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i; /^(exec|codex|tokens used|succeeded in \d+ms:?|```|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
const ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN =
/^(?:(?:[+\-]\s*)?(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=)\s*(?:\[\s*\]|0)\s*[,;]?|[+\-]\s*(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=).+|return\s+Promise\.resolve\(\{\s*ok:\s*true,\s*skipped:\s*(?:true|false),[\s\S]*failedCount:\s*0[\s\S]*\}\);?)$/i;
const CODEX_EXEC_MAX_ATTEMPTS = 2; const CODEX_EXEC_MAX_ATTEMPTS = 2;
const CODEX_EXEC_RETRY_DELAY_MS = 3000; const CODEX_EXEC_RETRY_DELAY_MS = 3000;
const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6; const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6;
@@ -47,6 +65,7 @@ const CODEX_HOME_RUNTIME_PATHS = [
'models_cache.json', 'models_cache.json',
'version.json', 'version.json',
]; ];
const ZERO_TOKEN_USAGE_TEXT = 'total 0 input 0 output 0 cached 0 reasoning 0';
function parseCodexTokenUsage(output) { function parseCodexTokenUsage(output) {
const text = stripAnsi(String(output ?? '')); const text = stripAnsi(String(output ?? ''));
@@ -99,6 +118,11 @@ function parseCodexTokenUsage(output) {
.trim(); .trim();
} }
function buildTokenUsageLine(tokenUsage) {
const normalized = String(tokenUsage ?? '').trim();
return `토큰 사용량: ${normalized || ZERO_TOKEN_USAGE_TEXT}`;
}
function reportProgress(message) { function reportProgress(message) {
const text = String(message ?? '').replace(/\s+/g, ' ').trim(); const text = String(message ?? '').replace(/\s+/g, ' ').trim();
@@ -506,7 +530,8 @@ function summarizeFailureOutput(output, fallback) {
.split('\n') .split('\n')
.map((line) => normalizeErrorSummaryLine(line)) .map((line) => normalizeErrorSummaryLine(line))
.filter(Boolean) .filter(Boolean)
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line)); .filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line))
.filter((line) => !ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN.test(line));
const bestLine = const bestLine =
normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ?? normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
@@ -577,6 +602,18 @@ function summarizeRequest(note, limit = PROMPT_REQUEST_SUMMARY_LIMIT) {
return `${text.slice(0, Math.max(0, limit - 1)).trim()}`; return `${text.slice(0, Math.max(0, limit - 1)).trim()}`;
} }
function truncateInlineText(value, maxLength = 400) {
const normalized = String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) {
return '';
}
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function escapeTemplateValue(value) { function escapeTemplateValue(value) {
return encodeURIComponent(String(value ?? '').trim()); return encodeURIComponent(String(value ?? '').trim());
} }
@@ -636,7 +673,7 @@ function formatRecentActionHistories(histories) {
.slice(0, 5) .slice(0, 5)
.map( .map(
(history, index) => (history, index) =>
`${index + 1}. [${history.actionType}] ${history.createdAt}\n${String(history.note ?? '').trim()}`, `${index + 1}. [${history.actionType}] ${history.createdAt}\n${truncateInlineText(history.note, 600)}`,
) )
.join('\n\n'); .join('\n\n');
} }
@@ -649,12 +686,297 @@ function formatRecentIssueHistories(histories) {
return histories return histories
.slice(0, 5) .slice(0, 5)
.map((history, index) => { .map((history, index) => {
const actionNote = history.actionNote ? `\n조치이력:\n${String(history.actionNote).trim()}` : ''; const actionNote = history.actionNote ? `\n조치이력:\n${truncateInlineText(history.actionNote, 500)}` : '';
return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`; return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${truncateInlineText(history.message, 500)}${actionNote}`;
}) })
.join('\n\n'); .join('\n\n');
} }
function isStockAlertRequest(note) {
const text = extractPrimaryRequestText(note);
const isExplicitCompanyRequest = /주가/.test(text) && STOCK_ALERT_COMPANIES.some((item) => item.pattern.test(text));
return isExplicitCompanyRequest || isRegisteredCurrentPriceStockAlertRequest(text);
}
function isRegisteredCurrentPriceStockAlertRequest(noteOrText) {
const text = extractPrimaryRequestText(noteOrText);
return STOCK_ALERT_REGISTERED_CURRENT_PRICE_PATTERN.test(text);
}
function extractPrimaryRequestText(note) {
const text = String(note ?? '');
const requestBodyMatch = text.match(/## 요청 본문\s*([\s\S]*?)(?:\n##\s+|\s*$)/);
return (requestBodyMatch?.[1] ?? text).trim();
}
function extractTargetAppDomains(note) {
const text = extractPrimaryRequestText(note);
const matches = text.match(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi) ?? [];
return [...new Set(matches.map((value) => value.trim().toLowerCase()).filter(Boolean))];
}
function resolveRequestedStockCompanies(note) {
const text = extractPrimaryRequestText(note);
return STOCK_ALERT_COMPANIES.filter((item) => item.pattern.test(text));
}
function stripHtmlTags(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function parseQuotedNumber(value) {
const normalized = String(value ?? '').replace(/[^\d.-]/g, '');
return normalized ? Number(normalized) : NaN;
}
async function fetchNaverStockPage(code) {
const response = await fetch(`${NAVER_STOCK_ITEM_URL}${code}`, {
headers: {
'user-agent': 'Mozilla/5.0 (compatible; ai-code-app/1.0; +https://test.sm-home.cloud/)',
accept: 'text/html,application/xhtml+xml',
'accept-language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
},
});
if (!response.ok) {
throw new Error(`주가 페이지를 불러오지 못했습니다. code=${code}, status=${response.status}`);
}
return response.text();
}
function parseNaverStockQuote(html, code) {
const infoMatch = html.match(
new RegExp(
`<dd>\\s*종목명\\s*([^<]+?)\\s*</dd>[\\s\\S]*?<dd>\\s*종목코드\\s*${code}[^<]*</dd>[\\s\\S]*?<dd>\\s*현재가\\s*([\\d,]+)\\s*전일대비\\s*(상승|하락|보합)\\s*([\\d,]+)?[\\s\\S]*?([\\d.]+)\\s*퍼센트`,
'i',
),
);
const timeMatch = html.match(/<em class="date">\s*([^<]+?)\s*<span>([^<]+)<\/span>\s*<\/em>/i);
if (!infoMatch) {
throw new Error(`주가 페이지 파싱에 실패했습니다. code=${code}`);
}
const name = stripHtmlTags(infoMatch[1]);
const priceText = String(infoMatch[2] ?? '').trim();
const direction = String(infoMatch[3] ?? '').trim();
const changeText = String(infoMatch[4] ?? '0').trim() || '0';
const percentText = String(infoMatch[5] ?? '0').trim() || '0';
const price = parseQuotedNumber(priceText);
const change = parseQuotedNumber(changeText);
const changePercent = Number(percentText);
if (!Number.isFinite(price) || !Number.isFinite(change) || !Number.isFinite(changePercent)) {
throw new Error(`주가 숫자 파싱에 실패했습니다. code=${code}`);
}
return {
code,
name,
price,
priceText,
direction,
change,
changeText,
changePercent,
changePercentText: Number(changePercent).toFixed(2),
quotedAt: timeMatch ? `${stripHtmlTags(timeMatch[1])} ${stripHtmlTags(timeMatch[2])}`.trim() : '',
};
}
function formatStockAlertPercent(quote) {
if (quote.direction === '상승') {
return `(+${quote.changePercentText}% ▲)`;
}
if (quote.direction === '하락') {
return `(-${quote.changePercentText}% ▼)`;
}
return `(0.00% -)`;
}
function buildStockAlertBodyLines(quotes) {
return quotes.map((quote) => `${quote.name} ${quote.priceText}${formatStockAlertPercent(quote)}`);
}
async function processStockAlertPlan(item) {
if (isRegisteredCurrentPriceStockAlertRequest(item.note)) {
return processRegisteredCurrentPriceStockAlertPlan(item);
}
const requestedCompanies = resolveRequestedStockCompanies(item.note);
if (requestedCompanies.length === 0) {
throw new Error('주가 요청에서 대상 종목을 찾지 못했습니다.');
}
const targetAppDomains = extractTargetAppDomains(item.note);
if (targetAppDomains.length === 0) {
throw new Error('주가 알림 요청에서 대상 앱 도메인을 찾지 못했습니다.');
}
reportProgress(`주가 알림 요청을 감지해 ${requestedCompanies.length}개 종목 시세를 조회하는 중입니다.`);
const quotes = await Promise.all(
requestedCompanies.map(async (company) => parseNaverStockQuote(await fetchNaverStockPage(company.code), company.code)),
);
const bodyLines = buildStockAlertBodyLines(quotes);
const notificationKey = [
'stock-alert',
targetAppDomains.join(','),
quotes.map((quote) => quote.code).join(','),
].join(':');
const sendResult = await request('/notifications/send', {
method: 'POST',
body: JSON.stringify({
title: DEFAULT_STOCK_ALERT_TITLE,
body: bodyLines.join('\n'),
threadId: `stock-alert:${targetAppDomains.join(',')}`,
targetAppDomains,
data: {
category: 'stock-alert',
eventType: 'stock-alert',
notificationKey,
source: 'finance.naver.com',
symbols: quotes.map((quote) => quote.code).join(','),
},
}),
});
const webSentCount = Number(sendResult?.web?.sentCount ?? 0);
const webFailedCount = Number(sendResult?.web?.failedCount ?? 0);
if (webSentCount <= 0) {
throw new Error(
`대상 도메인(${targetAppDomains.join(', ')})에 보낼 Web Push 구독이 없거나 발송에 실패했습니다. failed=${webFailedCount}`,
);
}
const summary = [
`주가 알림 전송 완료: ${targetAppDomains.join(', ')}`,
...bodyLines,
].join('\n');
const tokenUsageLine = buildTokenUsageLine(null);
await request(`/plan/items/${item.id}/source-works`, {
method: 'POST',
body: JSON.stringify({
summary,
branchName: item.assignedBranch || item.releaseTarget || 'main',
commitHash: null,
changedFiles: [],
commandLog: [
...requestedCompanies.map((company) => `fetch ${NAVER_STOCK_ITEM_URL}${company.code}`),
tokenUsageLine,
`POST /api/notifications/send targetAppDomains=${targetAppDomains.join(',')}`,
].join('\n'),
diffText: null,
}),
});
await request(`/plan/items/${item.id}/actions/note`, {
method: 'POST',
body: JSON.stringify({
actionType: '자동완료메모',
actionNote: [
summary,
tokenUsageLine,
quotes.some((quote) => quote.quotedAt) ? `시세 기준: ${quotes.map((quote) => quote.quotedAt).filter(Boolean).join(' / ')}` : null,
`notificationKey: ${notificationKey}`,
`web sent=${webSentCount}, failed=${webFailedCount}`,
]
.filter(Boolean)
.join('\n'),
}),
});
await request(`/plan/items/${item.id}/actions/complete`, {
method: 'POST',
body: JSON.stringify({
note: `주가 알림을 ${targetAppDomains.join(', ')} 대상으로 전송했습니다.`,
}),
});
return {
id: item.id,
outcome: 'stock-alert-complete',
targetAppDomains,
quotes,
sendResult,
};
}
async function processRegisteredCurrentPriceStockAlertPlan(item) {
reportProgress('주가 알림 요청을 감지해 stock알림 현재가 등록 종목 기준으로 웹푸시를 전송하는 중입니다.');
const sendResult = await request('/stock-alerts/notify-current-price', {
method: 'POST',
});
const lines = Array.isArray(sendResult?.lines)
? sendResult.lines.map((value) => String(value ?? '').trim()).filter(Boolean)
: [];
const itemCount = Number(sendResult?.itemCount ?? lines.length ?? 0);
const summary = [
sendResult?.skipped ? '주가 알림 전송 건너뜀' : '주가 알림 전송 완료',
`대상 도메인: test.sm-home.cloud`,
lines.length > 0 ? lines.join('\n') : String(sendResult?.web?.reason ?? sendResult?.ios?.reason ?? sendResult?.reason ?? '').trim(),
]
.filter(Boolean)
.join('\n');
const tokenUsageLine = buildTokenUsageLine(null);
await request(`/plan/items/${item.id}/source-works`, {
method: 'POST',
body: JSON.stringify({
summary,
branchName: item.assignedBranch || item.releaseTarget || 'main',
commitHash: null,
changedFiles: [],
commandLog: [tokenUsageLine, 'POST /api/stock-alerts/notify-current-price'].filter(Boolean).join('\n'),
diffText: null,
}),
});
await request(`/plan/items/${item.id}/actions/note`, {
method: 'POST',
body: JSON.stringify({
actionType: '자동완료메모',
actionNote: [
summary,
tokenUsageLine,
`itemCount=${Number.isFinite(itemCount) ? itemCount : lines.length}`,
`web sent=${Number(sendResult?.web?.sentCount ?? 0)}, failed=${Number(sendResult?.web?.failedCount ?? 0)}`,
]
.filter(Boolean)
.join('\n'),
}),
});
await request(`/plan/items/${item.id}/actions/complete`, {
method: 'POST',
body: JSON.stringify({
note: sendResult?.skipped
? '현재가 알림 대상이 없어 웹푸시를 건너뛰었습니다.'
: 'stock알림 현재가 등록 종목 대상으로 웹푸시를 전송했습니다.',
}),
});
return {
id: item.id,
outcome: sendResult?.skipped ? 'noop-complete' : 'stock-alert-complete',
targetAppDomains: ['test.sm-home.cloud'],
itemCount,
lines,
sendResult,
};
}
async function runCommand(command, args) { async function runCommand(command, args) {
if (command === 'git') { if (command === 'git') {
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], { await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], {
@@ -1296,6 +1618,23 @@ async function processErrorLogReviewPlan(item) {
} }
const summary = summaryLines.join('\n'); const summary = summaryLines.join('\n');
const tokenUsageLine = buildTokenUsageLine(null);
await request(`/plan/items/${item.id}/source-works`, {
method: 'POST',
body: JSON.stringify({
summary,
branchName: item.assignedBranch || item.releaseTarget || 'main',
commitHash: null,
changedFiles: [],
commandLog: [
'POST /api/plan/registrations/error-logs',
tokenUsageLine,
'ERROR_LOG_REVIEW: 저장소 파일 변경 없음',
].join('\n'),
diffText: null,
}),
});
await request(`/plan/items/${item.id}/actions/note`, { await request(`/plan/items/${item.id}/actions/note`, {
method: 'POST', method: 'POST',
@@ -1303,6 +1642,7 @@ async function processErrorLogReviewPlan(item) {
actionType: '에러로그점검', actionType: '에러로그점검',
actionNote: [ actionNote: [
summary, summary,
tokenUsageLine,
'', '',
'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.', 'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.',
].join('\n'), ].join('\n'),
@@ -1330,12 +1670,16 @@ async function processPlan(item) {
return processErrorLogReviewPlan(item); return processErrorLogReviewPlan(item);
} }
if (isStockAlertRequest(item.note)) {
return processStockAlertPlan(item);
}
const baselineChangedFiles = localMainMode ? await listChangedFiles() : []; const baselineChangedFiles = localMainMode ? await listChangedFiles() : [];
const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles)); const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles));
const codexResult = await runCodexForPlan(item); const codexResult = await runCodexForPlan(item);
const result = codexResult.message; const result = codexResult.message;
const tokenUsage = codexResult.tokenUsage; const tokenUsage = codexResult.tokenUsage;
const tokenUsageLine = tokenUsage ? `토큰 사용량: ${tokenUsage}` : null; const tokenUsageLine = buildTokenUsageLine(tokenUsage);
const summary = const summary =
result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.'; result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.';
const boardPost = parseBoardPostResult(result); const boardPost = parseBoardPostResult(result);

View File

@@ -0,0 +1,284 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteAutomationContext,
type AutomationContextRecord,
upsertAutomationContext,
useAutomationContextRegistry,
} from './automationContextAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Text, Title } = Typography;
type AutomationContextFormValue = {
id?: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
};
const EMPTY_FORM_VALUE: AutomationContextFormValue = {
title: '',
content: '',
enabled: true,
defaultSelected: true,
};
function toFormValue(context: AutomationContextRecord | null): AutomationContextFormValue {
if (!context) {
return EMPTY_FORM_VALUE;
}
return {
id: context.id,
title: context.title,
content: context.content,
enabled: context.enabled,
defaultSelected: context.defaultSelected,
};
}
export function AutomationContextManagementPage() {
const { hasAccess } = useTokenAccess();
const { automationContexts, setAutomationContexts, isLoading, errorMessage } = useAutomationContextRegistry();
const [selectedAutomationContextId, setSelectedAutomationContextId] = useState<string | null>(automationContexts[0]?.id ?? null);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationContextFormValue>();
const selectedAutomationContext = useMemo(
() => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null,
[automationContexts, selectedAutomationContextId],
);
useEffect(() => {
if (selectedAutomationContextId && automationContexts.some((item) => item.id === selectedAutomationContextId)) {
return;
}
setSelectedAutomationContextId(automationContexts[0]?.id ?? null);
}, [automationContexts, selectedAutomationContextId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
}, [detailMode, form, isCreating, selectedAutomationContext]);
const openCreateForm = () => {
setIsCreating(true);
setSelectedAutomationContextId(null);
setDetailMode('detail');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (automationContextId: string) => {
setIsCreating(false);
setSelectedAutomationContextId(automationContextId);
setDetailMode('detail');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
};
const handleDelete = async () => {
if (!selectedAutomationContext) {
return;
}
if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) {
return;
}
const nextAutomationContexts = deleteAutomationContext(automationContexts, selectedAutomationContext.id);
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts);
setSelectedAutomationContextId(savedAutomationContexts[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 삭제에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
if (!hasAccess) {
return (
<Card title="Context 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
/>
</Card>
);
}
return (
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
{detailMode === 'list' ? (
<Card
title="Context 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
Context
</Button>
}
>
<div className="chat-type-management-page__list">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> Context</Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${automationContexts.length}`}</Text>
</div>
{automationContexts.length > 0 ? (
<List
dataSource={automationContexts}
renderItem={(item) => (
<List.Item
className={
item.id === selectedAutomationContextId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item'
}
onClick={() => openDetail(item.id)}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.title}</Text>
<Text type="secondary">{item.id}</Text>
</Space>
<Space size={[8, 8]} wrap style={{ marginTop: 6 }}>
<Text type={item.enabled ? undefined : 'secondary'}>{item.enabled ? '사용' : '중지'}</Text>
<Text type={item.defaultSelected ? undefined : 'secondary'}>
{item.defaultSelected ? '기본 선택' : '기본 해제'}
</Text>
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
</div>
</div>
</List.Item>
)}
/>
) : (
<Empty description="등록된 Context가 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? 'Context 등록' : 'Context 상세'}
className="chat-type-management-page__card"
extra={
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
{!isCreating && selectedAutomationContext ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="삭제"
onClick={() => void handleDelete()}
/>
) : null}
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Space>
}
>
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<Form
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={async (values) => {
const nextAutomationContexts = upsertAutomationContext(automationContexts, values);
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts);
const savedAutomationContext = savedAutomationContexts.find(
(item) => item.id === values.id || item.title === values.title,
);
setIsCreating(false);
setSelectedAutomationContextId(savedAutomationContext?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
}}
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<Form.Item label="제목" name="title" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
<Input placeholder="예: 기본 처리" />
</Form.Item>
<Space wrap>
<Form.Item label="사용" name="enabled" valuePropName="checked">
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
<Form.Item label="기본 선택" name="defaultSelected" valuePropName="checked">
<Switch checkedChildren="기본" unCheckedChildren="해제" />
</Form.Item>
</Space>
<Form.Item label="Context 본문" name="content">
<Input.TextArea
autoSize={{ minRows: 10, maxRows: 18 }}
placeholder={'## 처리 기준\n- 이 Context에서 적용할 규칙을 Markdown으로 정리하세요.'}
/>
</Form.Item>
</Form>
</Card>
)}
</div>
);
}

View File

@@ -124,6 +124,24 @@ export function ChatTypeManagementPage() {
}; };
}, []); }, []);
useEffect(() => {
if (typeof document === 'undefined' || !isMobileViewport) {
return;
}
const { body, documentElement } = document;
const previousBodyOverflow = body.style.overflow;
const previousHtmlOverflow = documentElement.style.overflow;
body.style.overflow = 'hidden';
documentElement.style.overflow = 'hidden';
return () => {
body.style.overflow = previousBodyOverflow;
documentElement.style.overflow = previousHtmlOverflow;
};
}, [isMobileViewport]);
const openCreateForm = () => { const openCreateForm = () => {
setIsCreating(true); setIsCreating(true);
setSelectedChatTypeId(null); setSelectedChatTypeId(null);
@@ -151,7 +169,7 @@ export function ChatTypeManagementPage() {
return; return;
} }
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) { if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) {
return; return;
} }
@@ -197,13 +215,13 @@ export function ChatTypeManagementPage() {
/> />
</Tooltip> </Tooltip>
{!isCreating && selectedChatType ? ( {!isCreating && selectedChatType ? (
<Tooltip title="비활성화"> <Tooltip title="삭제">
<Button <Button
danger danger
shape="circle" shape="circle"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
loading={isSaving} loading={isSaving}
aria-label="비활성화" aria-label="삭제"
onClick={() => void handleDelete()} onClick={() => void handleDelete()}
/> />
</Tooltip> </Tooltip>

View File

@@ -28,6 +28,30 @@
overflow: hidden; overflow: hidden;
} }
.app-chat-panel--tablet-app.ant-card,
.app-chat-panel--tablet-app .ant-card-body,
.app-chat-panel--tablet-app .app-chat-panel__stack,
.app-chat-panel--tablet-app .app-chat-panel__stack--chat,
.app-chat-panel--tablet-app .app-chat-panel__conversation-shell,
.app-chat-panel--tablet-app .app-chat-panel__conversation-main,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view-inner,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list {
width: 100%;
min-width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.app-chat-panel--tablet-app .app-chat-panel__stack--chat,
.app-chat-panel--tablet-app .app-chat-panel__conversation-shell,
.app-chat-panel--tablet-app .app-chat-panel__conversation-main,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view-inner {
flex-basis: 100%;
}
.app-chat-panel__preview-modal.ant-modal { .app-chat-panel__preview-modal.ant-modal {
z-index: 1400; z-index: 1400;
max-width: 100vw; max-width: 100vw;
@@ -663,6 +687,12 @@
margin: auto; margin: auto;
} }
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty .ant-empty,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list .ant-empty {
width: 100%;
max-width: 100%;
}
.app-chat-preview-card--activity { .app-chat-preview-card--activity {
min-width: 0; min-width: 0;
width: 100%; width: 100%;
@@ -1141,12 +1171,16 @@
} }
} }
@media (min-width: 768px) and (max-width: 1180px) { @media (min-width: 768px) and (max-width: 1366px) {
.app-chat-panel .app-chat-panel__title-copy .ant-typography, .app-chat-panel .app-chat-panel__title-copy .ant-typography,
.app-chat-panel .app-chat-panel__conversation-header .ant-typography { .app-chat-panel .app-chat-panel__conversation-header .ant-typography {
font-size: 15px; font-size: 15px;
} }
.app-chat-panel .app-chat-message__header .ant-typography {
font-size: 16px;
}
.app-chat-panel .app-chat-panel__conversation-section-title, .app-chat-panel .app-chat-panel__conversation-section-title,
.app-chat-panel .app-chat-panel__conversation-section-count, .app-chat-panel .app-chat-panel__conversation-section-count,
.app-chat-panel .app-chat-panel__conversation-item-time, .app-chat-panel .app-chat-panel__conversation-item-time,
@@ -1167,7 +1201,7 @@
.app-chat-panel .app-chat-panel__resource-strip-filter, .app-chat-panel .app-chat-panel__resource-strip-filter,
.app-chat-panel .app-chat-panel__resource-strip-empty.ant-typography, .app-chat-panel .app-chat-panel__resource-strip-empty.ant-typography,
.app-chat-panel .app-chat-panel__busy-overlay span { .app-chat-panel .app-chat-panel__busy-overlay span {
font-size: 12px; font-size: 14px;
} }
.app-chat-panel .app-chat-panel__conversation-item-title, .app-chat-panel .app-chat-panel__conversation-item-title,
@@ -1180,6 +1214,10 @@
font-size: 14px; font-size: 14px;
} }
.app-chat-panel .app-chat-preview-card__ranked-link-anchor {
font-size: 13px;
}
.app-chat-panel .app-chat-panel__conversation-item-preview, .app-chat-panel .app-chat-panel__conversation-item-preview,
.app-chat-panel .app-chat-message__header-meta, .app-chat-panel .app-chat-message__header-meta,
.app-chat-panel .app-chat-message__header-meta strong, .app-chat-panel .app-chat-message__header-meta strong,
@@ -1187,11 +1225,12 @@
.app-chat-panel .app-chat-panel__composer-queue-text, .app-chat-panel .app-chat-panel__composer-queue-text,
.app-chat-panel .app-chat-panel__composer-queue-more, .app-chat-panel .app-chat-panel__composer-queue-more,
.app-chat-panel .app-chat-panel__preview-modal-close-label { .app-chat-panel .app-chat-panel__preview-modal-close-label {
font-size: 13px; font-size: 15px;
} }
.app-chat-panel .app-chat-message__body { .app-chat-panel .app-chat-message__body,
font-size: 18px; .app-chat-panel .app-chat-message__body.ant-typography {
font-size: 20px !important;
line-height: 1.6; line-height: 1.6;
} }
@@ -1722,6 +1761,37 @@
color: #475569; color: #475569;
} }
.app-chat-preview-card--ranked-link {
gap: 0;
}
.app-chat-preview-card__glyph--ranked-link {
color: #1d4ed8;
background: rgba(191, 219, 254, 0.72);
}
.app-chat-preview-card__body--ranked-link {
padding: 10px;
}
.app-chat-preview-card__ranked-link-anchor {
display: block;
color: #1d4ed8;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
text-decoration: none;
}
.app-chat-preview-card__ranked-link-anchor:hover {
text-decoration: underline;
}
.app-chat-preview-card__open-link.ant-btn {
height: 26px;
padding-inline: 8px;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.app-chat-panel { .app-chat-panel {
height: 100%; height: 100%;
@@ -2041,7 +2111,7 @@
flex: none; flex: none;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: clamp(112px, 18dvh, 160px);
} }
.app-chat-panel__composer-queue { .app-chat-panel__composer-queue {
@@ -2149,15 +2219,15 @@
width: 100%; width: 100%;
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
height: clamp(64px, 10dvh, 92px); height: clamp(112px, 18dvh, 160px);
min-height: clamp(64px, 10dvh, 92px); min-height: clamp(112px, 18dvh, 160px);
padding: 10px 52px 8px 14px; padding: 10px 52px 8px 14px;
box-sizing: border-box; box-sizing: border-box;
resize: none; resize: none;
} }
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input { .app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 76px; padding-top: 96px;
} }
.app-chat-panel__composer-topline, .app-chat-panel__composer-topline,
@@ -2188,6 +2258,24 @@
gap: 6px; gap: 6px;
} }
.app-chat-panel__composer-contextless-toggle.ant-btn {
color: #475569;
}
.app-chat-panel__composer-contextless-toggle--active.ant-btn {
border-color: #0f766e;
background: linear-gradient(135deg, #0f766e, #0f766e);
color: #f8fafc;
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.24);
}
.app-chat-panel__composer-contextless-toggle--active.ant-btn:hover,
.app-chat-panel__composer-contextless-toggle--active.ant-btn:focus-visible {
border-color: #0f766e;
background: linear-gradient(135deg, #0f766e, #115e59);
color: #f8fafc;
}
.app-chat-panel__composer-utility-buttons { .app-chat-panel__composer-utility-buttons {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 6px;
@@ -2901,14 +2989,18 @@
} }
.app-chat-panel__composer textarea.ant-input { .app-chat-panel__composer textarea.ant-input {
height: clamp(56px, 8.5dvh, 72px); height: clamp(104px, 16dvh, 136px);
min-height: clamp(56px, 8.5dvh, 72px); min-height: clamp(104px, 16dvh, 136px);
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
} }
.app-chat-panel__composer-input-shell {
min-height: clamp(104px, 16dvh, 136px);
}
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input { .app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 64px; padding-top: 88px;
} }
.app-chat-panel__resource-strip-list { .app-chat-panel__resource-strip-list {

View File

@@ -20,7 +20,15 @@ import {
import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd'; import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd';
import type { InputRef } from 'antd'; import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { TextAreaRef } from 'antd/es/input/TextArea';
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react'; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
} from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { useAppConfig } from './appConfig'; import { useAppConfig } from './appConfig';
@@ -34,6 +42,7 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController'; import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody'; import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils'; import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { shouldSkipForegroundResyncAfterExternalLink } from './mainChatPanel/linkNavigation';
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems'; import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
@@ -66,11 +75,13 @@ import type {
ChatViewContext, ChatViewContext,
MainChatPanelProps, MainChatPanelProps,
} from './mainChatPanel/types'; } from './mainChatPanel/types';
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
import { buildChatPath } from './routes'; import { buildChatPath } from './routes';
import './MainChatPanel.css'; import './MainChatPanel.css';
import './MainChatPanel.hotfix.css'; import './MainChatPanel.hotfix.css';
const { Text } = Typography; const { Text } = Typography;
const ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS = 5000;
type ChatTypeOption = { type ChatTypeOption = {
value: string; value: string;
@@ -107,6 +118,21 @@ type PendingContextConfirm = {
omittedContextCount: number; omittedContextCount: number;
}; };
type ImportedCodexDraftRequest = {
text: string;
autoSend: boolean;
sendMode: 'queue' | 'direct';
};
type PendingFreshConversationSendRequest = {
targetSessionId: string;
text: string;
sendMode: 'queue' | 'direct';
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
};
const CHAT_MAX_RETRY_ATTEMPTS = 5; const CHAT_MAX_RETRY_ATTEMPTS = 5;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_RESTART_REQUIRED_PATTERNS = [ const CHAT_RESTART_REQUIRED_PATTERNS = [
@@ -950,6 +976,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [previewFindQuery, setPreviewFindQuery] = useState(''); const [previewFindQuery, setPreviewFindQuery] = useState('');
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null); const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null); const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
const [queuedImportedDraft, setQueuedImportedDraft] = useState('');
const [pendingImportedDraftRequest, setPendingImportedDraftRequest] = useState<ImportedCodexDraftRequest | null>(null);
const [pendingFreshConversationSendRequest, setPendingFreshConversationSendRequest] =
useState<PendingFreshConversationSendRequest | null>(null);
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null); const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null); const viewportRef = useRef<HTMLDivElement | null>(null);
@@ -959,6 +990,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]); const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
const previewSearchMatchIndexRef = useRef(-1); const previewSearchMatchIndexRef = useRef(-1);
const previewSearchKeyRef = useRef(''); const previewSearchKeyRef = useRef('');
const activeConversationResyncPromiseRef = useRef<Promise<void> | null>(null);
const previousPreviewModalOpenRef = useRef(false);
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false); const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
const titleClusterRef = useRef<HTMLDivElement | null>(null); const titleClusterRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<number | null>(null); const copyFeedbackTimerRef = useRef<number | null>(null);
@@ -977,6 +1010,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const notifiedRestartRequirementKeysRef = useRef<string[]>([]); const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({}); const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : []; const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
const isCreatingImportedDraftConversationRef = useRef(false);
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => { const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
setRequestItemsState((previous) => { setRequestItemsState((previous) => {
const safePrevious = Array.isArray(previous) ? previous : []; const safePrevious = Array.isArray(previous) ? previous : [];
@@ -989,6 +1023,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}); });
}, []); }, []);
useEffect(() => {
if (location.pathname !== buildChatPath('live')) {
return;
}
const pendingDraft = consumeCodexLiveDraft();
if (!pendingDraft) {
return;
}
setActiveView('chat');
setQueuedImportedDraft(pendingDraft.text);
setPendingImportedDraftRequest(
pendingDraft.autoSend
? {
text: pendingDraft.text,
autoSend: true,
sendMode: pendingDraft.sendMode === 'direct' ? 'direct' : 'queue',
}
: null,
);
messageApi.success(pendingDraft.autoSend ? '레이아웃 명세를 Codex Live로 전송합니다.' : '레이아웃 명세를 Codex Live 입력창에 채웠습니다.');
}, [location.pathname, messageApi]);
useEffect(() => {
if (!queuedImportedDraft.trim()) {
return;
}
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
composerRef.current?.focus({ cursor: 'end' });
setQueuedImportedDraft('');
}, [activeSessionId, queuedImportedDraft]);
const { const {
conversationItems, conversationItems,
setConversationItems, setConversationItems,
@@ -1100,7 +1168,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} }
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.'); messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
return null;
} }
return sessionId;
}; };
const openCreateConversationModal = () => { const openCreateConversationModal = () => {
if (availableChatTypes.length === 0) { if (availableChatTypes.length === 0) {
@@ -1239,6 +1310,24 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, },
[activeSessionId, syncConversationDetailIntoState], [activeSessionId, syncConversationDetailIntoState],
); );
const resyncActiveConversationDetail = useCallback(async () => {
const normalizedSessionId = activeSessionId.trim();
if (!normalizedSessionId) {
return;
}
if (activeConversationResyncPromiseRef.current) {
return activeConversationResyncPromiseRef.current;
}
const requestPromise = syncConversationFromServer(normalizedSessionId).finally(() => {
activeConversationResyncPromiseRef.current = null;
});
activeConversationResyncPromiseRef.current = requestPromise;
return requestPromise;
}, [activeSessionId, syncConversationFromServer]);
const resyncConversationEntryState = useCallback(() => { const resyncConversationEntryState = useCallback(() => {
const now = Date.now(); const now = Date.now();
@@ -1250,9 +1339,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
void reloadConversationItems(); void reloadConversationItems();
if (activeSessionId.trim()) { if (activeSessionId.trim()) {
void syncConversationFromServer(activeSessionId); void resyncActiveConversationDetail();
} }
}, [activeSessionId, reloadConversationItems, syncConversationFromServer]); }, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]);
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => { const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
const sessionId = eventSessionId.trim() || activeSessionId; const sessionId = eventSessionId.trim() || activeSessionId;
@@ -1662,6 +1751,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
mapSystemStatusMessage, mapSystemStatusMessage,
isActivityLogMessage, isActivityLogMessage,
}); });
useEffect(() => {
if (activeView !== 'chat' || !activeSessionId.trim()) {
return;
}
if (typeof window === 'undefined') {
return;
}
const runSilentResync = () => {
if (document.visibilityState !== 'visible') {
return;
}
void resyncActiveConversationDetail();
};
runSilentResync();
const intervalId = window.setInterval(runSilentResync, ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [activeSessionId, activeView, resyncActiveConversationDetail]);
const { loadOlderMessages } = useConversationRoomController({ const { loadOlderMessages } = useConversationRoomController({
activeSessionId, activeSessionId,
oldestLoadedMessageId, oldestLoadedMessageId,
@@ -1782,6 +1896,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
previewError, previewError,
previewText, previewText,
setActivePreviewId, setActivePreviewId,
setActivePreviewOverride,
setIsPreviewModalOpen, setIsPreviewModalOpen,
} = useConversationViewController({ } = useConversationViewController({
activeSessionId, activeSessionId,
@@ -1799,11 +1914,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setMessages, setMessages,
}); });
const openPreviewModal = useCallback( const openPreviewModal = useCallback(
(previewId: string) => { (
setActivePreviewId(previewId); preview:
| string
| {
id: string;
label: string;
url: string;
kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
source?: 'message' | 'context';
},
) => {
if (typeof preview === 'string') {
setActivePreviewOverride(null);
setActivePreviewId(preview);
} else {
setActivePreviewOverride({
...preview,
source: preview.source ?? 'message',
});
setActivePreviewId(null);
}
setIsPreviewModalOpen(true); setIsPreviewModalOpen(true);
}, },
[setActivePreviewId, setIsPreviewModalOpen], [setActivePreviewId, setActivePreviewOverride, setIsPreviewModalOpen],
); );
const handleCopyActivePreview = useCallback(async () => { const handleCopyActivePreview = useCallback(async () => {
if (!activePreview) { if (!activePreview) {
@@ -1884,6 +2019,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} }
}, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]); }, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);
useEffect(() => {
if (!previousPreviewModalOpenRef.current && isPreviewModalOpen) {
previousPreviewModalOpenRef.current = true;
return;
}
if (previousPreviewModalOpenRef.current && !isPreviewModalOpen) {
previousPreviewModalOpenRef.current = false;
void reloadConversationItems();
void resyncActiveConversationDetail();
}
}, [isPreviewModalOpen, reloadConversationItems, resyncActiveConversationDetail]);
useEffect(() => { useEffect(() => {
resetActivePreviewSearchState(); resetActivePreviewSearchState();
clearActivePreviewSearchSelection(); clearActivePreviewSearchSelection();
@@ -2755,9 +2903,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
return;
}
resyncConversationEntryState(); resyncConversationEntryState();
}; };
const handlePageShow = () => { const handlePageShow = () => {
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
return;
}
resyncConversationEntryState(); resyncConversationEntryState();
}; };
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
@@ -2765,6 +2919,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return; return;
} }
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
return;
}
resyncConversationEntryState(); resyncConversationEntryState();
}; };
@@ -2777,7 +2935,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
window.removeEventListener('pageshow', handlePageShow); window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [resyncConversationEntryState]); }, [connectionState, resyncConversationEntryState]);
useEffect(() => { useEffect(() => {
if (connectionState !== 'disconnected') { if (connectionState !== 'disconnected') {
@@ -2946,6 +3104,172 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
scrollViewportToBottom, scrollViewportToBottom,
}); });
useEffect(() => {
if (!pendingFreshConversationSendRequest) {
return;
}
if (pendingContextConfirm) {
return;
}
if (activeSessionId !== pendingFreshConversationSendRequest.targetSessionId) {
return;
}
executeSendMessage({
mode: pendingFreshConversationSendRequest.sendMode,
text: pendingFreshConversationSendRequest.text,
chatTypeId: pendingFreshConversationSendRequest.chatTypeId,
chatTypeLabel: pendingFreshConversationSendRequest.chatTypeLabel,
chatTypeDescription: pendingFreshConversationSendRequest.chatTypeDescription,
includedContextCount: 0,
omittedContextCount: 0,
});
setPendingFreshConversationSendRequest(null);
}, [activeSessionId, executeSendMessage, pendingContextConfirm, pendingFreshConversationSendRequest]);
useEffect(() => {
if (!pendingImportedDraftRequest?.autoSend) {
return;
}
if (pendingContextConfirm) {
return;
}
const requestedSessionIdValue = requestedSessionId?.trim() ?? '';
if (requestedSessionIdValue && activeSessionId !== requestedSessionIdValue) {
return;
}
if (!activeSessionId.trim()) {
if (requestedSessionIdValue || isCreatingImportedDraftConversationRef.current) {
return;
}
const importChatType = selectedChatType ?? availableChatTypes[0] ?? null;
if (!importChatType) {
return;
}
isCreatingImportedDraftConversationRef.current = true;
setSelectedChatTypeId(importChatType.id);
void handleCreateConversation(importChatType).finally(() => {
isCreatingImportedDraftConversationRef.current = false;
});
return;
}
if (!effectiveChatType) {
return;
}
setPendingImportedDraftRequest(null);
setDraft('');
executeSendMessage({
mode: pendingImportedDraftRequest.sendMode,
text: pendingImportedDraftRequest.text,
chatTypeId: effectiveChatType.id,
chatTypeLabel: effectiveChatType.name,
chatTypeDescription: effectiveChatType.description,
includedContextCount: 0,
omittedContextCount: 0,
});
}, [
activeSessionId,
availableChatTypes,
effectiveChatType,
executeSendMessage,
handleCreateConversation,
pendingContextConfirm,
pendingImportedDraftRequest,
requestedSessionId,
selectedChatType,
setDraft,
setSelectedChatTypeId,
]);
const handleSendWithoutPreviousContext = useCallback(
async (mode: 'queue' | 'direct') => {
if (isComposerAttachmentUploading) {
return;
}
const nextConversationChatType =
effectiveChatType ??
(selectedChatType && isSelectedChatTypeAllowed
? {
id: selectedChatType.id,
name: selectedChatType.name,
description: selectedChatType.description,
}
: (availableChatTypes[0] ?? null));
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
if (!trimmed) {
return;
}
if (!nextConversationChatType) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
]);
return;
}
const createdSessionId = await handleCreateConversation(nextConversationChatType);
if (!createdSessionId) {
return;
}
setPendingFreshConversationSendRequest({
targetSessionId: createdSessionId,
text: trimmed,
sendMode: mode,
chatTypeId: nextConversationChatType.id,
chatTypeLabel: nextConversationChatType.name,
chatTypeDescription: nextConversationChatType.description,
});
setDraft('');
setComposerAttachments([]);
},
[
availableChatTypes,
buildOutgoingMessageText,
composerAttachments,
createLocalMessage,
draft,
effectiveChatType,
handleCreateConversation,
isComposerAttachmentUploading,
isSelectedChatTypeAllowed,
selectedChatType,
setMessages,
],
);
const handleComposerSend = useCallback(() => {
if (isSendWithoutContextEnabled) {
void handleSendWithoutPreviousContext('queue');
return;
}
handleSend();
}, [handleSend, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]);
const handleComposerSendImmediate = useCallback(() => {
if (isSendWithoutContextEnabled) {
void handleSendWithoutPreviousContext('direct');
return;
}
handleSendImmediate();
}, [handleSendImmediate, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]);
const handleCopyMessage = async (message: ChatMessage) => { const handleCopyMessage = async (message: ChatMessage) => {
await copyText(message.text); await copyText(message.text);
setCopiedMessageId(message.id); setCopiedMessageId(message.id);
@@ -3285,8 +3609,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setSelectedChatTypeId(nextChatTypeId); setSelectedChatTypeId(nextChatTypeId);
}} }}
onSend={handleSend} onSend={handleComposerSend}
onSendImmediate={handleSendImmediate} onSendImmediate={handleComposerSendImmediate}
isSendWithoutContextEnabled={isSendWithoutContextEnabled}
onToggleSendWithoutContext={() => {
setIsSendWithoutContextEnabled((current) => !current);
}}
onClearDraft={() => { onClearDraft={() => {
setDraft(''); setDraft('');
}} }}

View File

@@ -11,6 +11,7 @@ import { useSearchLayer } from '../../layer';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView'; import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage'; import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage'; import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage'; import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel'; import { MainChatPanel } from './MainChatPanel';
@@ -174,6 +175,10 @@ export function MainContent({
return <AutomationTypeManagementPage />; return <AutomationTypeManagementPage />;
} }
if (selectionId === 'page:plans:automation-context') {
return <AutomationContextManagementPage />;
}
const planStatus = getPlanStatusFromWindowSelection(selectionId); const planStatus = getPlanStatusFromWindowSelection(selectionId);
if (planStatus) { if (planStatus) {

View File

@@ -578,6 +578,12 @@
overflow-y: auto; overflow-y: auto;
} }
html:has(.chat-type-management-page),
body:has(.chat-type-management-page),
#root:has(.chat-type-management-page) {
overflow: hidden;
}
.app-shell, .app-shell,
.app-main-content.ant-layout-content, .app-main-content.ant-layout-content,
.app-main-panel, .app-main-panel,
@@ -602,6 +608,16 @@
overflow: hidden; overflow: hidden;
} }
.app-shell:has(.chat-type-management-page),
.app-shell:has(.chat-type-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.chat-type-management-page),
.app-main-panel:has(.chat-type-management-page),
.app-main-layout:has(.chat-type-management-page) {
height: calc(100dvh - 52px);
min-height: calc(100dvh - 52px);
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved), .app-shell:has(.app-main-panel--play-saved),
.app-shell:has(.app-main-panel--play-saved) > .ant-layout, .app-shell:has(.app-main-panel--play-saved) > .ant-layout,
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved), .app-main-content.ant-layout-content:has(.app-main-panel--play-saved),

View File

@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
export type AutomationContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export type AutomationContextInput = {
id?: string;
title: string;
content?: string;
enabled?: boolean;
defaultSelected?: boolean;
};
const AUTOMATION_CONTEXTS_API_PATH = '/automation-contexts';
const AUTOMATION_CONTEXT_SYNC_EVENT = 'work-app:automation-contexts-changed';
const AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS = 8000;
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const id =
normalizeText(record.id) ||
`automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
title: title || 'Context',
content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationContextRecord>();
const bySemanticKey = new Map<string, AutomationContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
}
function emitAutomationContextsChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(AUTOMATION_CONTEXT_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const API_BASE_URL = resolveApiBaseUrl();
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(`${baseUrl}${AUTOMATION_CONTEXTS_API_PATH}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
throw new Error((await response.text()) || '자동화 Context 요청에 실패했습니다.');
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestAutomationContexts<T>(init?: RequestInit) {
try {
return await requestOnce<T>(API_BASE_URL, init);
} catch (error) {
const shouldRetryWithFallback =
FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
error instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(FALLBACK_BASE_URL, init);
}
}
async function loadAutomationContextsFromServer() {
const response = await requestAutomationContexts<{ ok: boolean; automationContexts: Partial<AutomationContextRecord>[] | null }>({
method: 'GET',
});
if (response.automationContexts == null) {
return DEFAULT_AUTOMATION_CONTEXTS;
}
return sanitizeAutomationContexts(response.automationContexts);
}
async function saveAutomationContextsToServer(items: AutomationContextRecord[]) {
const resolved = sanitizeAutomationContexts(items);
const response = await requestAutomationContexts<{ ok: boolean; automationContexts: Partial<AutomationContextRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ automationContexts: resolved }),
});
return sanitizeAutomationContexts(response.automationContexts);
}
export function upsertAutomationContext(items: AutomationContextRecord[], input: AutomationContextInput) {
const nextItem = normalizeAutomationContext(input);
if (!nextItem) {
return sanitizeAutomationContexts(items);
}
const nextItems = items.filter((item) => item.id !== nextItem.id);
nextItems.push(nextItem);
return sanitizeAutomationContexts(nextItems);
}
export function deleteAutomationContext(items: AutomationContextRecord[], automationContextId: string) {
const normalizedId = normalizeText(automationContextId);
if (!normalizedId) {
return sanitizeAutomationContexts(items);
}
return sanitizeAutomationContexts(items.filter((item) => item.id !== normalizedId));
}
export function buildAutomationContextOptions(
items: AutomationContextRecord[],
selectedContextIds: string[] = [],
) {
const contexts = sanitizeAutomationContexts(items);
const selectedSet = new Set(selectedContextIds);
const enabledIds = new Set(contexts.filter((item) => item.enabled).map((item) => item.id));
return contexts
.filter((item) => enabledIds.has(item.id) || selectedSet.has(item.id))
.map((item) => ({
label: item.title,
value: item.id,
}));
}
export function resolveDefaultAutomationContextIds(items: AutomationContextRecord[]) {
return sanitizeAutomationContexts(items)
.filter((item) => item.enabled && item.defaultSelected)
.map((item) => item.id);
}
export function useAutomationContextRegistry() {
const [automationContexts, setAutomationContextsState] = useState<AutomationContextRecord[]>(DEFAULT_AUTOMATION_CONTEXTS);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
let cancelled = false;
const load = async () => {
setIsLoading(true);
setErrorMessage('');
try {
const nextAutomationContexts = await loadAutomationContextsFromServer();
if (!cancelled && mountedRef.current) {
setAutomationContextsState(nextAutomationContexts);
}
} catch (error) {
if (!cancelled && mountedRef.current) {
setAutomationContextsState(DEFAULT_AUTOMATION_CONTEXTS);
setErrorMessage(error instanceof Error ? error.message : '자동화 Context를 불러오지 못했습니다.');
}
} finally {
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
}
};
void load();
const handleSync = () => {
void load();
};
window.addEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
return () => {
cancelled = true;
window.removeEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
};
}, []);
const setAutomationContexts = async (nextItems: AutomationContextRecord[]) => {
const saved = await saveAutomationContextsToServer(nextItems);
if (mountedRef.current) {
setAutomationContextsState(saved);
setErrorMessage('');
}
emitAutomationContextsChange();
return saved;
};
return {
automationContexts,
setAutomationContexts,
isLoading,
errorMessage,
};
}

View File

@@ -13,10 +13,20 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number]; export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export type AutomationTypeRecord = { export type AutomationTypeRecord = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
contexts: AutomationTypeContextRecord[];
behaviorType: AutomationBehaviorType; behaviorType: AutomationBehaviorType;
enabled: boolean; enabled: boolean;
updatedAt: string; updatedAt: string;
@@ -26,6 +36,7 @@ export type AutomationTypeInput = {
id?: string; id?: string;
name: string; name: string;
description?: string; description?: string;
contexts?: Partial<AutomationTypeContextRecord>[];
behaviorType?: AutomationBehaviorType; behaviorType?: AutomationBehaviorType;
enabled?: boolean; enabled?: boolean;
}; };
@@ -43,11 +54,39 @@ export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string>
}; };
const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'general-inquiry',
name: '일반 문의',
description: '일반 문의/확인 요청으로 처리합니다.',
contexts: [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{ {
id: 'none', id: 'none',
name: '기본유형', name: '기본유형',
description: description: '기본 자동화 처리용 유형입니다.',
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.', contexts: [
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'none', behaviorType: 'none',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -56,6 +95,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'plan', id: 'plan',
name: '작업 요청 등록', name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.', description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
contexts: [
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'plan', behaviorType: 'plan',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -64,6 +113,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'command_execution', id: 'command_execution',
name: 'Command 실행', name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.', description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
contexts: [
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution', behaviorType: 'command_execution',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -72,6 +131,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'non_source_work', id: 'non_source_work',
name: '비 소스작업', name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.', description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
contexts: [
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'non_source_work', behaviorType: 'non_source_work',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -79,7 +148,18 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{ {
id: 'auto_worker', id: 'auto_worker',
name: 'autoWorker', name: 'autoWorker',
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.', description: '자동화 작업메모로 처리니다.',
contexts: [
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'auto_worker', behaviorType: 'auto_worker',
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -95,6 +175,10 @@ export function normalizeAutomationTypeId(
): PlanAutomationType | BoardAutomationType { ): PlanAutomationType | BoardAutomationType {
const normalized = normalizeText(typeof value === 'string' ? value : ''); const normalized = normalizeText(typeof value === 'string' ? value : '');
if (normalized === 'stock-alert') {
return 'general-inquiry';
}
if (normalized === 'plan_registration') { if (normalized === 'plan_registration') {
return 'plan'; return 'plan';
} }
@@ -128,7 +212,67 @@ function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecor
return 0; return 0;
} }
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null { function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const id =
normalizeText(record.id) ||
`automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
title: title || 'Context',
content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationTypeContextRecord>();
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
}
function normalizeAutomationType(
record: Partial<Omit<AutomationTypeRecord, 'contexts'>> & { contexts?: Partial<AutomationTypeContextRecord>[] },
): AutomationTypeRecord | null {
const name = normalizeText(record.name); const name = normalizeText(record.name);
if (!name) { if (!name) {
@@ -143,6 +287,7 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
id, id,
name, name,
description: normalizeText(record.description), description: normalizeText(record.description),
contexts: sanitizeAutomationContexts(record.contexts),
behaviorType: normalizeBehaviorType(record.behaviorType), behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: record.enabled !== false, enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
@@ -217,7 +362,7 @@ const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
async function requestOnce<T>(baseUrl: string, init?: RequestInit) { async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers); const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null; const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_TYPE_REQUEST_TIMEOUT_MS); const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_TYPE_REQUEST_TIMEOUT_MS);
@@ -329,6 +474,36 @@ export function buildAutomationTypeOptions(
})); }));
} }
export function buildAutomationContextOptions(
items: AutomationTypeRecord[],
automationTypeId: string | null | undefined,
selectedContextIds: string[] = [],
) {
const normalizedId = normalizeAutomationTypeId(automationTypeId);
const automationType = items.find((item) => item.id === normalizedId) ?? null;
const contexts = sanitizeAutomationContexts(automationType?.contexts);
const selectedSet = new Set(selectedContextIds);
const enabledIds = new Set(contexts.filter((item) => item.enabled).map((item) => item.id));
return contexts
.filter((item) => enabledIds.has(item.id) || selectedSet.has(item.id))
.map((item) => ({
label: item.title,
value: item.id,
}));
}
export function resolveDefaultAutomationContextIds(
items: AutomationTypeRecord[],
automationTypeId: string | null | undefined,
) {
const normalizedId = normalizeAutomationTypeId(automationTypeId);
const automationType = items.find((item) => item.id === normalizedId) ?? null;
return sanitizeAutomationContexts(automationType?.contexts)
.filter((item) => item.enabled && item.defaultSelected)
.map((item) => item.id);
}
export function useAutomationTypeRegistry() { export function useAutomationTypeRegistry() {
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES); const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);

View File

@@ -1,5 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity'; import { appendClientIdHeader } from './clientIdentity';
import {
LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
LAYOUT_EDITOR_CHAT_TYPE_ID,
LAYOUT_EDITOR_CHAT_TYPE_NAME,
} from './chatTypeDefaults';
export type ChatPermissionRole = 'guest' | 'token-user'; export type ChatPermissionRole = 'guest' | 'token-user';
@@ -38,6 +43,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true, enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z', updatedAt: '2026-04-21T00:00:00.000Z',
}, },
{
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-27T00:00:00.000Z',
},
{ {
id: 'api-request-template', id: 'api-request-template',
name: 'API요청', name: 'API요청',
@@ -275,17 +288,7 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
return sanitizeChatTypes(chatTypes); return sanitizeChatTypes(chatTypes);
} }
return sanitizeChatTypes( return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
chatTypes.map((item) =>
item.id === normalizedId
? {
...item,
enabled: false,
updatedAt: new Date().toISOString(),
}
: item,
),
);
} }
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] { export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {

View File

@@ -0,0 +1,6 @@
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
export const LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';

View File

@@ -64,6 +64,7 @@ export function ConversationRoomPane({
isMobileViewport={false} isMobileViewport={false}
isChatTypeSelectionLocked={true} isChatTypeSelectionLocked={true}
isComposerAttachmentUploading={false} isComposerAttachmentUploading={false}
isSendWithoutContextEnabled={false}
onViewportScroll={() => {}} onViewportScroll={() => {}}
onViewportTouchEnd={() => {}} onViewportTouchEnd={() => {}}
onViewportTouchMove={() => {}} onViewportTouchMove={() => {}}
@@ -74,6 +75,7 @@ export function ConversationRoomPane({
onSelectChatType={() => {}} onSelectChatType={() => {}}
onSend={() => {}} onSend={() => {}}
onSendImmediate={() => {}} onSendImmediate={() => {}}
onToggleSendWithoutContext={() => {}}
onClearDraft={() => {}} onClearDraft={() => {}}
onScrollToBottom={() => {}} onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}} onToggleResourceStrip={() => {}}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
@@ -119,67 +119,88 @@ export function useConversationComposerController({
sendChatRequest, sendChatRequest,
scrollViewportToBottom, scrollViewportToBottom,
}: UseConversationComposerControllerOptions) { }: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const handleComposerFilesPicked = useCallback( const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => { async (files: File[]): Promise<ComposerFilePickResult> => {
if (files.length === 0 || isComposerAttachmentUploading) { if (files.length === 0) {
return { items: [] }; return { items: [] };
} }
setIsComposerAttachmentUploading(true); const uploadBatch = async (): Promise<ComposerFilePickResult> => {
const uploadResults = await Promise.allSettled( activeComposerUploadCountRef.current += 1;
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedItems: Array<{ fileName: string; reason: string }> = [];
uploadResults.forEach((result, index) => { if (activeComposerUploadCountRef.current === 1) {
if (result.status === 'fulfilled') { setIsComposerAttachmentUploading(true);
uploadedItems.push(result.value);
return;
} }
const fileName = files[index]?.name || `파일 ${index + 1}`; try {
const reason = const uploadResults = await Promise.allSettled(
result.reason instanceof Error && result.reason.message.trim() files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
? result.reason.message.trim() );
: '업로드 실패'; const uploadedItems: ChatComposerAttachment[] = [];
failedItems.push({ fileName, reason }); const failedItems: Array<{ fileName: string; reason: string }> = [];
});
if (uploadedItems.length > 0) { uploadResults.forEach((result, index) => {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems)); if (result.status === 'fulfilled') {
shouldStickToBottomRef.current = true; uploadedItems.push(result.value);
setShowScrollToBottom(false); return;
} }
if (failedItems.length > 0) { const fileName = files[index]?.name || `파일 ${index + 1}`;
setMessages((previous) => [ const reason =
...previous.slice(-39), result.reason instanceof Error && result.reason.message.trim()
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
setIsComposerAttachmentUploading(false);
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim() ? result.reason.message.trim()
: '업로드 실패', : '업로드 실패';
})), failedItems.push({ fileName, reason });
});
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
if (failedItems.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패',
})),
};
} finally {
activeComposerUploadCountRef.current = Math.max(0, activeComposerUploadCountRef.current - 1);
if (activeComposerUploadCountRef.current === 0) {
setIsComposerAttachmentUploading(false);
}
}
}; };
const queuedUpload = composerUploadQueueRef.current.then(uploadBatch, uploadBatch);
composerUploadQueueRef.current = queuedUpload.catch(() => ({ items: [] }));
return queuedUpload;
}, },
[ [
activeSessionId, activeSessionId,
composerUploadQueueRef,
createLocalMessage, createLocalMessage,
isComposerAttachmentUploading,
mergeComposerAttachments, mergeComposerAttachments,
setComposerAttachments, setComposerAttachments,
setIsComposerAttachmentUploading, setIsComposerAttachmentUploading,

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel'; import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types'; import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = { type UseConversationListDataOptions = {
@@ -16,6 +17,8 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>; setConversationSearch: Dispatch<SetStateAction<string>>;
}; };
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
function mergeConversationItemsPreservingRequestedSession( function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[], nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[], previousItems: ChatConversationSummary[],
@@ -49,51 +52,117 @@ export function useConversationListData({
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]); const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
const [isConversationListLoading, setIsConversationListLoading] = useState(false); const [isConversationListLoading, setIsConversationListLoading] = useState(false);
const [conversationSearch, setConversationSearch] = useState(''); const [conversationSearch, setConversationSearch] = useState('');
const isMountedRef = useRef(true);
const listRequestIdRef = useRef(0);
const pendingRequestRef = useRef<Promise<void> | null>(null);
const loadConversationItems = async () => { const loadConversationItems = useCallback(async (options?: { silent?: boolean }) => {
setIsConversationListLoading(true); if (pendingRequestRef.current) {
return pendingRequestRef.current;
try {
const items = await chatGateway.listConversations();
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} catch {
setConversationItems((previous) => previous);
} finally {
setIsConversationListLoading(false);
} }
};
const requestId = listRequestIdRef.current + 1;
listRequestIdRef.current = requestId;
const isSilent = options?.silent === true;
if (!isSilent) {
setIsConversationListLoading(true);
}
const requestPromise = (async () => {
try {
const items = await chatGateway.listConversations();
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
return;
}
setConversationItems((previous) => {
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
emitChatConversationsUpdated(nextItems);
return nextItems;
});
} catch {
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
return;
}
setConversationItems((previous) => previous);
} finally {
pendingRequestRef.current = null;
if (!isMountedRef.current || listRequestIdRef.current !== requestId || isSilent) {
return;
}
setIsConversationListLoading(false);
}
})();
pendingRequestRef.current = requestPromise;
return requestPromise;
}, [requestedSessionId]);
useEffect(() => { useEffect(() => {
let isCancelled = false; isMountedRef.current = true;
void chatGateway
.listConversations()
.then((items) => {
if (!isCancelled) {
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
}
})
.catch(() => {
if (!isCancelled) {
setConversationItems((previous) => previous);
}
})
.finally(() => {
if (!isCancelled) {
setIsConversationListLoading(false);
}
});
setIsConversationListLoading(true); setIsConversationListLoading(true);
void loadConversationItems();
return () => { return () => {
isCancelled = true; isMountedRef.current = false;
}; };
}, []); }, [loadConversationItems]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let intervalId: number | null = null;
const startPolling = () => {
if (intervalId != null || document.visibilityState !== 'visible') {
return;
}
intervalId = window.setInterval(() => {
void loadConversationItems({ silent: true });
}, CONVERSATION_LIST_POLL_INTERVAL_MS);
};
const stopPolling = () => {
if (intervalId == null) {
return;
}
window.clearInterval(intervalId);
intervalId = null;
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadConversationItems({ silent: true });
startPolling();
return;
}
stopPolling();
};
const handleFocus = () => {
void loadConversationItems({ silent: true });
startPolling();
};
startPolling();
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [loadConversationItems]);
return { return {
conversationItems, conversationItems,

View File

@@ -42,13 +42,14 @@ export function useConversationViewController({
}: UseConversationViewControllerOptions) { }: UseConversationViewControllerOptions) {
const previousSessionIdRef = useRef(activeSessionId); const previousSessionIdRef = useRef(activeSessionId);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null); const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const [activePreviewOverride, setActivePreviewOverride] = useState<PreviewItem | null>(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false); const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const [previewText, setPreviewText] = useState(''); const [previewText, setPreviewText] = useState('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(''); const [previewError, setPreviewError] = useState('');
const [previewContentType, setPreviewContentType] = useState(''); const [previewContentType, setPreviewContentType] = useState('');
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null; const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
useEffect(() => { useEffect(() => {
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId; const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
@@ -64,6 +65,7 @@ export function useConversationViewController({
setComposerAttachments([]); setComposerAttachments([]);
setCopiedMessageId(null); setCopiedMessageId(null);
setActivePreviewId(null); setActivePreviewId(null);
setActivePreviewOverride(null);
setIsPreviewModalOpen(false); setIsPreviewModalOpen(false);
setActiveSystemStatus(null); setActiveSystemStatus(null);
setIsSystemStatusPending(false); setIsSystemStatusPending(false);
@@ -80,7 +82,7 @@ export function useConversationViewController({
]); ]);
useEffect(() => { useEffect(() => {
if (!activePreviewId) { if (!activePreviewId || activePreviewOverride) {
return; return;
} }
@@ -90,7 +92,7 @@ export function useConversationViewController({
setActivePreviewId(null); setActivePreviewId(null);
setIsPreviewModalOpen(false); setIsPreviewModalOpen(false);
}, [activePreviewId, previewItems]); }, [activePreviewId, activePreviewOverride, previewItems]);
useEffect(() => { useEffect(() => {
if (!isPreviewModalOpen || !activePreview) { if (!isPreviewModalOpen || !activePreview) {
@@ -205,6 +207,7 @@ export function useConversationViewController({
previewError, previewError,
previewText, previewText,
setActivePreviewId, setActivePreviewId,
setActivePreviewOverride,
setIsPreviewModalOpen, setIsPreviewModalOpen,
}; };
} }

View File

@@ -261,14 +261,22 @@ export function useConversationViewportController({
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => { const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) { if (!viewport || isLoadingOlderMessages) {
touchStartYRef.current = null; touchStartYRef.current = null;
touchPullActiveRef.current = false; touchPullActiveRef.current = false;
return; return;
} }
const isAtTop = viewport.scrollTop <= 0;
if (isAtTop && hasOlderMessages) {
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true;
return;
}
touchStartYRef.current = event.touches[0]?.clientY ?? null; touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true; touchPullActiveRef.current = false;
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]); }, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => { const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
@@ -279,7 +287,15 @@ export function useConversationViewportController({
const viewport = viewportRef.current; const viewport = viewportRef.current;
const currentY = event.touches[0]?.clientY ?? null; const currentY = event.touches[0]?.clientY ?? null;
if (!viewport || currentY == null || viewport.scrollTop > 0) { if (!viewport || currentY == null) {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
setIsPullToLoadArmed(false);
return;
}
if (viewport.scrollTop > 0) {
touchPullActiveRef.current = false; touchPullActiveRef.current = false;
touchStartYRef.current = null; touchStartYRef.current = null;
setPullToLoadDistance(0); setPullToLoadDistance(0);
@@ -313,7 +329,13 @@ export function useConversationViewportController({
if (shouldLoadOlder) { if (shouldLoadOlder) {
void onLoadOlderMessages(); void onLoadOlderMessages();
} }
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]); }, [
hasOlderMessages,
isLoadingOlderMessages,
isPullToLoadArmed,
onLoadOlderMessages,
resetPullToLoad,
]);
useEffect(() => { useEffect(() => {
if (connectionState === 'disconnected') { if (connectionState === 'disconnected') {

View File

@@ -0,0 +1,67 @@
const CODEX_LIVE_DRAFT_STORAGE_KEY = 'codex-live:draft-bridge';
export type CodexLiveDraftPayload = {
text: string;
source: string;
createdAt: string;
autoSend?: boolean;
sendMode?: 'queue' | 'direct';
};
export function stashCodexLiveDraft(payload: CodexLiveDraftPayload) {
if (typeof window === 'undefined') {
return false;
}
const text = payload.text.trim();
if (!text) {
return false;
}
window.sessionStorage.setItem(
CODEX_LIVE_DRAFT_STORAGE_KEY,
JSON.stringify({
text,
source: payload.source.trim() || 'unknown',
createdAt: payload.createdAt.trim() || new Date().toISOString(),
autoSend: payload.autoSend === true,
sendMode: payload.sendMode === 'direct' ? 'direct' : 'queue',
}),
);
return true;
}
export function consumeCodexLiveDraft() {
if (typeof window === 'undefined') {
return null;
}
const raw = window.sessionStorage.getItem(CODEX_LIVE_DRAFT_STORAGE_KEY);
if (!raw) {
return null;
}
window.sessionStorage.removeItem(CODEX_LIVE_DRAFT_STORAGE_KEY);
try {
const payload = JSON.parse(raw) as Partial<CodexLiveDraftPayload>;
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
if (!text) {
return null;
}
return {
text,
source: typeof payload.source === 'string' ? payload.source.trim() || 'unknown' : 'unknown',
createdAt:
typeof payload.createdAt === 'string' && payload.createdAt.trim()
? payload.createdAt.trim()
: new Date().toISOString(),
autoSend: payload.autoSend === true,
sendMode: payload.sendMode === 'direct' ? 'direct' : 'queue',
} satisfies CodexLiveDraftPayload;
} catch {
return null;
}
}

View File

@@ -88,6 +88,7 @@ function parseRoute(pathname: string): {
first === 'schedule' || first === 'schedule' ||
first === 'history' || first === 'history' ||
first === 'automation-type' || first === 'automation-type' ||
first === 'automation-context' ||
first === 'server-command') first === 'server-command')
) { ) {
return { return {
@@ -155,8 +156,16 @@ function getIsMobileViewport() {
return window.matchMedia('(max-width: 768px)').matches; return window.matchMedia('(max-width: 768px)').matches;
} }
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) { function getIsSidebarOverlayViewport(topMenu: TopMenuKey) {
if (!isMobileViewport) { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia(topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean, topMenu: TopMenuKey) {
if (!isSidebarOverlayViewport) {
return false; return false;
} }
@@ -208,7 +217,10 @@ export function MainLayout() {
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]); const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport()); const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu), resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu), routeState.topMenu),
);
const [isSidebarOverlayViewport, setIsSidebarOverlayViewport] = useState(() =>
getIsSidebarOverlayViewport(routeState.topMenu),
); );
const [contentExpanded, setContentExpanded] = useState(false); const [contentExpanded, setContentExpanded] = useState(false);
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>( const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
@@ -218,7 +230,7 @@ export function MainLayout() {
'working' | 'release-pending-main' | 'automation-failed' | null 'working' | 'release-pending-main' | 'automation-failed' | null
>(routeState.planMenu === 'release' ? 'release-pending-main' : null); >(routeState.planMenu === 'release' ? 'release-pending-main' : null);
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0); const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData; const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
const { chatUnreadCount } = useUnreadCounts(); const { chatUnreadCount } = useUnreadCounts();
useEffect(() => { useEffect(() => {
@@ -240,8 +252,22 @@ export function MainLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu)); const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)');
}, [isMobileViewport, routeState.topMenu]); const updateViewport = () => {
setIsSidebarOverlayViewport(mediaQuery.matches);
};
updateViewport();
mediaQuery.addEventListener('change', updateViewport);
return () => {
mediaQuery.removeEventListener('change', updateViewport);
};
}, [routeState.topMenu]);
useEffect(() => {
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport, routeState.topMenu));
}, [isSidebarOverlayViewport, routeState.topMenu]);
useEffect(() => { useEffect(() => {
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu)); setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
@@ -256,10 +282,10 @@ export function MainLayout() {
useEffect(() => { useEffect(() => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu); const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
if (savedLayoutId && !savedLayouts.some((record) => record.id === savedLayoutId)) { if (savedLayoutId && savedLayoutsReady && !savedLayouts.some((record) => record.id === savedLayoutId)) {
navigate(buildPlayPath('layout'), { replace: true }); navigate(buildPlayPath('layout'), { replace: true });
} }
}, [navigate, routeState.playMenu, savedLayouts]); }, [navigate, routeState.playMenu, savedLayouts, savedLayoutsReady]);
useEffect(() => { useEffect(() => {
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) { if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
@@ -407,6 +433,7 @@ export function MainLayout() {
componentSamples, componentSamples,
widgetSamples, widgetSamples,
savedLayouts, savedLayouts,
savedLayoutsReady,
setSavedLayouts, setSavedLayouts,
searchOptions, searchOptions,
}} }}
@@ -427,21 +454,21 @@ export function MainLayout() {
}} }}
onChangeTopMenu={(menu) => { onChangeTopMenu={(menu) => {
navigate(resolveTopMenuPath(menu, currentDocsFolder)); navigate(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu)); setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu), menu));
}} }}
onOpenPlanQuickFilter={(filter) => { onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter); const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
setActivePlanQuickFilter(filter); setActivePlanQuickFilter(filter);
setPlanQuickFilterRequestKey((previous) => previous + 1); setPlanQuickFilterRequestKey((previous) => previous + 1);
navigate(buildPlansPath(targetPlanMenu)); navigate(buildPlansPath(targetPlanMenu));
setSidebarCollapsed(isMobileViewport); setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans'), 'plans'));
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all'); scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
}} }}
/> />
)} )}
<Layout> <Layout>
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : ( {contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
<MainSidebar <MainSidebar
activeTopMenu={routeState.topMenu} activeTopMenu={routeState.topMenu}
hasAccess={hasAccess} hasAccess={hasAccess}
@@ -461,13 +488,13 @@ export function MainLayout() {
onOpenKeysChange={setSidebarOpenKeys} onOpenKeysChange={setSidebarOpenKeys}
onSelectApiMenu={(key) => { onSelectApiMenu={(key) => {
navigate(buildApisPath(key as ApiSectionKey)); navigate(buildApisPath(key as ApiSectionKey));
if (isMobileViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
onSelectDocsMenu={(key) => { onSelectDocsMenu={(key) => {
navigate(buildDocsPath(key)); navigate(buildDocsPath(key));
if (isMobileViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
@@ -475,20 +502,20 @@ export function MainLayout() {
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null); setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
setPlanQuickFilterRequestKey((previous) => previous + 1); setPlanQuickFilterRequestKey((previous) => previous + 1);
navigate(buildPlansPath(key)); navigate(buildPlansPath(key));
if (isMobileViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
onSelectChatMenu={(key) => { onSelectChatMenu={(key) => {
navigate(buildChatPath(key)); navigate(buildChatPath(key));
if (isMobileViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
onSelectPlayMenu={(key) => { onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key); const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout')); navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
if (isMobileViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
@@ -498,7 +525,7 @@ export function MainLayout() {
/> />
)} )}
{isMobileViewport && !sidebarCollapsed ? null : ( {isSidebarOverlayViewport && !sidebarCollapsed ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}> <MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet /> <Outlet />
</MainContent> </MainContent>

View File

@@ -29,6 +29,7 @@ export type MainLayoutContextValue = {
componentSamples: LoadedSampleEntry[]; componentSamples: LoadedSampleEntry[];
widgetSamples: LoadedSampleEntry[]; widgetSamples: LoadedSampleEntry[];
savedLayouts: SavedLayoutRecord[]; savedLayouts: SavedLayoutRecord[];
savedLayoutsReady: boolean;
setSavedLayouts: (layouts: SavedLayoutRecord[]) => void; setSavedLayouts: (layouts: SavedLayoutRecord[]) => void;
searchOptions: SearchKeywordOption[]; searchOptions: SearchKeywordOption[];
}; };

View File

@@ -157,6 +157,18 @@ export function buildSearchOptions({
}, },
onSelectWindow, onSelectWindow,
} satisfies SearchKeywordOption, } satisfies SearchKeywordOption,
{
id: 'page:plans:automation-context',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-context']}`,
group: 'Page',
keywords: ['plans', 'plan', 'context', 'context type', '컨텍스트', 'Context 유형', '부모 context'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlansPath('automation-context'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{ {
id: 'page:plans:history', id: 'page:plans:history',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`, label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,

View File

@@ -13,6 +13,7 @@ export function useMainLayoutData() {
const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]); const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]);
const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]); const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]);
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]); const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
const [savedLayoutsReady, setSavedLayoutsReady] = useState(false);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -47,11 +48,13 @@ export function useMainLayoutData() {
.then((layouts) => { .then((layouts) => {
if (mounted) { if (mounted) {
setSavedLayouts(layouts); setSavedLayouts(layouts);
setSavedLayoutsReady(true);
} }
}) })
.catch(() => { .catch(() => {
if (mounted) { if (mounted) {
setSavedLayouts([]); setSavedLayouts([]);
setSavedLayoutsReady(true);
} }
}); });
@@ -98,6 +101,7 @@ export function useMainLayoutData() {
widgetSamples, widgetSamples,
docsDocuments, docsDocuments,
savedLayouts, savedLayouts,
savedLayoutsReady,
setSavedLayouts, setSavedLayouts,
docFolders, docFolders,
}; };

View File

@@ -2,6 +2,7 @@ import {
CloseOutlined, CloseOutlined,
CopyOutlined, CopyOutlined,
DeleteOutlined, DeleteOutlined,
DisconnectOutlined,
DownloadOutlined, DownloadOutlined,
DownOutlined, DownOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
@@ -33,11 +34,16 @@ import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer'; import { CodexDiffBlock } from '../../../components/previewer';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController'; import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody'; import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { triggerResourceDownload } from './downloadUtils';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls'; import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers'; import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl'; import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils'; import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types'; import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
import { extractChatMessageParts } from './messageParts';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types';
const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIME_ZONE = 'Asia/Seoul';
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
@@ -80,6 +86,16 @@ type InlinePreviewTarget = {
kind: InlinePreviewKind; kind: InlinePreviewKind;
}; };
type OpenPreviewTarget =
| string
| {
id: string;
label: string;
url: string;
kind: InlinePreviewKind;
source?: 'message' | 'context';
};
type PendingComposerUpload = { type PendingComposerUpload = {
key: string; key: string;
name: string; name: string;
@@ -102,8 +118,14 @@ type MessageRenderPayload = {
previewSourceText: string; previewSourceText: string;
visibleText: string; visibleText: string;
diffBlocks: string[]; diffBlocks: string[];
rankedLinkTargets: RankedLinkPreviewTarget[];
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
}; };
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;
function normalizeInlinePreviewUrl(value: string) { function normalizeInlinePreviewUrl(value: string) {
return normalizeChatResourceUrl(value); return normalizeChatResourceUrl(value);
} }
@@ -167,7 +189,7 @@ function buildInlinePreviewLabel(url: string) {
} }
} }
function buildPreviewFileName(item: PreviewOption) { function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
try { try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim(); const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
@@ -177,10 +199,203 @@ function buildPreviewFileName(item: PreviewOption) {
} }
} }
function normalizeRankedLinkTitle(value: string) {
return value
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function extractRankedLinkTargets(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
const seen = new Set<string>();
const pushRankedLink = (title: string, url: string) => {
const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
const key = `${normalizedTitle}::${normalizedUrl}`;
if (seen.has(key)) {
return;
}
seen.add(key);
rankedLinkTargets.push({
title: normalizedTitle,
url: normalizedUrl,
});
};
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
const trimmedLine = line.trim();
if (!trimmedLine) {
keptLines.push(line);
continue;
}
const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
markdownMatches.forEach((match) => {
const [, label, href] = match;
if (href?.trim()) {
pushRankedLink(label?.trim() || href.trim(), href);
}
});
continue;
}
const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
if (!titleMatch) {
keptLines.push(line);
continue;
}
const collectedLines = [line];
const title = titleMatch[1]?.trim() ?? '';
let url = '';
let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
let cursor = index + 1;
while (cursor < lines.length) {
const candidate = lines[cursor] ?? '';
const trimmedCandidate = candidate.trim();
if (!trimmedCandidate) {
collectedLines.push(candidate);
cursor += 1;
continue;
}
if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
break;
}
const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
if (linkMatch) {
url = linkMatch[1]?.trim() ?? url;
collectedLines.push(candidate);
hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
cursor += 1;
continue;
}
if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
hasRank = true;
collectedLines.push(candidate);
cursor += 1;
continue;
}
break;
}
if (title && url && hasRank) {
pushRankedLink(title, url);
index = cursor - 1;
continue;
}
keptLines.push(...collectedLines);
index = cursor - 1;
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
rankedLinkTargets,
};
}
function buildComposerFilePickKey(file: File) { function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`; return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
} }
function isClipboardImageFile(file: File) {
const normalizedType = String(file.type ?? '').trim().toLowerCase();
if (normalizedType.startsWith('image/')) {
return true;
}
const normalizedName = String(file.name ?? '').trim().toLowerCase();
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
}
function isGeneratedClipboardImageName(file: File) {
const normalizedName = String(file.name ?? '').trim().toLowerCase();
if (!normalizedName) {
return true;
}
return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName);
}
function getClipboardImageMimeRank(file: File) {
const normalizedType = String(file.type ?? '').trim().toLowerCase();
switch (normalizedType) {
case 'image/png':
return 0;
case 'image/jpeg':
return 1;
case 'image/webp':
return 2;
case 'image/gif':
return 3;
case 'image/bmp':
return 4;
case 'image/heic':
case 'image/heif':
return 5;
case 'image/tiff':
case 'image/tif':
return 6;
default:
return 7;
}
}
function resolvePreferredClipboardImageFiles(files: File[]) {
if (files.length <= 1) {
return files;
}
const sortedFiles = [...files]
.sort((left, right) => {
const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);
if (rankDifference !== 0) {
return rankDifference;
}
return right.size - left.size;
})
.slice(0, 1);
if (files.every(isGeneratedClipboardImageName)) {
return sortedFiles;
}
return sortedFiles;
}
function resolveComposerPasteFiles(clipboardData: DataTransfer) {
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file instanceof File)
.filter((file) => file.size > 0);
const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
const imageFiles = candidateFiles.filter(isClipboardImageFile);
const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;
return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> { async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = ''; let responseMessage = '';
@@ -252,7 +467,15 @@ function renderMessageInlineParts(line: string): ReactNode[] {
const href = normalizeInlinePreviewUrl(rawHref.trim()); const href = normalizeInlinePreviewUrl(rawHref.trim());
renderedParts.push( renderedParts.push(
<a key={`${href}-${start}`} href={href} target="_blank" rel="noreferrer"> <a
key={`${href}-${start}`}
href={href}
target="_blank"
rel="noreferrer noopener"
onClick={(event) => {
openChatExternalLink(href, event);
}}
>
{label.trim() || href} {label.trim() || href}
</a>, </a>,
); );
@@ -300,18 +523,28 @@ function renderMessageBody(text: string) {
}); });
} }
function extractMessageRenderPayload(text: string): MessageRenderPayload { function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
const extractedMessageParts = extractChatMessageParts(message.text);
const text = extractedMessageParts.strippedText;
const linkCardTargets = [
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN)) const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim()) .map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value)); .filter((value): value is string => Boolean(value));
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, ''); const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
const visibleText = stripHiddenPreviewTags(previewSourceText); const visibleText = stripHiddenPreviewTags(previewSourceText);
return { return {
previewSourceText, previewSourceText,
visibleText, visibleText,
diffBlocks, diffBlocks,
rankedLinkTargets,
linkCardTargets,
}; };
} }
@@ -320,6 +553,10 @@ function summarizeQueuedText(text: string) {
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized; return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
} }
function normalizeAttachmentName(value: string) {
return String(value ?? '').trim().toLowerCase();
}
function isActivityLogMessage(message: ChatMessage) { function isActivityLogMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
} }
@@ -552,8 +789,11 @@ function InlineMessagePreview({
className="app-chat-preview-card__action" className="app-chat-preview-card__action"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
aria-label="preview 다운로드" aria-label="preview 다운로드"
href={target.url} onClick={() => {
download void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
});
}}
/> />
<Button <Button
type="link" type="link"
@@ -699,6 +939,7 @@ type ChatConversationViewProps = {
isMobileViewport: boolean; isMobileViewport: boolean;
isChatTypeSelectionLocked: boolean; isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean; isComposerAttachmentUploading: boolean;
isSendWithoutContextEnabled: boolean;
onViewportScroll: () => void; onViewportScroll: () => void;
onViewportTouchEnd: () => void; onViewportTouchEnd: () => void;
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void; onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
@@ -709,10 +950,11 @@ type ChatConversationViewProps = {
onSelectChatType: (value: string) => void; onSelectChatType: (value: string) => void;
onSend: () => void; onSend: () => void;
onSendImmediate: () => void; onSendImmediate: () => void;
onToggleSendWithoutContext: () => void;
onClearDraft: () => void; onClearDraft: () => void;
onScrollToBottom: () => void; onScrollToBottom: () => void;
onToggleResourceStrip: () => void; onToggleResourceStrip: () => void;
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void; onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void; onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void; onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void; onCancelMessage: (message: ChatMessage) => void;
@@ -746,6 +988,7 @@ export function ChatConversationView({
isMobileViewport, isMobileViewport,
isChatTypeSelectionLocked, isChatTypeSelectionLocked,
isComposerAttachmentUploading, isComposerAttachmentUploading,
isSendWithoutContextEnabled,
onViewportScroll, onViewportScroll,
onViewportTouchEnd, onViewportTouchEnd,
onViewportTouchMove, onViewportTouchMove,
@@ -756,6 +999,7 @@ export function ChatConversationView({
onSelectChatType, onSelectChatType,
onSend, onSend,
onSendImmediate, onSendImmediate,
onToggleSendWithoutContext,
onClearDraft, onClearDraft,
onScrollToBottom, onScrollToBottom,
onToggleResourceStrip, onToggleResourceStrip,
@@ -1056,11 +1300,17 @@ export function ChatConversationView({
} }
const uploadedAttachmentNames = new Set( const uploadedAttachmentNames = new Set(
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean), composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
);
const resolvedUploads = pendingComposerUploads.filter(
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
); );
const resolvedUploads = pendingComposerUploads.filter((item) => {
const normalizedName = normalizeAttachmentName(item.name);
if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
return false;
}
return item.status === 'uploaded' || item.status === 'failed';
});
if (resolvedUploads.length > 0) { if (resolvedUploads.length > 0) {
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key)); const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
@@ -1071,6 +1321,7 @@ export function ChatConversationView({
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.'; const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const syncPendingComposerUploads = async (files: File[]) => { const syncPendingComposerUploads = async (files: File[]) => {
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
const nextPendingUploads = files.map((file) => ({ const nextPendingUploads = files.map((file) => ({
key: buildComposerFilePickKey(file), key: buildComposerFilePickKey(file),
name: file.name, name: file.name,
@@ -1079,7 +1330,7 @@ export function ChatConversationView({
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key)); const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
setPendingComposerUploads((current) => [ setPendingComposerUploads((current) => [
...current.filter((item) => !pendingKeys.has(item.key)), ...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
...nextPendingUploads, ...nextPendingUploads,
]); ]);
@@ -1135,24 +1386,14 @@ export function ChatConversationView({
if (!clipboardData) { if (!clipboardData) {
return; return;
} }
const files = resolveComposerPasteFiles(clipboardData);
const itemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []);
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
event.preventDefault(); event.preventDefault();
void syncPendingComposerUploads(files);
const uniqueFiles = Array.from(
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
);
void syncPendingComposerUploads(uniqueFiles);
}; };
const dismissPendingComposerUpload = (key: string) => { const dismissPendingComposerUpload = (key: string) => {
@@ -1398,14 +1639,15 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id); const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text); const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
if (isActivityLogMessage(message)) { if (isActivityLogMessage(message)) {
return renderActivityCard(message); return renderActivityCard(message);
} }
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText); const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; const hasPreviewCards =
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
const shouldRenderStandalonePreview = const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const stackClassName = [ const stackClassName = [
@@ -1534,6 +1776,12 @@ export function ChatConversationView({
)} )}
{hasPreviewCards ? ( {hasPreviewCards ? (
<div className="app-chat-message-stack__previews"> <div className="app-chat-message-stack__previews">
{linkCardTargets.map((target) => (
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{rankedLinkTargets.map((target) => (
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{diffBlocks.map((diffText, index) => { {diffBlocks.map((diffText, index) => {
const previewKey = `${message.id}-diff-${index}`; const previewKey = `${message.id}-diff-${index}`;
@@ -1577,12 +1825,20 @@ export function ChatConversationView({
key={previewKey} key={previewKey}
target={target} target={target}
isExpanded={expandedPreviewKey === previewKey} isExpanded={expandedPreviewKey === previewKey}
hasModalPreview={Boolean(matchedPreview)} hasModalPreview
onOpenModalPreview={() => { onOpenModalPreview={() => {
if (matchedPreview) { onOpenPreview(
onOpenPreview(matchedPreview.id, { fullscreen: true }); matchedPreview
return; ? matchedPreview.id
} : {
id: previewKey,
label: target.label,
url: target.url,
kind: target.kind,
source: 'message',
},
{ fullscreen: true },
);
}} }}
onToggle={() => { onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey)); setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
@@ -1595,7 +1851,6 @@ export function ChatConversationView({
</div> </div>
); );
})} })}
</div> </div>
{activeSystemStatus ? ( {activeSystemStatus ? (
@@ -1656,6 +1911,17 @@ export function ChatConversationView({
</div> </div>
<div className="app-chat-panel__composer-actions"> <div className="app-chat-panel__composer-actions">
<div className="app-chat-panel__composer-action-buttons"> <div className="app-chat-panel__composer-action-buttons">
<Button
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
className={`app-chat-panel__composer-contextless-toggle${
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
}`}
icon={<DisconnectOutlined />}
aria-label={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
title={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
onClick={onToggleSendWithoutContext}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button <Button
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
aria-label="즉시 요청" aria-label="즉시 요청"

View File

@@ -0,0 +1,48 @@
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { openChatExternalLink } from './linkNavigation';
import type { ChatMessagePart } from './types';
export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePart, { type: 'link_card' }> }) {
return (
<section className="app-chat-preview-card app-chat-preview-card--link-card">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
<LinkOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.title}</span>
<span className="app-chat-preview-card__kind">link card</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="link"
size="small"
className="app-chat-preview-card__open-link"
icon={<ExportOutlined />}
onClick={(event) => {
void openChatExternalLink(target.url, event);
}}
>
{target.actionLabel?.trim() || '열기'}
</Button>
</div>
</div>
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
<a
className="app-chat-preview-card__ranked-link-anchor"
href={target.url}
target="_blank"
rel="noreferrer noopener"
onClick={(event) => {
openChatExternalLink(target.url, event);
}}
>
{target.url}
</a>
</div>
</section>
);
}

View File

@@ -9,7 +9,7 @@ import {
PictureOutlined, PictureOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd'; import { Alert, Button, Empty, Space, Spin, Typography, message } from 'antd';
import { InlineImage } from '../../../components/common/InlineImage'; import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent'; import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer'; import { CodexDiffBlock } from '../../../components/previewer';
@@ -326,6 +326,13 @@ export function ChatPreviewBody({
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />; return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
} }
const handleDownloadResource = () => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
void triggerResourceDownload(target.url, fileName).catch((error: unknown) => {
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
});
};
if (target.kind === 'file') { if (target.kind === 'file') {
return ( return (
<div className="app-chat-panel__preview-file"> <div className="app-chat-panel__preview-file">
@@ -334,15 +341,7 @@ export function ChatPreviewBody({
</Paragraph> </Paragraph>
<Space wrap> <Space wrap>
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} /> <Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button <Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
</Space> </Space>
</div> </div>
); );
@@ -414,15 +413,7 @@ export function ChatPreviewBody({
</Paragraph> </Paragraph>
<Space wrap> <Space wrap>
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} /> <Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button <Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
</Space> </Space>
</div> </div>
); );

View File

@@ -0,0 +1,52 @@
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { openChatExternalLink } from './linkNavigation';
export type RankedLinkPreviewTarget = {
title: string;
url: string;
};
export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTarget }) {
return (
<section className="app-chat-preview-card app-chat-preview-card--ranked-link">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
<LinkOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.title}</span>
<span className="app-chat-preview-card__kind">link preview</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="link"
size="small"
className="app-chat-preview-card__open-link"
icon={<ExportOutlined />}
onClick={(event) => {
void openChatExternalLink(target.url, event);
}}
>
</Button>
</div>
</div>
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
<a
className="app-chat-preview-card__ranked-link-anchor"
href={target.url}
target="_blank"
rel="noreferrer noopener"
onClick={(event) => {
openChatExternalLink(target.url, event);
}}
>
{target.url}
</a>
</div>
</section>
);
}

View File

@@ -24,7 +24,74 @@ export function shouldOpenDownloadInNewWindow() {
return isStandaloneDisplayMode() && isMobileLikeViewport(); return isStandaloneDisplayMode() && isMobileLikeViewport();
} }
export function triggerResourceDownload(url: string, fileName?: string) { function decodeDownloadFileName(value: string) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function resolveFileNameFromUrl(url: string) {
try {
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : 'https://local.invalid');
return decodeDownloadFileName(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '');
} catch {
return '';
}
}
function parseContentDispositionFileName(headerValue: string | null) {
const normalized = String(headerValue ?? '').trim();
if (!normalized) {
return '';
}
const utf8Match = normalized.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeDownloadFileName(utf8Match[1]);
}
const quotedMatch = normalized.match(/filename="([^"]+)"/i);
if (quotedMatch?.[1]) {
return decodeDownloadFileName(quotedMatch[1]);
}
const plainMatch = normalized.match(/filename=([^;]+)/i);
return plainMatch?.[1] ? decodeDownloadFileName(plainMatch[1].replace(/^["']|["']$/g, '')) : '';
}
function isHtmlFileName(fileName: string) {
return /\.html?$/i.test(fileName.trim());
}
function downloadBlob(blob: Blob, fileName: string) {
if (typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => {
URL.revokeObjectURL(objectUrl);
}, 0);
}
function triggerAnchorDownload(url: string, fileName?: string) {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
throw new Error('download-unavailable'); throw new Error('download-unavailable');
} }
@@ -45,3 +112,57 @@ export function triggerResourceDownload(url: string, fileName?: string) {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
function buildDownloadErrorMessage(response: Response) {
if (response.status === 401) {
return '인증이 없어 파일을 내려받지 못했습니다.';
}
if (response.status === 403) {
return '권한이 없어 파일을 내려받지 못했습니다.';
}
if (response.status === 404) {
return '파일을 찾지 못했습니다.';
}
return `다운로드에 실패했습니다. (${response.status})`;
}
export async function triggerResourceDownload(url: string, fileName?: string) {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const parsedUrl = new URL(url, window.location.href);
const preferredFileName = fileName?.trim() || resolveFileNameFromUrl(parsedUrl.toString()) || 'resource';
if (parsedUrl.origin !== window.location.origin) {
triggerAnchorDownload(parsedUrl.toString(), preferredFileName);
return;
}
const response = await fetch(parsedUrl.toString(), {
credentials: 'include',
});
if (!response.ok) {
throw new Error(buildDownloadErrorMessage(response));
}
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
const contentDisposition = response.headers.get('content-disposition');
const responseFileName = parseContentDispositionFileName(contentDisposition);
const resolvedFileName = responseFileName || preferredFileName;
const blob = await response.blob();
if (contentType.includes('text/html') && !isHtmlFileName(resolvedFileName)) {
const htmlPreview = (await blob.text()).trimStart().toLowerCase();
if (htmlPreview.startsWith('<!doctype html') || htmlPreview.startsWith('<html') || htmlPreview.includes('<head')) {
throw new Error('실제 파일 대신 앱 HTML이 반환되어 다운로드를 중단했습니다.');
}
}
downloadBlob(blob, resolvedFileName);
}

View File

@@ -0,0 +1,65 @@
const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at';
const CHAT_EXTERNAL_LINK_TTL_MS = 15_000;
type LinkNavigationEvent = {
preventDefault?: () => void;
stopPropagation?: () => void;
};
function canUseSessionStorage() {
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
}
function persistExternalLinkOpenTimestamp(openedAt: number) {
if (!canUseSessionStorage()) {
return;
}
window.sessionStorage.setItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY, String(openedAt));
}
function clearExternalLinkOpenTimestamp() {
if (!canUseSessionStorage()) {
return;
}
window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
}
export function shouldSkipForegroundResyncAfterExternalLink() {
if (!canUseSessionStorage()) {
return false;
}
const rawOpenedAt = window.sessionStorage.getItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
clearExternalLinkOpenTimestamp();
if (!rawOpenedAt) {
return false;
}
const openedAt = Number(rawOpenedAt);
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
}
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (typeof window === 'undefined') {
return;
}
persistExternalLinkOpenTimestamp(Date.now());
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer');
if (openedWindow) {
return;
}
const anchor = document.createElement('a');
anchor.href = url;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.click();
}

View File

@@ -0,0 +1,164 @@
import type { ChatMessagePart } from './types';
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\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;
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
return '';
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return false;
}
if (/^https?:\/\//i.test(normalized)) {
return !hasKnownFileExtension(normalized);
}
return !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
try {
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return lastSegment || parsed.hostname || normalizeText(url);
} catch {
return normalizeText(url);
}
}
function normalizeStandaloneTitle(value: string) {
return value
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
.replace(/[`'"]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
if (candidate) {
return candidate;
}
}
return buildFallbackLinkTitle(url);
}
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const segments = rawBody
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length < 2) {
return null;
}
const [rawTitle, rawUrl, rawActionLabel] = segments;
const title = normalizeText(rawTitle);
const url = normalizeUrl(rawUrl);
const actionLabel = normalizeText(rawActionLabel) || null;
if (!title || !url) {
return null;
}
return {
type: 'link_card',
title,
url,
actionLabel,
};
}
export function extractChatMessageParts(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
const parts: ChatMessagePart[] = [];
const seenLinkKeys = new Set<string>();
const pushPart = (nextPart: ChatMessagePart | null) => {
if (!nextPart) {
return false;
}
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
if (seenLinkKeys.has(dedupeKey)) {
return true;
}
seenLinkKeys.add(dedupeKey);
parts.push(nextPart);
return true;
};
for (const line of lines) {
const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) {
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
if (markdownLinkMatch) {
const [, rawTitle, rawUrl] = markdownLinkMatch;
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
continue;
}
}
}
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
if (standaloneUrlMatch) {
const rawUrl = standaloneUrlMatch[1] ?? '';
if (isStructuredLinkCardCandidate(rawUrl)) {
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
continue;
}
}
}
keptLines.push(line);
continue;
}
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
keptLines.push(line);
}
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
parts,
};
}

View File

@@ -1,5 +1,6 @@
import { normalizeChatResourceUrl } from './chatResourceUrl'; import { normalizeChatResourceUrl } from './chatResourceUrl';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls'; import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractChatMessageParts } from './messageParts';
import { extractHiddenPreviewUrls } from './previewMarkers'; import { extractHiddenPreviewUrls } from './previewMarkers';
import type { ChatMessage } from './types'; import type { ChatMessage } from './types';
@@ -106,7 +107,21 @@ export function extractPreviewItems(messages: ChatMessage[]) {
const orderedMessages = [...messages].reverse(); const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => { orderedMessages.forEach((message) => {
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)]; const extractedMessageParts = extractChatMessageParts(message.text);
const structuredLinkUrls = [
...(Array.isArray(message.parts) ? message.parts : []),
...extractedMessageParts.parts,
]
.filter(
(part): part is Extract<(typeof extractedMessageParts.parts)[number], { type: 'link_card' }> =>
part.type === 'link_card' && Boolean(part.url),
)
.map((part) => part.url);
const matches = [
...extractAutoDetectedPreviewUrls(message.text),
...extractHiddenPreviewUrls(message.text),
...structuredLinkUrls,
];
matches.forEach((matchedUrl) => { matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl); const normalizedUrl = normalizePreviewUrl(matchedUrl);

View File

@@ -1,5 +1,13 @@
import type { ErrorLogItem } from '../errorLogApi'; import type { ErrorLogItem } from '../errorLogApi';
export type ChatMessagePart =
| {
type: 'link_card';
title: string;
url: string;
actionLabel?: string | null;
};
export type ChatMessage = { export type ChatMessage = {
id: number; id: number;
author: 'codex' | 'system' | 'user'; author: 'codex' | 'system' | 'user';
@@ -8,6 +16,7 @@ export type ChatMessage = {
clientRequestId?: string | null; clientRequestId?: string | null;
deliveryStatus?: 'retrying' | 'failed' | null; deliveryStatus?: 'retrying' | 'failed' | null;
retryCount?: number; retryCount?: number;
parts?: ChatMessagePart[];
}; };
export type ChatComposerAttachment = { export type ChatComposerAttachment = {

View File

@@ -28,6 +28,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
schedule: '스케줄', schedule: '스케줄',
history: '이력', history: '이력',
'automation-type': '자동화 유형', 'automation-type': '자동화 유형',
'automation-context': 'Context 유형',
'server-command': 'Command', 'server-command': 'Command',
}; };
@@ -52,6 +53,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
schedule: 'plan-menu-schedule', schedule: 'plan-menu-schedule',
history: 'plan-menu-history', history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type', 'automation-type': 'plan-menu-automation-type',
'automation-context': 'plan-menu-automation-context',
'server-command': 'plan-menu-server-command', 'server-command': 'plan-menu-server-command',
}; };

View File

@@ -113,6 +113,18 @@ export function buildMainViewSearchOptions({
}, },
onSelectWindow, onSelectWindow,
}, },
{
id: 'page:plans:automation-context',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-context']}`,
group: 'Page',
keywords: ['plans', 'plan', 'context', 'context type', '컨텍스트', 'Context 유형', '부모 context'],
onSelect: () => {
setActiveTopMenu('plans');
setSelectedPlanMenu('automation-context');
setFocusedComponentId(null);
},
onSelectWindow,
},
...(hasAccess ...(hasAccess
? [ ? [
{ {

View File

@@ -77,6 +77,9 @@ export type ClientNotificationPayload = {
body: string; body: string;
data?: Record<string, string>; data?: Record<string, string>;
threadId?: string; threadId?: string;
targetClientIds?: string[];
targetAppOrigins?: string[];
targetAppDomains?: string[];
}; };
export type ClientNotificationSendResult = { export type ClientNotificationSendResult = {
@@ -100,8 +103,26 @@ export type ClientNotificationSendResult = {
export type PwaNotificationTokenPayload = { export type PwaNotificationTokenPayload = {
token: string; token: string;
deviceId?: string; deviceId?: string;
appOrigin?: string;
appDomain?: string;
}; };
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent'; export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent';
export type NotificationMessageListStatus = 'all' | 'unread'; export type NotificationMessageListStatus = 'all' | 'unread';
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated'; export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
@@ -724,6 +745,8 @@ export async function registerWebPushSubscription(
subscription, subscription,
deviceId, deviceId,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
appOrigin: getCurrentAppOrigin(),
appDomain: getCurrentAppDomain(),
enabled: true, enabled: true,
}), }),
}); });
@@ -744,6 +767,8 @@ export async function registerPwaNotificationToken(payload: PwaNotificationToken
body: JSON.stringify({ body: JSON.stringify({
token: payload.token, token: payload.token,
deviceId: payload.deviceId, deviceId: payload.deviceId,
appOrigin: payload.appOrigin || getCurrentAppOrigin(),
appDomain: payload.appDomain || getCurrentAppDomain(),
enabled: true, enabled: true,
}), }),
}); });

View File

@@ -1,4 +1,5 @@
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage'; import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
import { AutomationContextManagementPage } from '../AutomationContextManagementPage';
import { BoardPage } from '../../../features/board'; import { BoardPage } from '../../../features/board';
import { HistoryPage } from '../../../features/history'; import { HistoryPage } from '../../../features/history';
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard'; import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
@@ -62,6 +63,14 @@ export function PlansPage() {
); );
} }
if (selectedPlanMenu === 'automation-context') {
return (
<div className="app-main-panel">
<AutomationContextManagementPage />
</div>
);
}
if (selectedPlanMenu === 'server-command') { if (selectedPlanMenu === 'server-command') {
return ( return (
<div className="app-main-panel"> <div className="app-main-panel">

View File

@@ -1,16 +1,22 @@
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView'; import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
import { MemoLayoutPage } from '../../../features/layout/memo';
import { useMainLayoutContext } from '../layout/MainLayoutContext'; import { useMainLayoutContext } from '../layout/MainLayoutContext';
import { resolveSavedLayoutIdFromMenuKey } from '../routes'; import { resolveSavedLayoutIdFromMenuKey } from '../routes';
export function PlayPage() { export function PlayPage() {
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext(); const { selectedPlayMenu, savedLayouts, setSavedLayouts } = useMainLayoutContext();
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu); const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
const selectedSavedLayout = selectedSavedLayoutId
? savedLayouts.find((layout) => layout.id === selectedSavedLayoutId) ?? null
: null;
const isMemoLayout = selectedSavedLayout?.name === '메모';
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play'; const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
return ( return (
<div className={panelClassName}> <div className={panelClassName}>
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null} {selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedSavedLayoutId ? ( {selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
{selectedSavedLayoutId && !isMemoLayout ? (
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} /> <LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
) : null} ) : null}
</div> </div>

View File

@@ -16,6 +16,7 @@ export type PlanSectionKey =
| 'schedule' | 'schedule'
| 'history' | 'history'
| 'automation-type' | 'automation-type'
| 'automation-context'
| 'server-command'; | 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage'; export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout'; export type PlaySectionKey = 'layout';
@@ -49,6 +50,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
schedule: '스케줄', schedule: '스케줄',
history: '이력', history: '이력',
'automation-type': '자동화 유형', 'automation-type': '자동화 유형',
'automation-context': 'Context 유형',
'server-command': 'Command', 'server-command': 'Command',
}; };
@@ -68,6 +70,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
schedule: 'plan-menu-schedule', schedule: 'plan-menu-schedule',
history: 'plan-menu-history', history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type', 'automation-type': 'plan-menu-automation-type',
'automation-context': 'plan-menu-automation-context',
'server-command': 'plan-menu-server-command', 'server-command': 'plan-menu-server-command',
}; };
@@ -203,6 +206,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
key: 'automation-type', key: 'automation-type',
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']), label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
}, },
{
key: 'automation-context',
label: renderPlanMenuLabel('automation-context', PLAN_SIDEBAR_LABELS['automation-context']),
},
], ],
}, },
{ {

View File

@@ -0,0 +1,22 @@
.input-base-sample-preview {
display: block;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
}
.input-base-sample-preview__control.ant-input {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100% !important;
min-height: 100%;
min-height: 44px;
max-height: none;
margin: 0;
border-radius: 14px;
padding-inline: 14px;
box-shadow: none;
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core'; import type { SampleMeta } from '../../../../../widgets/core';
import { InputUI } from '../InputUI'; import { InputUI } from '../InputUI';
import './BaseSample.css';
export const sampleMeta: SampleMeta = { export const sampleMeta: SampleMeta = {
id: 'input-base', id: 'input-base',
@@ -18,12 +19,15 @@ export function Sample() {
const [value, setValue] = useState('초기값'); const [value, setValue] = useState('초기값');
return ( return (
<InputUI <div className="input-base-sample-preview">
value={value} <InputUI
placeholder="입력 후 Enter 또는 blur" className="input-base-sample-preview__control"
onChange={(event) => { value={value}
setValue(event.target.value); placeholder="입력 후 Enter 또는 blur"
}} onChange={(event) => {
/> setValue(event.target.value);
}}
/>
</div>
); );
} }

View File

@@ -38,7 +38,7 @@ export function Sample() {
}} }}
/> />
<Text> : {committedValue}</Text> <Text> : {committedValue}</Text>
<Text type="secondary"> : {commitCount}</Text> <Text type="secondary">{`확정 횟수: ${commitCount}`}</Text>
</Flex> </Flex>
</Card> </Card>
); );

View File

@@ -12,6 +12,7 @@ export function SelectUI({
value, value,
defaultValue, defaultValue,
onChange, onChange,
formatLabel,
showSearch = true, showSearch = true,
allowClear = true, allowClear = true,
placeholder = '항목을 선택하세요', placeholder = '항목을 선택하세요',
@@ -21,10 +22,10 @@ export function SelectUI({
() => () =>
data.map((item) => ({ data.map((item) => ({
value: item.code, value: item.code,
label: item.value, label: formatLabel ? formatLabel(item) : item.value,
item, item,
})), })),
[data], [data, formatLabel],
); );
const itemMap = useMemo( const itemMap = useMemo(

View File

@@ -13,4 +13,5 @@ export type SelectUIProps = Omit<
value?: string; value?: string;
defaultValue?: string; defaultValue?: string;
onChange?: (code?: string, item?: SelectOptionItem) => void; onChange?: (code?: string, item?: SelectOptionItem) => void;
formatLabel?: (item: SelectOptionItem) => string;
}; };

View File

@@ -7,8 +7,33 @@
- 컴포넌트 샘플 레이아웃 - 컴포넌트 샘플 레이아웃
- 위젯 샘플 레이아웃 - 위젯 샘플 레이아웃
- Markdown preview 리스트 레이아웃 - Markdown preview 리스트 레이아웃
- `Layout Editor`와 저장 레이아웃 흐름
## 규칙 ## 규칙
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치 - 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토 - 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
## Layout Editor 기준
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
용어 기준:
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
허용 범위:
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
금지 해석:
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.

View File

@@ -0,0 +1,240 @@
.memo-layout-page {
width: 100%;
height: 100%;
min-height: 0;
background:
radial-gradient(circle at top left, rgba(250, 204, 21, 0.18), transparent 30%),
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
}
.memo-layout-page__splitter,
.memo-layout-page__splitter .ant-splitter-panel {
width: 100%;
height: 100%;
min-height: 0;
}
.memo-layout-page__pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
padding: 18px;
box-sizing: border-box;
}
.memo-layout-page__pane--title {
align-items: stretch;
}
.memo-layout-page__title-input.ant-input {
align-self: stretch;
width: 100%;
height: 100%;
padding: 24px 26px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
box-shadow:
0 22px 48px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
color: #0f172a;
font-size: clamp(26px, 4vw, 40px);
font-weight: 700;
line-height: 1.15;
}
.memo-layout-page__title-input.ant-input::placeholder {
color: rgba(100, 116, 139, 0.7);
}
.memo-layout-page__pane--memo {
flex-direction: column;
gap: 12px;
}
.memo-layout-page__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.memo-layout-page__toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.memo-layout-page__toolbar .ant-btn {
width: 32px;
min-width: 32px;
height: 32px;
border-radius: 12px;
color: #475569;
}
.memo-layout-page__toolbar .ant-btn:not(:disabled):hover {
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
}
.memo-layout-page__body {
display: flex;
flex: 1 1 auto;
gap: 12px;
min-height: 0;
}
.memo-layout-page__body--list-open .memo-layout-page__editor {
border-top-left-radius: 22px;
border-bottom-left-radius: 22px;
}
.memo-layout-page__list-shell {
flex: 0 0 260px;
min-width: 220px;
min-height: 0;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 24px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.memo-layout-page__empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 0;
}
.memo-layout-page__list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px;
overflow: auto;
box-sizing: border-box;
}
.memo-layout-page__list-item {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 12px 14px;
border: 0;
border-radius: 18px;
background: rgba(248, 250, 252, 0.96);
text-align: left;
cursor: pointer;
}
.memo-layout-page__list-item:hover {
background: rgba(241, 245, 249, 1);
}
.memo-layout-page__list-item--active {
background: rgba(254, 240, 138, 0.42);
}
.memo-layout-page__list-time {
color: rgba(100, 116, 139, 0.94);
font-size: 12px;
}
.memo-layout-page__list-preview {
color: #0f172a;
font-size: 14px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.memo-layout-page__editor {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid rgba(245, 158, 11, 0.18);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.42)),
repeating-linear-gradient(
180deg,
rgba(255, 248, 216, 0.98) 0,
rgba(255, 248, 216, 0.98) 37px,
rgba(236, 221, 177, 0.78) 37px,
rgba(236, 221, 177, 0.78) 38px
);
box-shadow:
0 18px 44px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
overflow: hidden;
}
.memo-layout-page__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
padding: 14px 18px 0;
color: rgba(100, 116, 139, 0.92);
font-size: 12px;
}
.memo-layout-page__meta > :first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memo-layout-page__textarea.ant-input {
flex: 1 1 auto;
min-height: 0;
padding: 10px 18px 32px;
color: #3f3a2f;
font-size: 16px;
line-height: 38px;
background: transparent;
resize: none;
}
.memo-layout-page__textarea.ant-input::placeholder {
color: rgba(120, 113, 91, 0.72);
}
@media (max-width: 768px) {
.memo-layout-page__pane {
padding: 12px;
}
.memo-layout-page__body {
flex-direction: column;
}
.memo-layout-page__list-shell {
flex: 0 0 180px;
min-width: 0;
}
.memo-layout-page__title-input.ant-input {
padding: 18px 20px;
border-radius: 22px;
font-size: 24px;
}
.memo-layout-page__editor {
border-radius: 22px;
}
}

View File

@@ -0,0 +1,345 @@
import {
CheckOutlined,
DeleteOutlined,
LeftOutlined,
PlusOutlined,
RightOutlined,
SaveOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Button, Empty, Input, Modal, Splitter, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { InputUI } from '../../../components/inputs/primitives/input';
import {
createTextMemoNote,
deleteTextMemoNote,
fetchTextMemoNotes,
updateTextMemoNote,
type TextMemoNoteRecord,
} from '../../../widgets/text-memo-widget/textMemoApi';
import './MemoLayoutPage.css';
type MemoLayoutPageProps = {
layoutId: string;
};
type MemoNote = TextMemoNoteRecord;
const PRIMARY_SIZE = '42%';
const PRIMARY_MIN = '24%';
const SECONDARY_MIN = '20%';
const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200;
function getFirstLine(value: string) {
const [firstLine = ''] = value.split(/\r?\n/u);
return firstLine;
}
function replaceFirstLine(body: string, nextTitle: string) {
const normalizedTitle = nextTitle.trim();
const lineBreakIndex = body.search(/\r?\n/u);
if (lineBreakIndex < 0) {
return normalizedTitle;
}
const nextTail = body.slice(lineBreakIndex);
return `${normalizedTitle}${nextTail}`;
}
function getPreviewText(body: string) {
const preview = body.replace(/\s+/gu, ' ').trim();
return preview || '새 메모';
}
function formatMemoTimestamp(value: string) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
export function MemoLayoutPage({ layoutId }: MemoLayoutPageProps) {
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [notes, setNotes] = useState<MemoNote[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [body, setBody] = useState('');
const [isListOpen, setIsListOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
setIsLoading(true);
try {
const items = await fetchTextMemoNotes();
if (cancelled) {
return;
}
setNotes(items);
if (items[0]) {
setSelectedId(items[0].id);
setBody(items[0].body);
} else {
setSelectedId(null);
setBody('');
}
} catch (error) {
if (!cancelled) {
void messageApi.error(error instanceof Error ? error.message : '메모를 불러오지 못했습니다.');
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [messageApi]);
const selectedIndex = useMemo(
() => (selectedId ? notes.findIndex((note) => note.id === selectedId) : -1),
[notes, selectedId],
);
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
const inputValue = getFirstLine(body);
const hasDraft = body.trim().length > 0;
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
const selectNote = (noteId: string) => {
const nextNote = notes.find((item) => item.id === noteId);
if (!nextNote) {
return;
}
setSelectedId(nextNote.id);
setBody(nextNote.body);
};
const moveSelection = (direction: -1 | 1) => {
if (notes.length === 0) {
return;
}
const fallbackIndex = direction > 0 ? 0 : notes.length - 1;
const nextIndex = selectedIndex < 0 ? fallbackIndex : (selectedIndex + direction + notes.length) % notes.length;
const nextNote = notes[nextIndex];
if (!nextNote) {
return;
}
setSelectedId(nextNote.id);
setBody(nextNote.body);
};
const handleCreate = () => {
setSelectedId(null);
setBody('');
setIsListOpen(false);
};
const handleSave = async () => {
const trimmedBody = body.trim();
if (!trimmedBody || isSaving) {
return;
}
setIsSaving(true);
try {
if (selectedNote) {
const updated = await updateTextMemoNote(selectedNote.id, { body: trimmedBody });
const nextNotes = [updated, ...notes.filter((note) => note.id !== updated.id)].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(updated.id);
setBody(updated.body);
} else {
const created = await createTextMemoNote({ body: trimmedBody });
const nextNotes = [created, ...notes].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(created.id);
setBody(created.body);
}
void messageApi.success('저장됨');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '메모 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedNote && !hasDraft) {
return;
}
void modalApi.confirm({
title: selectedNote ? '선택한 메모를 삭제할까요?' : '작성 중인 메모를 삭제할까요?',
content: selectedNote ? '삭제 후 되돌릴 수 없습니다.' : '작성 중인 내용이 사라집니다.',
okText: '삭제',
cancelText: '취소',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
async onOk() {
if (!selectedNote) {
setBody('');
return;
}
await deleteTextMemoNote(selectedNote.id);
const nextNotes = notes.filter((note) => note.id !== selectedNote.id);
const fallbackNote = nextNotes[0] ?? null;
setNotes(nextNotes);
setSelectedId(fallbackNote?.id ?? null);
setBody(fallbackNote?.body ?? '');
void messageApi.success('삭제됨');
},
});
};
return (
<div className="memo-layout-page" data-layout-id={layoutId}>
{contextHolder}
{modalContextHolder}
<Splitter layout="vertical" className="memo-layout-page__splitter">
<Splitter.Panel size={PRIMARY_SIZE} min={PRIMARY_MIN} resizable>
<section className="memo-layout-page__pane memo-layout-page__pane--title">
<InputUI
value={inputValue}
placeholder="제목"
className="memo-layout-page__title-input"
onChange={(event) => {
const nextValue = event.target.value.slice(0, MAX_BODY_LENGTH);
setBody((previousBody) => replaceFirstLine(previousBody, nextValue));
}}
/>
</section>
</Splitter.Panel>
<Splitter.Panel min={SECONDARY_MIN} resizable>
<section className="memo-layout-page__pane memo-layout-page__pane--memo">
<div className="memo-layout-page__toolbar" role="toolbar" aria-label="메모 도구">
<div className="memo-layout-page__toolbar-group">
<Button type="text" aria-label="새 메모" icon={<PlusOutlined />} onClick={handleCreate} />
<Button
type={isListOpen ? 'default' : 'text'}
aria-label="메모 목록"
icon={<UnorderedListOutlined />}
onClick={() => {
setIsListOpen((previous) => !previous);
}}
/>
<Button
type="text"
aria-label="이전 메모"
icon={<LeftOutlined />}
disabled={notes.length === 0}
onClick={() => {
moveSelection(-1);
}}
/>
<Button
type="text"
aria-label="다음 메모"
icon={<RightOutlined />}
disabled={notes.length === 0}
onClick={() => {
moveSelection(1);
}}
/>
</div>
<div className="memo-layout-page__toolbar-group">
<Button
type="text"
aria-label="삭제"
icon={<DeleteOutlined />}
disabled={!selectedNote && !hasDraft}
onClick={handleDelete}
/>
<Button
type="text"
aria-label="저장"
icon={isDirty ? <SaveOutlined /> : <CheckOutlined />}
disabled={!hasDraft || isSaving || !isDirty}
onClick={() => {
void handleSave();
}}
/>
</div>
</div>
<div className={`memo-layout-page__body${isListOpen ? ' memo-layout-page__body--list-open' : ''}`}>
{isListOpen ? (
<div className="memo-layout-page__list-shell">
{notes.length === 0 ? (
<div className="memo-layout-page__empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={false} />
</div>
) : (
<div className="memo-layout-page__list">
{notes.map((note) => (
<button
key={note.id}
type="button"
className={`memo-layout-page__list-item${
note.id === selectedId ? ' memo-layout-page__list-item--active' : ''
}`}
onClick={() => {
selectNote(note.id);
setIsListOpen(false);
}}
>
<span className="memo-layout-page__list-time">{formatMemoTimestamp(note.updatedAt)}</span>
<span className="memo-layout-page__list-preview">{getPreviewText(note.body)}</span>
</button>
))}
</div>
)}
</div>
) : null}
<div className="memo-layout-page__editor">
<div className="memo-layout-page__meta">
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : ''}</span>
<span>{body.length}/{MAX_BODY_LENGTH}</span>
</div>
<Input.TextArea
value={body}
placeholder="메모 입력"
className="memo-layout-page__textarea"
autoSize={false}
disabled={isLoading}
maxLength={MAX_BODY_LENGTH}
onChange={(event) => {
setBody(event.target.value);
}}
/>
</div>
</div>
</section>
</Splitter.Panel>
</Splitter>
</div>
);
}

View File

@@ -0,0 +1 @@
export { MemoLayoutPage } from './MemoLayoutPage';

View File

@@ -0,0 +1,97 @@
.stock-alert-layout {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.stock-alert-layout__filter {
flex: 0 0 auto;
height: auto;
min-height: auto;
justify-content: center;
padding: 16px;
}
.stock-alert-layout__filter .ant-select {
width: 100%;
}
.stock-alert-layout__grid {
gap: 12px;
padding: 12px;
}
.stock-alert-layout__toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.stock-alert-layout__toolbar .ant-btn {
min-width: 40px;
}
.stock-alert-layout__surface {
flex: 1;
min-height: 0;
}
.stock-alert-layout__surface.ag-theme-quartz {
--ag-font-size: 13px;
--ag-border-color: #d9d9d9;
--ag-header-background-color: #fafafa;
--ag-row-border-color: #f0f0f0;
width: 100%;
height: 100%;
border: 1px solid #f0f0f0;
border-radius: 14px;
overflow: hidden;
}
.stock-alert-layout__search-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.stock-alert-layout__search-modal .ant-table-wrapper {
min-height: 0;
}
.stock-alert-layout__change-rate--up {
color: #cf1322;
font-weight: 600;
}
.stock-alert-layout__change-rate--down {
color: #0958d9;
font-weight: 600;
}
.stock-alert-layout__change-rate--flat {
color: #595959;
}
.stock-alert-layout__alert-type-editor {
display: flex;
align-items: center;
min-height: 100%;
width: 100%;
cursor: pointer;
}
.stock-alert-layout__alert-type-select {
width: 100%;
}
.stock-alert-layout__alert-type-select .ant-select-selector {
min-height: 32px;
border-radius: 8px;
}
.stock-alert-layout__alert-type-editor.is-open .stock-alert-layout__alert-type-select .ant-select-selector {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(5 145 255 / 0.12);
}

View File

@@ -0,0 +1,702 @@
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, Flex, Input, Modal, Select, Table, message } from 'antd';
import type {
CellValueChangedEvent,
ColDef,
GridApi,
ICellRendererParams,
RowSelectionOptions,
ValueFormatterParams,
} from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import {
createContext,
useDeferredValue,
use,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
import {
deleteStockAlertRow,
fetchStockAlerts,
searchStockAlertCandidates,
saveStockAlertRows,
type StockAlertDraftRow,
type StockAlertFilterValue,
type StockAlertSearchItem,
type StockAlertType,
} from './stockAlertApi';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
import './StockAlertLayout.css';
ModuleRegistry.registerModules([AllCommunityModule]);
const FILTER_OPTIONS: SelectOptionItem[] = [
{ code: 'all', value: '전체' },
{ code: 'price', value: '현재가' },
{ code: 'top3', value: '등락폭이 큰 상위3종목' },
];
const ALERT_TYPE_LABEL_MAP = new Map<StockAlertType, string>([
['price', '현재가'],
['top3', '등락폭이 큰 상위3종목'],
]);
const ALERT_TYPE_VALUES = Array.from(ALERT_TYPE_LABEL_MAP.keys());
type StockAlertLayoutContextValue = {
filterValue: StockAlertFilterValue;
rows: StockAlertDraftRow[];
isLoading: boolean;
pendingFocusRowId: string | null;
setFilterValue: (value: StockAlertFilterValue) => void;
updateRow: (rowId: string, patch: Partial<StockAlertDraftRow>) => void;
addRow: (item: StockAlertSearchItem) => boolean;
clearPendingFocusRowId: () => void;
refreshRows: () => Promise<void>;
saveRows: () => Promise<void>;
deleteRows: (rowIds: string[]) => Promise<void>;
};
const StockAlertLayoutContext = createContext<StockAlertLayoutContextValue | null>(null);
function useStockAlertLayoutContext() {
const context = use(StockAlertLayoutContext);
if (!context) {
throw new Error('StockAlertLayoutProvider가 필요합니다.');
}
return context;
}
function getAlertTypeLabel(value: StockAlertType) {
return ALERT_TYPE_LABEL_MAP.get(value) ?? value;
}
function toAlertTypeLabels(values: StockAlertType[]) {
return values.map((value) => getAlertTypeLabel(value));
}
function mergeDraftRows(previousRows: StockAlertDraftRow[], nextRows: StockAlertDraftRow[]) {
const dirtyRowMap = new Map(
previousRows
.filter((row) => row.isDirty)
.map((row) => [row.persistedId ?? row.id, row]),
);
return nextRows.map((row) => {
const dirtyRow = dirtyRowMap.get(row.persistedId ?? row.id);
if (!dirtyRow) {
return row;
}
return {
...row,
stockCode: dirtyRow.stockCode,
stockName: dirtyRow.stockName,
alertTypes: dirtyRow.alertTypes,
alertTypeLabels: toAlertTypeLabels(dirtyRow.alertTypes),
isDirty: true,
};
});
}
export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
const [messageApi, contextHolder] = message.useMessage();
const [filterValue, setFilterValue] = useState<StockAlertFilterValue>('all');
const [rows, setRows] = useState<StockAlertDraftRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingFocusRowId, setPendingFocusRowId] = useState<string | null>(null);
const refreshRows = async () => {
setIsLoading(true);
try {
const nextRows = await fetchStockAlerts(filterValue);
setRows((previousRows) => mergeDraftRows(previousRows, nextRows));
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 목록을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void refreshRows();
}, [filterValue]);
const updateRow = (rowId: string, patch: Partial<StockAlertDraftRow>) => {
setRows((previousRows) =>
previousRows.map((row) =>
row.id === rowId
? {
...row,
...patch,
alertTypes: (patch.alertTypes ?? row.alertTypes) as StockAlertType[],
alertTypeLabels: toAlertTypeLabels((patch.alertTypes ?? row.alertTypes) as StockAlertType[]),
isDirty: true,
}
: row,
),
);
};
const addRow = (item: StockAlertSearchItem) => {
const nextAlertTypes: StockAlertType[] = [filterValue === 'all' ? 'price' : filterValue];
if (rows.some((row) => row.stockCode === item.stockCode)) {
messageApi.warning('이미 추가된 종목입니다.');
return false;
}
const nextRowId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
setRows((previousRows) => [
{
id: nextRowId,
persistedId: null,
stockCode: item.stockCode,
stockName: item.stockName,
alertTypes: nextAlertTypes,
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
isDirty: true,
isNew: true,
},
...previousRows,
]);
setPendingFocusRowId(nextRowId);
return true;
};
const saveRows = async () => {
const dirtyRows = rows.filter((row) => row.isDirty);
if (!dirtyRows.length) {
return;
}
if (dirtyRows.some((row) => !row.stockName.trim())) {
messageApi.error('종목명을 입력한 뒤 저장해 주세요.');
return;
}
if (dirtyRows.some((row) => !row.stockCode.trim())) {
messageApi.error('종목 검색으로 추가한 뒤 저장해 주세요.');
return;
}
if (dirtyRows.some((row) => !row.alertTypes.length)) {
messageApi.error('알림유형을 하나 이상 선택해 주세요.');
return;
}
setIsLoading(true);
try {
await saveStockAlertRows(dirtyRows);
await refreshRows();
messageApi.success('종목 알림을 저장했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 저장에 실패했습니다.');
setIsLoading(false);
}
};
const deleteRows = async (rowIds: string[]) => {
if (!rowIds.length) {
return;
}
setIsLoading(true);
try {
const persistedIds = rowIds
.map((rowId) => rows.find((row) => row.id === rowId)?.persistedId ?? null)
.filter((value): value is string => Boolean(value));
await Promise.all(persistedIds.map((id) => deleteStockAlertRow(id)));
setRows((previousRows) => previousRows.filter((row) => !rowIds.includes(row.id)));
await refreshRows();
messageApi.success('선택한 종목 알림을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 삭제에 실패했습니다.');
setIsLoading(false);
}
};
const contextValue = useMemo<StockAlertLayoutContextValue>(
() => ({
filterValue,
rows,
isLoading,
pendingFocusRowId,
setFilterValue,
updateRow,
addRow,
clearPendingFocusRowId: () => {
setPendingFocusRowId(null);
},
refreshRows,
saveRows,
deleteRows,
}),
[filterValue, isLoading, pendingFocusRowId, rows],
);
return (
<StockAlertLayoutContext.Provider value={contextValue}>
{contextHolder}
{children}
</StockAlertLayoutContext.Provider>
);
}
export function StockAlertFilterPane() {
const { filterValue, setFilterValue } = useStockAlertLayoutContext();
return (
<Flex className="stock-alert-layout stock-alert-layout__filter">
<SelectUI
data={FILTER_OPTIONS}
value={filterValue}
allowClear={false}
placeholder="알림유형"
onChange={(nextCode) => {
setFilterValue((nextCode as StockAlertFilterValue | undefined) ?? 'all');
}}
/>
</Flex>
);
}
function formatPrice(value: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
return new Intl.NumberFormat('ko-KR').format(value);
}
function formatChangeRate(value: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
function formatQuotedAt(value: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
function ChangeRateCellRenderer({ value }: ICellRendererParams<StockAlertDraftRow, number | null>) {
const numericValue = typeof value === 'number' ? value : null;
const className =
numericValue === null
? 'stock-alert-layout__change-rate--flat'
: numericValue > 0
? 'stock-alert-layout__change-rate--up'
: numericValue < 0
? 'stock-alert-layout__change-rate--down'
: 'stock-alert-layout__change-rate--flat';
return <span className={className}>{formatChangeRate(numericValue)}</span>;
}
type AlertTypeCellRendererProps = ICellRendererParams<StockAlertDraftRow> & {
isOpen?: boolean;
onOpen?: (rowId: string) => void;
onClose?: () => void;
};
function AlertTypeCellEditorRenderer({ data, isOpen = false, onOpen, onClose }: AlertTypeCellRendererProps) {
const { updateRow } = useStockAlertLayoutContext();
if (!data) {
return null;
}
return (
<div
className={`stock-alert-layout__alert-type-editor${isOpen ? ' is-open' : ''}`}
onClick={(event) => {
event.stopPropagation();
onOpen?.(data.id);
}}
>
<Select<StockAlertType[]>
mode="multiple"
size="small"
open={isOpen}
autoFocus={isOpen}
value={data.alertTypes}
options={ALERT_TYPE_VALUES.map((value) => ({
value,
label: getAlertTypeLabel(value),
}))}
placeholder="알림유형 선택"
maxTagCount="responsive"
allowClear={false}
popupMatchSelectWidth={false}
className="stock-alert-layout__alert-type-select"
onChange={(nextValues) => {
updateRow(data.id, {
alertTypes: nextValues as StockAlertType[],
});
}}
onOpenChange={(nextOpen) => {
if (nextOpen) {
onOpen?.(data.id);
return;
}
onClose?.();
}}
/>
</div>
);
}
function StockSearchModal({
open,
onCancel,
onSelect,
}: {
open: boolean;
onCancel: () => void;
onSelect: (item: StockAlertSearchItem) => void;
}) {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const [items, setItems] = useState<StockAlertSearchItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const search = async (rawValue: string) => {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
setItems([]);
return;
}
setIsLoading(true);
try {
const nextItems = await searchStockAlertCandidates(trimmedValue, 20);
setItems(nextItems);
if (!nextItems.length) {
messageApi.info('조회 결과가 없습니다.');
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 검색에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setIsLoading(false);
}
}, [open]);
return (
<>
{contextHolder}
<Modal
open={open}
title="종목 검색"
onCancel={onCancel}
footer={null}
width={720}
destroyOnHidden
modalRender={renderModalWithEnterConfirm}
>
<div className="stock-alert-layout__search-modal">
<Input.Search
value={keyword}
placeholder="종목명 또는 종목코드"
enterButton="조회"
allowClear
onChange={(event) => {
setKeyword(event.target.value);
}}
onSearch={(value) => {
void search(value);
}}
/>
<Table<StockAlertSearchItem>
size="small"
rowKey={(record) => record.stockCode}
loading={isLoading}
pagination={false}
dataSource={items}
scroll={{ y: 360 }}
locale={{
emptyText: deferredKeyword.trim() ? '조회 결과가 없습니다.' : '종목명 또는 종목코드를 입력하세요.',
}}
onRow={(record) => ({
onDoubleClick: () => {
onSelect(record);
},
})}
columns={[
{
title: '종목코드',
dataIndex: 'stockCode',
width: 140,
},
{
title: '종목명',
dataIndex: 'stockName',
},
{
title: '시장구분',
dataIndex: 'market',
width: 140,
},
{
key: 'action',
width: 88,
render: (_, record) => (
<Button
type="link"
onClick={() => {
onSelect(record);
}}
>
</Button>
),
},
]}
/>
</div>
</Modal>
</>
);
}
export function StockAlertGridPane() {
const {
rows,
isLoading,
pendingFocusRowId,
updateRow,
addRow,
clearPendingFocusRowId,
refreshRows,
saveRows,
deleteRows,
} = useStockAlertLayoutContext();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [activeAlertTypeEditorRowId, setActiveAlertTypeEditorRowId] = useState<string | null>(null);
const gridApiRef = useRef<GridApi<StockAlertDraftRow> | null>(null);
const rowSelection = useMemo<RowSelectionOptions>(
() => ({
mode: 'multiRow',
enableClickSelection: true,
checkboxes: true,
headerCheckbox: true,
}),
[],
);
const columnDefs = useMemo<ColDef<StockAlertDraftRow>[]>(
() => [
{
field: 'stockName',
headerName: '종목명',
editable: false,
minWidth: 170,
flex: 1.3,
},
{
field: 'changeRate',
headerName: '등락률',
editable: false,
minWidth: 130,
flex: 0.9,
cellRenderer: ChangeRateCellRenderer,
},
{
field: 'currentPrice',
headerName: '현재가',
editable: false,
minWidth: 120,
flex: 0.9,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
},
{
field: 'quotedAt',
headerName: '기준일시',
editable: false,
minWidth: 190,
flex: 1.2,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
},
{
field: 'alertTypes',
headerName: '알림유형',
editable: false,
minWidth: 220,
flex: 1.1,
cellRenderer: AlertTypeCellEditorRenderer,
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
isOpen: params.data?.id === activeAlertTypeEditorRowId,
onOpen: (rowId: string) => {
setActiveAlertTypeEditorRowId(rowId);
},
onClose: () => {
setActiveAlertTypeEditorRowId((currentValue) => (currentValue === params.data?.id ? null : currentValue));
},
}),
sortable: false,
filter: false,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, StockAlertType[] | null>) =>
Array.isArray(params.value) ? toAlertTypeLabels(params.value).join(', ') : '',
},
{
field: 'stockCode',
headerName: '종목코드',
hide: true,
},
],
[activeAlertTypeEditorRowId],
);
const defaultColDef = useMemo<ColDef<StockAlertDraftRow>>(
() => ({
sortable: true,
filter: true,
resizable: true,
}),
[],
);
const handleCellValueChanged = (event: CellValueChangedEvent<StockAlertDraftRow>) => {
if (!event.data) {
return;
}
if (event.colDef.field === 'stockName') {
updateRow(event.data.id, {
stockName: String(event.newValue ?? ''),
});
}
};
useEffect(() => {
if (!pendingFocusRowId || !gridApiRef.current) {
return;
}
const rowIndex = rows.findIndex((row) => row.id === pendingFocusRowId);
if (rowIndex < 0) {
return;
}
gridApiRef.current.ensureIndexVisible(rowIndex, 'top');
gridApiRef.current.setFocusedCell(rowIndex, 'alertTypes');
setActiveAlertTypeEditorRowId(pendingFocusRowId);
clearPendingFocusRowId();
}, [clearPendingFocusRowId, pendingFocusRowId, rows]);
return (
<>
<StockSearchModal
open={isSearchModalOpen}
onCancel={() => {
setIsSearchModalOpen(false);
}}
onSelect={(item) => {
const added = addRow(item);
if (added) {
setIsSearchModalOpen(false);
}
}}
/>
<div className="stock-alert-layout stock-alert-layout__grid">
<div className="stock-alert-layout__toolbar">
<Button
icon={<PlusOutlined />}
onClick={() => {
setIsSearchModalOpen(true);
}}
/>
<Button icon={<DeleteOutlined />} danger disabled={!selectedRowIds.length} onClick={() => void deleteRows(selectedRowIds)} />
<Button icon={<SaveOutlined />} type="primary" onClick={() => void saveRows()} />
<Button icon={<ReloadOutlined />} onClick={() => void refreshRows()} />
</div>
<div className="stock-alert-layout__surface ag-theme-quartz">
<AgGridReact<StockAlertDraftRow>
loading={isLoading}
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection={rowSelection}
suppressRowClickSelection={false}
getRowId={(params) => params.data.id}
onGridReady={(event) => {
gridApiRef.current = event.api;
}}
onCellValueChanged={handleCellValueChanged}
onCellClicked={(event) => {
const rowId = event.data?.id ?? null;
if (event.colDef.field === 'alertTypes' && rowId) {
setActiveAlertTypeEditorRowId(rowId);
return;
}
setActiveAlertTypeEditorRowId(null);
}}
onSelectionChanged={(event) => {
setSelectedRowIds(event.api.getSelectedRows().map((row) => row.id));
}}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1 @@
export { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from './StockAlertLayout';

View File

@@ -0,0 +1,171 @@
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
export type StockAlertFilterValue = 'all' | 'price' | 'top3';
export type StockAlertType = Exclude<StockAlertFilterValue, 'all'>;
export type StockAlertItem = {
id: string;
stockCode: string;
stockName: string;
alertTypes: StockAlertType[];
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type StockAlertDraftRow = {
id: string;
persistedId: string | null;
stockCode: string;
stockName: string;
alertTypes: StockAlertType[];
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
isDirty: boolean;
isNew: boolean;
};
export type StockAlertSearchItem = {
stockCode: string;
stockName: string;
market: string;
};
const WORK_SERVER_TIMEOUT_MS = 10000;
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
return {
id: item.id,
persistedId: item.id,
stockCode: item.stockCode,
stockName: item.stockName,
alertTypes: item.alertTypes,
alertTypeLabels: item.alertTypeLabels,
currentPrice: item.currentPrice,
changeRate: item.changeRate,
quotedAt: item.quotedAt,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
isDirty: false,
isNew: false,
};
}
export function createEmptyStockAlertRow(): StockAlertDraftRow {
const localId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: localId,
persistedId: null,
stockCode: '',
stockName: '',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
isDirty: true,
isNew: true,
};
}
async function request<T>(path: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
try {
const response = await fetch(`${WORK_SERVER_BASE_URL}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new Error(payload.message || '종목 알림 요청에 실패했습니다.');
} catch {
throw new Error(text || '종목 알림 요청에 실패했습니다.');
}
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function fetchStockAlerts(filterValue: StockAlertFilterValue) {
const searchParams = new URLSearchParams();
if (filterValue !== 'all') {
searchParams.set('alertType', filterValue);
}
const path = `/stock-alerts${searchParams.size ? `?${searchParams.toString()}` : ''}`;
const response = await request<{ items: StockAlertItem[] }>(path);
return response.items.map(toDraftRow);
}
export async function searchStockAlertCandidates(query: string, limit = 20) {
const searchParams = new URLSearchParams({
query: query.trim(),
limit: String(limit),
});
const response = await request<{ items: StockAlertSearchItem[] }>(`/stock-alerts/search?${searchParams.toString()}`);
return response.items;
}
export async function saveStockAlertRows(rows: StockAlertDraftRow[]) {
const payload = rows.map((row) => ({
id: row.persistedId ?? undefined,
stockCode: row.stockCode,
stockName: row.stockName,
alertTypes: row.alertTypes,
}));
const response = await request<{ items: StockAlertItem[] }>('/stock-alerts/batch', {
method: 'PUT',
body: JSON.stringify({ items: payload }),
});
return response.items.map(toDraftRow);
}
export async function deleteStockAlertRow(id: string) {
await request<{ ok: boolean; id: string }>(`/stock-alerts/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}

View File

@@ -30,12 +30,19 @@ import {
message, message,
} from 'antd'; } from 'antd';
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'; import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppConfig, type AppConfig } from '../../app/main/appConfig'; import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
import { import {
buildAutomationTypeOptions, buildAutomationTypeOptions,
resolveAutomationTypeLabel, resolveAutomationTypeLabel,
useAutomationTypeRegistry, useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess'; } from '../../app/main/automationTypeAccess';
import {
buildAutomationContextOptions,
resolveDefaultAutomationContextIds,
useAutomationContextRegistry,
} from '../../app/main/automationContextAccess';
import { buildPlansPath } from '../../app/main/routes';
import { uploadChatComposerFile } from '../../app/main/mainChatPanel'; import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl'; import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types'; import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
@@ -607,9 +614,11 @@ function createEmptyDraft(appConfig: AppConfig): PlanDraft {
workId: '', workId: '',
note: '', note: '',
automationType: 'none', automationType: 'none',
automationContextIds: [],
status: '등록', status: '등록',
jangsingProcessingRequired: appConfig.planDefaults.jangsingProcessingRequired, jangsingProcessingRequired: appConfig.planDefaults.jangsingProcessingRequired,
autoDeployToMain: appConfig.planDefaults.autoDeployToMain, autoDeployToMain: appConfig.planDefaults.autoDeployToMain,
suppressWebPush: false,
repeatRequestEnabled: false, repeatRequestEnabled: false,
repeatIntervalMinutes: 60, repeatIntervalMinutes: 60,
}; };
@@ -630,8 +639,10 @@ export function PlanBoardPage({
initialSelectedPlanId = null, initialSelectedPlanId = null,
initialSelectedWorkId = null, initialSelectedWorkId = null,
}: PlanBoardPageProps) { }: PlanBoardPageProps) {
const navigate = useNavigate();
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry(); const { automationTypes } = useAutomationTypeRegistry();
const { automationContexts } = useAutomationContextRegistry();
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000; const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
@@ -1186,7 +1197,10 @@ export function PlanBoardPage({
} }
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId(); noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
setDraft(createEmptyDraft(appConfig)); setDraft({
...createEmptyDraft(appConfig),
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
});
setResolveLatestIssue(false); setResolveLatestIssue(false);
setRetryLatestIssue(true); setRetryLatestIssue(true);
setEditorOpen(true); setEditorOpen(true);
@@ -1598,6 +1612,10 @@ export function PlanBoardPage({
() => buildAutomationTypeOptions(automationTypes, draft.automationType), () => buildAutomationTypeOptions(automationTypes, draft.automationType),
[automationTypes, draft.automationType], [automationTypes, draft.automationType],
); );
const automationContextOptions = useMemo(
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
[automationContexts, draft.automationContextIds],
);
const automationTypeLabel = useMemo( const automationTypeLabel = useMemo(
() => resolveAutomationTypeLabel(automationTypes, draft.automationType), () => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType], [automationTypes, draft.automationType],
@@ -2102,6 +2120,42 @@ export function PlanBoardPage({
)} )}
</div> </div>
<div>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong>Context</Text>
<Space size={8} wrap>
<Text type="secondary"> , </Text>
<Button size="small" onClick={() => navigate(buildPlansPath('automation-context'))}>
Context
</Button>
</Space>
</Flex>
{requestReceived ? (
<div className="plan-board-page__readonly-field" aria-readonly="true">
<Text>{draft.automationContextIds.length ? `${draft.automationContextIds.length}개 선택` : '선택 안함'}</Text>
<Tag color="processing"> </Tag>
</div>
) : (
<Select
mode="multiple"
allowClear
className="plan-board-page__select"
value={draft.automationContextIds}
options={automationContextOptions}
popupClassName="plan-board-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
placeholder="선택된 Context 없음"
onChange={(automationContextIds) => {
updateDraft((previous) => ({
...previous,
automationContextIds,
}));
}}
/>
)}
</div>
<div> <div>
<Flex justify="space-between" align="center" gap={8} wrap> <Flex justify="space-between" align="center" gap={8} wrap>
<Text strong></Text> <Text strong></Text>
@@ -2201,6 +2255,25 @@ export function PlanBoardPage({
) : null} ) : null}
</div> </div>
<div>
<Checkbox
checked={draft.suppressWebPush ?? false}
disabled={isRequestLocked}
onChange={(event) => {
const suppressWebPush = event.target.checked;
updateDraft((previous) => ({
...previous,
suppressWebPush,
}));
}}
>
</Checkbox>
<div style={{ marginTop: 8 }}>
<Text type="secondary"> .</Text>
</div>
</div>
<div> <div>
<Text strong> / </Text> <Text strong> / </Text>
<Space direction="vertical" size={4} style={{ display: 'flex' }}> <Space direction="vertical" size={4} style={{ display: 'flex' }}>
@@ -3248,9 +3321,11 @@ function toDraft(item: PlanItem): PlanDraft {
workId: item.workId, workId: item.workId,
note: item.note, note: item.note,
automationType: item.automationType, automationType: item.automationType,
automationContextIds: item.automationContextIds ?? [],
status: item.status, status: item.status,
jangsingProcessingRequired: item.jangsingProcessingRequired, jangsingProcessingRequired: item.jangsingProcessingRequired,
autoDeployToMain: item.autoDeployToMain, autoDeployToMain: item.autoDeployToMain,
suppressWebPush: item.suppressWebPush,
repeatRequestEnabled: item.repeatRequestEnabled, repeatRequestEnabled: item.repeatRequestEnabled,
repeatIntervalMinutes: item.repeatIntervalMinutes, repeatIntervalMinutes: item.repeatIntervalMinutes,
}; };
@@ -3949,6 +4024,9 @@ function summarizeAutomationUsageSnapshotTokens(snapshot: PlanAutomationUsageSna
const validEntries = entries.filter(([value]) => Number.isFinite(value) && value > 0); const validEntries = entries.filter(([value]) => Number.isFinite(value) && value > 0);
if (validEntries.length === 0) { if (validEntries.length === 0) {
if (Number(snapshot.sourceWorkCount ?? 0) > 0) {
return '총 0';
}
return null; return null;
} }

View File

@@ -18,10 +18,17 @@ import {
message, message,
} from 'antd'; } from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
buildAutomationTypeOptions, buildAutomationTypeOptions,
useAutomationTypeRegistry, useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess'; } from '../../app/main/automationTypeAccess';
import {
buildAutomationContextOptions,
resolveDefaultAutomationContextIds,
useAutomationContextRegistry,
} from '../../app/main/automationContextAccess';
import { buildPlansPath } from '../../app/main/routes';
import { useTokenAccess } from '../../app/main/tokenAccess'; import { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css'; import './planBoard.css';
import './planSchedule.css'; import './planSchedule.css';
@@ -32,21 +39,28 @@ import {
deletePlanScheduledTask, deletePlanScheduledTask,
fetchPlanScheduledTasks, fetchPlanScheduledTasks,
setupPlanBoard, setupPlanBoard,
type PlanScheduleExecutionMode,
updatePlanScheduledTask, updatePlanScheduledTask,
type PlanScheduleMode, type PlanScheduleMode,
type PlanScheduleRepeatUnit, type PlanScheduleRepeatUnit,
type PlanScheduledTask, type PlanScheduledTask,
type PlanScheduledTaskDraft, type PlanScheduledTaskDraft,
type PlanScheduledTaskSaveResult,
} from './api'; } from './api';
const { Paragraph, Text, Title } = Typography; const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
const FUNCTION_CHECK_OPTIONS = ['완료', '오동작']; const FUNCTION_CHECK_OPTIONS = ['완료', '오동작'];
const EXECUTION_MODE_OPTIONS: { label: string; value: PlanScheduleExecutionMode }[] = [
{ label: 'Codex 직접 처리', value: 'codex' },
{ label: '별도 서비스 관리', value: 'managed-service' },
];
const SCHEDULE_MODE_TAB_ITEMS: { key: PlanScheduleMode; label: string }[] = [ const SCHEDULE_MODE_TAB_ITEMS: { key: PlanScheduleMode; label: string }[] = [
{ key: 'interval', label: '반복 주기' }, { key: 'interval', label: '반복 주기' },
{ key: 'daily', label: '매일 시간' }, { key: 'daily', label: '매일 시간' },
]; ];
const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [ const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [
{ label: '초', value: 'second' },
{ label: '분', value: 'minute' }, { label: '분', value: 'minute' },
{ label: '시간', value: 'hour' }, { label: '시간', value: 'hour' },
{ label: '일', value: 'day' }, { label: '일', value: 'day' },
@@ -54,6 +68,7 @@ const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] =
{ label: '월', value: 'month' }, { label: '월', value: 'month' },
]; ];
const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = { const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
second: '초',
minute: '분', minute: '분',
hour: '시간', hour: '시간',
day: '일', day: '일',
@@ -61,7 +76,9 @@ const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
month: '개월', month: '개월',
}; };
const REPEAT_PRESET_OPTIONS: { label: string; value: number; unit: PlanScheduleRepeatUnit }[] = [ const REPEAT_PRESET_OPTIONS: { label: string; value: number; unit: PlanScheduleRepeatUnit }[] = [
{ label: '10', value: 10, unit: 'minute' }, { label: '10', value: 10, unit: 'second' },
{ label: '30초', value: 30, unit: 'second' },
{ label: '1분', value: 1, unit: 'minute' },
{ label: '30분', value: 30, unit: 'minute' }, { label: '30분', value: 30, unit: 'minute' },
{ label: '1시간', value: 1, unit: 'hour' }, { label: '1시간', value: 1, unit: 'hour' },
{ label: '6시간', value: 6, unit: 'hour' }, { label: '6시간', value: 6, unit: 'hour' },
@@ -80,29 +97,42 @@ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
const DEFAULT_DAILY_RUN_TIME = '09:00'; const DEFAULT_DAILY_RUN_TIME = '09:00';
const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIME_ZONE = 'Asia/Seoul';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) { const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
function getRepeatIntervalSeconds(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1)); const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
if (unit === 'second') {
return normalizedValue;
}
if (unit === 'day') { if (unit === 'day') {
return normalizedValue * 24 * 60; return normalizedValue * 24 * 60 * 60;
} }
if (unit === 'week') { if (unit === 'week') {
return normalizedValue * 7 * 24 * 60; return normalizedValue * 7 * 24 * 60 * 60;
} }
if (unit === 'month') { if (unit === 'month') {
return normalizedValue * 30 * 24 * 60; return normalizedValue * 30 * 24 * 60 * 60;
} }
if (unit === 'hour') { if (unit === 'hour') {
return normalizedValue * 60; return normalizedValue * 60 * 60;
} }
return normalizedValue; return normalizedValue * 60;
}
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
return Math.max(1, Math.ceil(getRepeatIntervalSeconds(value, unit) / 60));
} }
function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) { function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
if (unit === 'second') {
return 86400;
}
if (unit === 'month') { if (unit === 'month') {
return 12; return 12;
} }
@@ -122,14 +152,27 @@ function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
return 525600; return 525600;
} }
function buildScheduleSaveMessage(
isUpdate: boolean,
saveResult: PlanScheduledTaskSaveResult,
) {
const baseMessage = isUpdate ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.';
if (!saveResult.registeredPlan) {
return baseMessage;
}
return `${baseMessage} 자동화도 바로 접수되어 Plan #${saveResult.registeredPlan.id}가 생성됐습니다.`;
}
function normalizeRepeatIntervalValue(value: number, unit: PlanScheduleRepeatUnit) { function normalizeRepeatIntervalValue(value: number, unit: PlanScheduleRepeatUnit) {
const roundedValue = Math.max(1, Math.round(Number(value) || 1)); const roundedValue = Math.max(1, Math.round(Number(value) || 1));
return Math.min(getRepeatIntervalValueMax(unit), roundedValue); return Math.min(getRepeatIntervalValueMax(unit), roundedValue);
} }
function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackMinutes: number) { function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackSeconds: number) {
if (!value || !unit) { if (!value || !unit) {
return `${fallbackMinutes}마다`; return `${fallbackSeconds}마다`;
} }
return `${value}${REPEAT_UNIT_LABELS[unit]}마다`; return `${value}${REPEAT_UNIT_LABELS[unit]}마다`;
@@ -140,7 +183,7 @@ function normalizeScheduleMode(value: PlanScheduleMode | null | undefined): Plan
} }
function normalizeDailyRunTime(value: string | null | undefined) { function normalizeDailyRunTime(value: string | null | undefined) {
return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : DEFAULT_DAILY_RUN_TIME; return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : DEFAULT_DAILY_RUN_TIME;
} }
function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValue: string) { function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValue: string) {
@@ -148,6 +191,42 @@ function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValu
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`; return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
} }
function normalizeOptionalTimeOfDay(value: string | null | undefined) {
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null;
}
function updateOptionalTimeOfDay(
value: string | null | undefined,
part: 'hour' | 'minute',
nextPartValue: string | undefined,
) {
if (nextPartValue === undefined) {
return null;
}
const [hour, minute] = (normalizeOptionalTimeOfDay(value) ?? '00:00').split(':');
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
}
function formatRepeatWindowLabel(startTime: string | null | undefined, endTime: string | null | undefined) {
const normalizedStartTime = normalizeOptionalTimeOfDay(startTime);
const normalizedEndTime = normalizeOptionalTimeOfDay(endTime);
if (!normalizedStartTime && !normalizedEndTime) {
return '시간 제한 없음';
}
if (normalizedStartTime && normalizedEndTime) {
return `${normalizedStartTime}~${normalizedEndTime}`;
}
if (normalizedStartTime) {
return `${normalizedStartTime} 이후`;
}
return `${normalizedEndTime} 이전`;
}
function formatScheduleCycle(item: PlanScheduledTask) { function formatScheduleCycle(item: PlanScheduledTask) {
const scheduleMode = normalizeScheduleMode(item.scheduleMode); const scheduleMode = normalizeScheduleMode(item.scheduleMode);
@@ -155,7 +234,7 @@ function formatScheduleCycle(item: PlanScheduledTask) {
return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`; return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`;
} }
return formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalMinutes); return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${formatRepeatWindowLabel(item.repeatWindowStartTime, item.repeatWindowEndTime)}`;
} }
function getValidDate(value: string | null | undefined) { function getValidDate(value: string | null | undefined) {
@@ -227,7 +306,7 @@ function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date())
} }
const baseAt = lastRegisteredAt ?? getValidDate(item.createdAt) ?? now; const baseAt = lastRegisteredAt ?? getValidDate(item.createdAt) ?? now;
const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalMinutes * 60 * 1000); const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalSeconds * 1000);
return nextRunAt.getTime() <= now.getTime() ? now : nextRunAt; return nextRunAt.getTime() <= now.getTime() ? now : nextRunAt;
} }
@@ -238,16 +317,24 @@ function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanSchedul
workId: '', workId: '',
note: '', note: '',
automationType: 'none', automationType: 'none',
automationContextIds: [],
releaseTarget: defaultReleaseTarget, releaseTarget: defaultReleaseTarget,
jangsingProcessingRequired: true, jangsingProcessingRequired: true,
autoDeployToMain: true, autoDeployToMain: true,
suppressWebPush: false,
enabled: true, enabled: true,
immediateRunEnabled: true, immediateRunEnabled: true,
refreshContextSnapshotOnNextRun: false,
executionMode: 'codex',
recreateManagedServiceOnNextSave: false,
scheduleMode: 'interval', scheduleMode: 'interval',
repeatIntervalValue: 60, repeatIntervalValue: 60,
repeatIntervalUnit: 'minute', repeatIntervalUnit: 'minute',
repeatIntervalSeconds: getRepeatIntervalSeconds(60, 'minute'),
repeatIntervalMinutes: 60, repeatIntervalMinutes: 60,
dailyRunTime: DEFAULT_DAILY_RUN_TIME, dailyRunTime: DEFAULT_DAILY_RUN_TIME,
repeatWindowStartTime: null,
repeatWindowEndTime: null,
}; };
} }
@@ -263,16 +350,24 @@ function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
workId: item.workId, workId: item.workId,
note: item.note, note: item.note,
automationType: item.automationType, automationType: item.automationType,
automationContextIds: item.automationContextIds ?? [],
releaseTarget: item.releaseTarget, releaseTarget: item.releaseTarget,
jangsingProcessingRequired: item.jangsingProcessingRequired, jangsingProcessingRequired: item.jangsingProcessingRequired,
autoDeployToMain: item.autoDeployToMain, autoDeployToMain: item.autoDeployToMain,
suppressWebPush: item.suppressWebPush,
enabled: item.enabled, enabled: item.enabled,
immediateRunEnabled: item.immediateRunEnabled, immediateRunEnabled: item.immediateRunEnabled,
refreshContextSnapshotOnNextRun: item.refreshContextSnapshotOnNextRun,
executionMode: item.executionMode ?? 'codex',
recreateManagedServiceOnNextSave: item.recreateManagedServiceOnNextSave ?? false,
scheduleMode: normalizeScheduleMode(item.scheduleMode), scheduleMode: normalizeScheduleMode(item.scheduleMode),
repeatIntervalValue, repeatIntervalValue,
repeatIntervalUnit, repeatIntervalUnit,
repeatIntervalSeconds: item.repeatIntervalSeconds ?? getRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime), dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
repeatWindowStartTime: normalizeOptionalTimeOfDay(item.repeatWindowStartTime),
repeatWindowEndTime: normalizeOptionalTimeOfDay(item.repeatWindowEndTime),
}; };
} }
@@ -309,10 +404,6 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
messages.push('반복 등록할 작업 메모를 입력하세요.'); messages.push('반복 등록할 작업 메모를 입력하세요.');
} }
if (draft.scheduleMode === 'interval' && getRepeatIntervalMinutes(draft.repeatIntervalValue, draft.repeatIntervalUnit) < 10) {
messages.push('반복 주기는 최소 10분 이상으로 설정하세요.');
}
if (!draft.enabled) { if (!draft.enabled) {
messages.push('비활성 스케줄은 자동 등록되지 않습니다.'); messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
} }
@@ -323,6 +414,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
export function PlanSchedulePage() { export function PlanSchedulePage() {
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry(); const { automationTypes } = useAutomationTypeRegistry();
const { automationContexts } = useAutomationContextRegistry();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<PlanScheduledTask[]>([]); const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft()); const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
@@ -335,15 +427,22 @@ export function PlanSchedulePage() {
[draft.id, items], [draft.id, items],
); );
const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]); const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]);
const automationContextOptions = useMemo(
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
[automationContexts, draft.automationContextIds],
);
async function loadItems() { async function loadItems() {
setLoading(true); setLoading(true);
setErrorMessage(null); setErrorMessage(null);
try { try {
setItems(await fetchPlanScheduledTasks()); const nextItems = await fetchPlanScheduledTasks();
setItems(nextItems);
return nextItems;
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.'); setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.');
return [];
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -388,12 +487,20 @@ export function PlanSchedulePage() {
const draftToSave = { const draftToSave = {
...draft, ...draft,
repeatIntervalValue, repeatIntervalValue,
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, draft.repeatIntervalUnit),
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit), repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit),
repeatWindowStartTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowStartTime) : null,
repeatWindowEndTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowEndTime) : null,
}; };
const savedItem = draft.id ? await updatePlanScheduledTask(draftToSave) : await createPlanScheduledTask(draftToSave); const saveResult = draft.id
messageApi.success(draft.id ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.'); ? await updatePlanScheduledTask(draftToSave)
setDraft(toDraft(savedItem)); : await createPlanScheduledTask(draftToSave);
await loadItems(); const nextItems = await loadItems();
const latestSavedItem = nextItems.find((item) => item.id === saveResult.item.id) ?? saveResult.item;
messageApi.success(buildScheduleSaveMessage(Boolean(draft.id), saveResult));
setDraft(toDraft(latestSavedItem));
} catch (error) { } catch (error) {
messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.'); messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.');
} finally { } finally {
@@ -420,7 +527,10 @@ export function PlanSchedulePage() {
try { try {
await deletePlanScheduledTask(draft.id); await deletePlanScheduledTask(draft.id);
messageApi.success('스케줄을 삭제했습니다.'); messageApi.success('스케줄을 삭제했습니다.');
setDraft(createEmptyScheduleDraft(draft.releaseTarget)); setDraft({
...createEmptyScheduleDraft(draft.releaseTarget),
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
});
setEditorOpen(false); setEditorOpen(false);
await loadItems(); await loadItems();
} catch (error) { } catch (error) {
@@ -440,7 +550,10 @@ export function PlanSchedulePage() {
} }
function handleCreateNew() { function handleCreateNew() {
setDraft(createEmptyScheduleDraft(draft.releaseTarget)); setDraft({
...createEmptyScheduleDraft(draft.releaseTarget),
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
});
setEditorOpen(true); setEditorOpen(true);
} }
@@ -533,6 +646,7 @@ export function PlanSchedulePage() {
detailContent={ detailContent={
<PlanScheduleDetail <PlanScheduleDetail
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)} automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
automationContextOptions={automationContextOptions}
draft={draft} draft={draft}
hasAccess={hasAccess} hasAccess={hasAccess}
selectedItem={selectedItem} selectedItem={selectedItem}
@@ -584,10 +698,18 @@ const PlanScheduleList = memo(function PlanScheduleList({
{item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'} {item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'}
</Paragraph> </Paragraph>
<Space wrap size={8}> <Space wrap size={8}>
<Tag color={item.executionMode === 'managed-service' ? 'geekblue' : 'default'}>
{item.executionMode === 'managed-service' ? '외부 서비스 관리형' : 'Codex 직접'}
</Tag>
<Tag>{formatScheduleCycle(item)}</Tag> <Tag>{formatScheduleCycle(item)}</Tag>
<Tag color="blue"> {formatNextPlanScheduleRunAt(item)}</Tag> <Tag color="blue"> {formatNextPlanScheduleRunAt(item)}</Tag>
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag> <Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
{item.refreshContextSnapshotOnNextRun ? <Tag color="purple"> </Tag> : null}
{item.executionMode === 'managed-service' && item.managedServiceKey ? (
<Tag color="cyan">{item.managedServiceKey}</Tag>
) : null}
<Tag>{item.autoDeployToMain ? 'main 자동등록' : 'release만'}</Tag> <Tag>{item.autoDeployToMain ? 'main 자동등록' : 'release만'}</Tag>
{item.suppressWebPush ? <Tag color="gold"> </Tag> : null}
<Tag> {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag> <Tag> {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag>
</Space> </Space>
<Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}> <Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}>
@@ -603,6 +725,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
function PlanScheduleDetail({ function PlanScheduleDetail({
automationTypeOptions, automationTypeOptions,
automationContextOptions,
draft, draft,
hasAccess, hasAccess,
selectedItem, selectedItem,
@@ -611,6 +734,7 @@ function PlanScheduleDetail({
onCopyText, onCopyText,
}: { }: {
automationTypeOptions: Array<{ label: string; value: string }>; automationTypeOptions: Array<{ label: string; value: string }>;
automationContextOptions: Array<{ label: string; value: string }>;
draft: PlanScheduledTaskDraft; draft: PlanScheduledTaskDraft;
hasAccess: boolean; hasAccess: boolean;
selectedItem: PlanScheduledTask | null; selectedItem: PlanScheduledTask | null;
@@ -618,6 +742,8 @@ function PlanScheduleDetail({
onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>; onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>;
onCopyText: (text: string) => Promise<void>; onCopyText: (text: string) => Promise<void>;
}) { }) {
const navigate = useNavigate();
return ( return (
<div className="plan-schedule-page__detail"> <div className="plan-schedule-page__detail">
{selectedItem ? ( {selectedItem ? (
@@ -629,7 +755,15 @@ function PlanScheduleDetail({
description={ description={
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Text> : {formatNextPlanScheduleRunAt(selectedItem)}</Text> <Text> : {formatNextPlanScheduleRunAt(selectedItem)}</Text>
<Text> : {selectedItem.refreshContextSnapshotOnNextRun ? '다음 실행 1회' : '없음'}</Text>
<Text> : {selectedItem.executionMode === 'managed-service' ? '별도 서비스 관리형' : 'Codex 직접 처리'}</Text>
{selectedItem.executionMode === 'managed-service' ? (
<Text> : {selectedItem.managedServiceKey ?? `schedule-${selectedItem.id}`}</Text>
) : null}
<Text> : {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)}</Text> <Text> : {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)}</Text>
{selectedItem.executionMode === 'managed-service' ? (
<Text> : {selectedItem.managedServiceGeneratedAt ? formatPlanScheduleDateTime(selectedItem.managedServiceGeneratedAt) : '미생성'}</Text>
) : null}
<Text>: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text> <Text>: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text>
<Text>: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text> <Text>: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text>
</Space> </Space>
@@ -704,7 +838,58 @@ function PlanScheduleDetail({
popupClassName="plan-schedule-page__select-popup" popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body} getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess} disabled={!hasAccess}
onChange={(automationType) => onChangeDraft((previous) => ({ ...previous, automationType }))} onChange={(automationType) =>
onChangeDraft((previous) => ({
...previous,
automationType,
}))
}
/>
</div>
<div>
<Text strong> </Text>
<Segmented
options={EXECUTION_MODE_OPTIONS}
value={draft.executionMode}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
executionMode: value as PlanScheduleExecutionMode,
recreateManagedServiceOnNextSave:
value === 'managed-service' ? previous.recreateManagedServiceOnNextSave : false,
}))
}
/>
<div style={{ marginTop: 8 }}>
<Text type="secondary">
{draft.executionMode === 'managed-service'
? `스케줄 PK를 포함한 고정 패키지 경로로 별도 서비스 번들을 관리합니다.`
: '현재처럼 Codex 기반 자동화 메모 등록 흐름으로 처리합니다.'}
</Text>
</div>
</div>
<div>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong>Context</Text>
<Space size={8} wrap>
<Text type="secondary"> , </Text>
<Button size="small" onClick={() => navigate(buildPlansPath('automation-context'))}>
Context
</Button>
</Space>
</Flex>
<Select
mode="multiple"
allowClear
className="plan-schedule-page__select"
value={draft.automationContextIds}
options={automationContextOptions}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
placeholder="선택된 Context 없음"
onChange={(automationContextIds) => onChangeDraft((previous) => ({ ...previous, automationContextIds }))}
/> />
</div> </div>
<div> <div>
@@ -743,6 +928,7 @@ function PlanScheduleDetail({
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value), dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
repeatIntervalValue: 1, repeatIntervalValue: 1,
repeatIntervalUnit: 'day', repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'), repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
})) }))
} }
@@ -760,6 +946,7 @@ function PlanScheduleDetail({
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value), dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
repeatIntervalValue: 1, repeatIntervalValue: 1,
repeatIntervalUnit: 'day', repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'), repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
})) }))
} }
@@ -782,6 +969,7 @@ function PlanScheduleDetail({
onChangeDraft((previous) => ({ onChangeDraft((previous) => ({
...previous, ...previous,
repeatIntervalValue, repeatIntervalValue,
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, previous.repeatIntervalUnit),
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit), repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit),
})); }));
}} }}
@@ -798,6 +986,10 @@ function PlanScheduleDetail({
...previous, ...previous,
repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value), repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
repeatIntervalUnit: value, repeatIntervalUnit: value,
repeatIntervalSeconds: getRepeatIntervalSeconds(
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
value,
),
repeatIntervalMinutes: getRepeatIntervalMinutes( repeatIntervalMinutes: getRepeatIntervalMinutes(
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value), normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
value, value,
@@ -819,6 +1011,7 @@ function PlanScheduleDetail({
scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval', scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval',
repeatIntervalValue: option.value, repeatIntervalValue: option.value,
repeatIntervalUnit: option.unit, repeatIntervalUnit: option.unit,
repeatIntervalSeconds: getRepeatIntervalSeconds(option.value, option.unit),
repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit), repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit),
})) }))
} }
@@ -827,6 +1020,77 @@ function PlanScheduleDetail({
</Button> </Button>
))} ))}
</Space> </Space>
<Space align="center" wrap style={{ marginTop: 12 }}>
<Text type="secondary"> </Text>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'hour', value),
}))
}
/>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'minute', value),
}))
}
/>
<Text type="secondary"> </Text>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'hour', value),
}))
}
/>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'minute', value),
}))
}
/>
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary"> .</Text>
</div>
</> </>
)} )}
</div> </div>
@@ -841,6 +1105,53 @@ function PlanScheduleDetail({
</Checkbox> </Checkbox>
</div> </div>
<div>
<Checkbox
checked={draft.refreshContextSnapshotOnNextRun}
disabled={!hasAccess}
onChange={(event) =>
onChangeDraft((previous) => ({
...previous,
refreshContextSnapshotOnNextRun: event.target.checked,
}))
}
>
/
</Checkbox>
<div>
<Text type="secondary"> 1 `.auto_codex/schedule/...` .</Text>
</div>
</div>
{draft.executionMode === 'managed-service' ? (
<div>
<Checkbox
checked={draft.recreateManagedServiceOnNextSave}
disabled={!hasAccess}
onChange={(event) =>
onChangeDraft((previous) => ({
...previous,
recreateManagedServiceOnNextSave: event.target.checked,
}))
}
>
Plan
</Checkbox>
<div>
<Text type="secondary">
Plan을 ,
{' '}
<Text code>.auto_codex/schedule/&lt;id&gt;</Text>
{' '}
.
</Text>
</div>
{selectedItem?.managedServiceDirectory ? (
<div style={{ marginTop: 8 }}>
<Text code>{selectedItem.managedServiceDirectory}</Text>
</div>
) : null}
</div>
) : null}
<div> <div>
<Checkbox <Checkbox
checked={draft.autoDeployToMain} checked={draft.autoDeployToMain}
@@ -850,6 +1161,18 @@ function PlanScheduleDetail({
</Checkbox> </Checkbox>
</div> </div>
<div>
<Checkbox
checked={draft.suppressWebPush}
disabled={!hasAccess}
onChange={(event) => onChangeDraft((previous) => ({ ...previous, suppressWebPush: event.target.checked }))}
>
</Checkbox>
<div>
<Text type="secondary"> .</Text>
</div>
</div>
<div> <div>
<Text strong></Text> <Text strong></Text>
<Segmented <Segmented

View File

@@ -270,8 +270,10 @@ export async function createPlanItem(draft: PlanDraft) {
workId: draft.workId, workId: draft.workId,
note: draft.note, note: draft.note,
automationType: draft.automationType, automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired, jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain, autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}), }),
}); });
@@ -289,8 +291,10 @@ export async function updatePlanItem(draft: PlanDraft) {
workId: draft.workId, workId: draft.workId,
note: draft.note, note: draft.note,
automationType: draft.automationType, automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired, jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain, autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}), }),
}); });
@@ -382,6 +386,9 @@ function normalizePlanItem(item: PlanItem): PlanItem {
...item, ...item,
automationType: normalizePlanAutomationType(item.automationType), automationType: normalizePlanAutomationType(item.automationType),
automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType), automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType),
automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '', releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot), usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
}; };
@@ -451,39 +458,67 @@ export type PlanScheduledTask = {
workId: string; workId: string;
note: string; note: string;
automationType: PlanAutomationType; automationType: PlanAutomationType;
automationContextIds: string[];
releaseTarget: string; releaseTarget: string;
jangsingProcessingRequired: boolean; jangsingProcessingRequired: boolean;
autoDeployToMain: boolean; autoDeployToMain: boolean;
suppressWebPush: boolean;
enabled: boolean; enabled: boolean;
immediateRunEnabled: boolean; immediateRunEnabled: boolean;
refreshContextSnapshotOnNextRun: boolean;
executionMode: PlanScheduleExecutionMode;
managedServiceKey: string | null;
managedServicePackageName: string | null;
managedServiceDirectory: string | null;
managedServiceManifestPath: string | null;
managedServiceGeneratedAt: string | null;
recreateManagedServiceOnNextSave: boolean;
scheduleMode: PlanScheduleMode; scheduleMode: PlanScheduleMode;
repeatIntervalValue: number; repeatIntervalValue: number;
repeatIntervalUnit: PlanScheduleRepeatUnit; repeatIntervalUnit: PlanScheduleRepeatUnit;
repeatIntervalSeconds: number;
repeatIntervalMinutes: number; repeatIntervalMinutes: number;
dailyRunTime: string; dailyRunTime: string;
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
lastRegisteredAt: string | null; lastRegisteredAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
export type PlanScheduleExecutionMode = 'codex' | 'managed-service';
export type PlanScheduleMode = 'interval' | 'daily'; export type PlanScheduleMode = 'interval' | 'daily';
export type PlanScheduleRepeatUnit = 'minute' | 'hour' | 'day' | 'week' | 'month'; export type PlanScheduleRepeatUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
export type PlanScheduledTaskDraft = { export type PlanScheduledTaskDraft = {
id: number | null; id: number | null;
workId: string; workId: string;
note: string; note: string;
automationType: PlanAutomationType; automationType: PlanAutomationType;
automationContextIds: string[];
releaseTarget: string; releaseTarget: string;
jangsingProcessingRequired: boolean; jangsingProcessingRequired: boolean;
autoDeployToMain: boolean; autoDeployToMain: boolean;
suppressWebPush: boolean;
enabled: boolean; enabled: boolean;
immediateRunEnabled: boolean; immediateRunEnabled: boolean;
refreshContextSnapshotOnNextRun: boolean;
executionMode: PlanScheduleExecutionMode;
recreateManagedServiceOnNextSave: boolean;
scheduleMode: PlanScheduleMode; scheduleMode: PlanScheduleMode;
repeatIntervalValue: number; repeatIntervalValue: number;
repeatIntervalUnit: PlanScheduleRepeatUnit; repeatIntervalUnit: PlanScheduleRepeatUnit;
repeatIntervalSeconds: number;
repeatIntervalMinutes: number; repeatIntervalMinutes: number;
dailyRunTime: string; dailyRunTime: string;
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
};
export type PlanScheduledTaskSaveResult = {
item: PlanScheduledTask;
registeredPlan: PlanItem | null;
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
}; };
async function requestPlanScheduleTask<T>(pathSuffix = '', init?: RequestInit) { async function requestPlanScheduleTask<T>(pathSuffix = '', init?: RequestInit) {
@@ -521,33 +556,60 @@ export async function fetchPlanScheduledTasks() {
return response.items.map((item) => ({ return response.items.map((item) => ({
...item, ...item,
automationType: normalizePlanAutomationType(item.automationType), automationType: normalizePlanAutomationType(item.automationType),
automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave),
})); }));
} }
export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) { export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>('', { const response = await requestPlanScheduleTask<{
ok: boolean;
item: PlanScheduledTask;
registeredPlan: PlanItem | null;
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
}>('', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
workId: draft.workId, workId: draft.workId,
note: draft.note, note: draft.note,
automationType: draft.automationType, automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
releaseTarget: draft.releaseTarget, releaseTarget: draft.releaseTarget,
jangsingProcessingRequired: draft.jangsingProcessingRequired, jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain, autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
enabled: draft.enabled, enabled: draft.enabled,
immediateRunEnabled: draft.immediateRunEnabled, immediateRunEnabled: draft.immediateRunEnabled,
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
executionMode: draft.executionMode,
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
scheduleMode: draft.scheduleMode, scheduleMode: draft.scheduleMode,
repeatIntervalValue: draft.repeatIntervalValue, repeatIntervalValue: draft.repeatIntervalValue,
repeatIntervalUnit: draft.repeatIntervalUnit, repeatIntervalUnit: draft.repeatIntervalUnit,
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes, repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime, dailyRunTime: draft.dailyRunTime,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}), }),
}); });
return { return {
...response.item, item: {
automationType: normalizePlanAutomationType(response.item.automationType), ...response.item,
}; automationType: normalizePlanAutomationType(response.item.automationType),
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
} satisfies PlanScheduledTaskSaveResult;
} }
export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) { export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
@@ -555,29 +617,51 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
throw new Error('수정할 스케줄 ID가 없습니다.'); throw new Error('수정할 스케줄 ID가 없습니다.');
} }
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>(`/${draft.id}`, { const response = await requestPlanScheduleTask<{
ok: boolean;
item: PlanScheduledTask;
registeredPlan: PlanItem | null;
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
}>(`/${draft.id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
workId: draft.workId, workId: draft.workId,
note: draft.note, note: draft.note,
automationType: draft.automationType, automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
releaseTarget: draft.releaseTarget, releaseTarget: draft.releaseTarget,
jangsingProcessingRequired: draft.jangsingProcessingRequired, jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain, autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
enabled: draft.enabled, enabled: draft.enabled,
immediateRunEnabled: draft.immediateRunEnabled, immediateRunEnabled: draft.immediateRunEnabled,
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
executionMode: draft.executionMode,
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
scheduleMode: draft.scheduleMode, scheduleMode: draft.scheduleMode,
repeatIntervalValue: draft.repeatIntervalValue, repeatIntervalValue: draft.repeatIntervalValue,
repeatIntervalUnit: draft.repeatIntervalUnit, repeatIntervalUnit: draft.repeatIntervalUnit,
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes, repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime, dailyRunTime: draft.dailyRunTime,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}), }),
}); });
return { return {
...response.item, item: {
automationType: normalizePlanAutomationType(response.item.automationType), ...response.item,
}; automationType: normalizePlanAutomationType(response.item.automationType),
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
} satisfies PlanScheduledTaskSaveResult;
} }
export async function deletePlanScheduledTask(id: number) { export async function deletePlanScheduledTask(id: number) {

View File

@@ -111,11 +111,13 @@ export type PlanItem = {
note: string; note: string;
automationType: PlanAutomationType; automationType: PlanAutomationType;
automationBehaviorType?: string; automationBehaviorType?: string;
automationContextIds: string[];
releaseReviewNote: string; releaseReviewNote: string;
noteMasked?: boolean; noteMasked?: boolean;
status: PlanStatus; status: PlanStatus;
jangsingProcessingRequired: boolean; jangsingProcessingRequired: boolean;
autoDeployToMain: boolean; autoDeployToMain: boolean;
suppressWebPush: boolean;
repeatRequestEnabled: boolean; repeatRequestEnabled: boolean;
repeatIntervalMinutes: number; repeatIntervalMinutes: number;
assignedBranch: string | null; assignedBranch: string | null;
@@ -137,9 +139,11 @@ export type PlanDraft = {
workId: string; workId: string;
note: string; note: string;
automationType: PlanAutomationType; automationType: PlanAutomationType;
automationContextIds: string[];
status: PlanStatus; status: PlanStatus;
jangsingProcessingRequired: boolean; jangsingProcessingRequired: boolean;
autoDeployToMain: boolean; autoDeployToMain: boolean;
suppressWebPush: boolean;
repeatRequestEnabled: boolean; repeatRequestEnabled: boolean;
repeatIntervalMinutes: number; repeatIntervalMinutes: number;
}; };

View File

@@ -45,6 +45,7 @@ export * from './layer';
export * from './store'; export * from './store';
export * from './widgets/core'; export * from './widgets/core';
export * from './widgets/ag-grid-widget';
export * from './widgets/api-sample-card'; export * from './widgets/api-sample-card';
export * from './widgets/dashboard-report-card'; export * from './widgets/dashboard-report-card';
export * from './widgets/gps-sample-card'; export * from './widgets/gps-sample-card';

View File

@@ -27,6 +27,51 @@ self.addEventListener('message', (event) => {
} }
}); });
function normalizeNotificationValue(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeNotificationAliases(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => normalizeNotificationValue(item)).filter(Boolean);
}
function shouldCloseExistingNotification(notification, payload) {
const data = payload.data ?? {};
const notificationScope = normalizeNotificationValue(data.notificationScope);
const notificationSource = normalizeNotificationValue(data.source);
const notificationKey = normalizeNotificationValue(data.notificationKey);
const notificationAliases = normalizeNotificationAliases(data.notificationAliases);
const replaceExistingScope =
data.replaceExistingScope === true || normalizeNotificationValue(data.replaceExistingScope).toLowerCase() === 'true';
if (!notificationScope || (!replaceExistingScope && notificationScope !== 'automation')) {
return false;
}
const existingData = notification.data && typeof notification.data === 'object' ? notification.data : {};
const existingScope = normalizeNotificationValue(existingData.notificationScope);
const existingSource = normalizeNotificationValue(existingData.source);
const existingNotificationKey = normalizeNotificationValue(existingData.notificationKey);
const existingTag = normalizeNotificationValue(notification.tag);
const replaceTargets = new Set([
notificationScope,
notificationSource,
notificationKey,
...notificationAliases,
].filter(Boolean));
return (
replaceTargets.has(existingScope) ||
replaceTargets.has(existingSource) ||
replaceTargets.has(existingNotificationKey) ||
replaceTargets.has(existingTag)
);
}
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
if (!event.data) { if (!event.data) {
return; return;
@@ -46,18 +91,13 @@ self.addEventListener('push', (event) => {
const title = payload.title || 'AI Code App'; const title = payload.title || 'AI Code App';
const body = payload.body || '새 알림이 도착했습니다.'; const body = payload.body || '새 알림이 도착했습니다.';
const notificationScope = payload.data?.notificationScope;
const notificationKey = const notificationKey =
payload.data?.notificationKey || payload.data?.notificationKey ||
[payload.threadId ?? 'ai-code-app-notification', payload.data?.eventType ?? 'event', Date.now()].join(':'); [payload.threadId ?? 'ai-code-app-notification', payload.data?.eventType ?? 'event', Date.now()].join(':');
event.waitUntil( event.waitUntil(
self.registration.getNotifications().then((notifications) => { self.registration.getNotifications().then((notifications) => {
if (notificationScope === 'automation') { notifications.filter((notification) => shouldCloseExistingNotification(notification, payload)).forEach((notification) => notification.close());
notifications
.filter((notification) => notification.data?.notificationScope === 'automation')
.forEach((notification) => notification.close());
}
return self.registration.showNotification(title, { return self.registration.showNotification(title, {
body, body,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More