feat: expand live chat and work server tools
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ playwright-report/
|
||||
test-results/
|
||||
.cache/
|
||||
tmp/
|
||||
.tmp-*/
|
||||
node_modules.root-owned-backup/
|
||||
|
||||
.env
|
||||
|
||||
@@ -100,6 +100,9 @@ src/components
|
||||
- 위치: `src/features/layout`
|
||||
- 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃
|
||||
- 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, Docs markdown preview, Plan 게시판
|
||||
- `Layout Editor`의 기능 명세는 위젯 기본 스펙 정의가 아니라, 현재 레이아웃에 배치된 컴포넌트의 화면 역할과 상호작용을 기술하는 데이터로 취급
|
||||
- 따라서 컴포넌트 간 이벤트/상태 연결과 API 연결 흐름도 `Layout Editor` 기능 명세에서 구현 가능한 범위로 본다
|
||||
- 따라서 샘플 메타나 위젯 기본 기능을 `Layout Editor` 기능 명세에 자동 주입하지 않음
|
||||
|
||||
프로젝트 종속 기능 규칙:
|
||||
|
||||
@@ -135,6 +138,13 @@ src/components
|
||||
- alpha 버전 배포는 `npm publish --tag alpha`
|
||||
- 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 운영 기준
|
||||
|
||||
- 부가 서버/DB/타언어 프로젝트는 `etc` 아래에서 분리 관리
|
||||
|
||||
@@ -22,16 +22,22 @@
|
||||
## 입력 항목
|
||||
|
||||
- `workId`: 반복 등록할 작업 ID
|
||||
- 스케줄이 실제 자동화 접수로 Plan을 만들 때 이 값을 베이스 ID로 사용합니다.
|
||||
- 생성되는 Plan 작업 ID는 `workId-1`부터 `workId-999` 범위의 suffix를 붙여 유니크하게 관리합니다.
|
||||
- `note`: 매번 생성될 요청 메모
|
||||
- `automationType`: 자동화 유형
|
||||
- `plan`: Markdown 스타일 Plan 문서 등록/접수
|
||||
- `auto_worker`: 실제 자동 작업 실행
|
||||
- `command_execution`, `non_source_work`: 기존 분류 유지
|
||||
- 자동화 유형 관리를 통해 등록된 항목을 그대로 선택합니다.
|
||||
- 스케줄 실행 시 선택한 자동화 유형 ID를 유지한 채 자동화 작업메모를 등록하고 즉시 접수합니다.
|
||||
- `releaseTarget`: 반영 대상 브랜치
|
||||
- `jangsingProcessingRequired`: 기능동작확인 필요 여부
|
||||
- `autoDeployToMain`: main 자동 반영 대상 여부
|
||||
- `enabled`: 스케줄 사용 여부
|
||||
- `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`로 등록
|
||||
- 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인
|
||||
- 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작
|
||||
- 기존 스케줄 참조 문서를 다시 만들고 싶으면 `refreshContextSnapshotOnNextRun`을 켠 뒤 저장하면 다음 실행 1회 후 자동 해제
|
||||
- 외부 프로그램으로 확장할 예정인 작업은 `managed-service`로 분리해 두면 스케줄 PK 기준 서비스 키와 패키지 경로를 고정할 수 있음
|
||||
- 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤
|
||||
|
||||
## API 경로 메모
|
||||
|
||||
@@ -8,17 +8,4 @@ COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if docker inspect work-server >/dev/null 2>&1; then
|
||||
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
|
||||
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
|
||||
|
||||
@@ -103,6 +103,7 @@ npm run server-command:runner
|
||||
- `DELETE /api/plan/items/:id`
|
||||
- `POST /api/notifications/setup`
|
||||
- `GET /api/notifications/tokens`
|
||||
- `GET /api/notifications/subscriptions/web`
|
||||
- `PUT /api/notifications/tokens/ios`
|
||||
- `DELETE /api/notifications/tokens/ios`
|
||||
- `POST /api/notifications/send-test`
|
||||
@@ -112,3 +113,12 @@ npm run server-command:runner
|
||||
- 프론트에서 알림 `On` 시 `PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
|
||||
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
|
||||
- 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`로 사용해 이전 알림을 대체합니다.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerStockAlertRoutes } from './routes/stock-alert.js';
|
||||
import { registerTextMemoRoutes } from './routes/text-memo.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
@@ -34,6 +35,7 @@ export function createApp() {
|
||||
app.register(registerSchemaRoutes);
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerStockAlertRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
upsertAppConfig,
|
||||
upsertChatTypesConfig,
|
||||
} 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';
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
listIosNotificationTokens,
|
||||
listWebPushSubscriptions,
|
||||
getAutomationNotificationPreference,
|
||||
getWebPushConfig,
|
||||
registerIosNotificationToken,
|
||||
@@ -51,6 +52,10 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
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/messages', async (request) => {
|
||||
|
||||
@@ -164,11 +164,22 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
|
||||
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
|
||||
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 {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
item: mapPlanScheduledTaskRow(latestRow ?? row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
@@ -210,16 +221,34 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const shouldTriggerImmediateRegistration =
|
||||
row &&
|
||||
Boolean(row.enabled ?? true) &&
|
||||
Boolean(row.immediate_run_enabled ?? true) &&
|
||||
payload.enabled !== false;
|
||||
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null;
|
||||
const shouldTriggerImmediateRegistration = (
|
||||
row
|
||||
&& String(row.execution_mode ?? '') === 'managed-service'
|
||||
&& payload.recreateManagedServiceOnNextSave === true
|
||||
) || (
|
||||
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 {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
item: mapPlanScheduledTaskRow(latestRow ?? row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
|
||||
146
etc/servers/work-server/src/routes/stock-alert.ts
Normal file
146
etc/servers/work-server/src/routes/stock-alert.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
textMemoNoteCreateSchema,
|
||||
textMemoNoteImportSchema,
|
||||
textMemoNoteUpdateSchema,
|
||||
updateTextMemoLayoutFeatureDescription,
|
||||
updateTextMemoNote,
|
||||
} from '../services/text-memo-service.js';
|
||||
|
||||
@@ -17,6 +18,7 @@ function resolveClientId(headers: Record<string, unknown>) {
|
||||
|
||||
export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.get('/api/text-memo/notes', async (request) => {
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
const items = await listTextMemoNotes(resolveClientId(request.headers));
|
||||
return {
|
||||
ok: true,
|
||||
@@ -27,6 +29,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.post('/api/text-memo/notes', async (request) => {
|
||||
const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
|
||||
const item = await createTextMemoNote(resolveClientId(request.headers), payload);
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -37,6 +40,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.post('/api/text-memo/notes/import', async (request) => {
|
||||
const payload = textMemoNoteImportSchema.parse(request.body ?? {});
|
||||
const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -55,6 +59,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
@@ -71,6 +77,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
|
||||
@@ -21,10 +21,29 @@ test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () =
|
||||
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', () => {
|
||||
const merged = mergeDefaultChatTypes([]);
|
||||
|
||||
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 === 'general-inquiry'));
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import { db } from '../db/client.js';
|
||||
|
||||
export const APP_CONFIG_TABLE = 'app_configs';
|
||||
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 = {
|
||||
maxContextMessages: 12,
|
||||
maxContextChars: 3200,
|
||||
@@ -31,6 +34,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
enabled: true,
|
||||
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',
|
||||
name: 'API요청',
|
||||
@@ -203,7 +214,8 @@ export function mergeDefaultChatTypes(items: unknown[]) {
|
||||
const byId = new Map(savedItems.map((item) => [item.id, item] as const));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -14,21 +14,59 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
contexts: AutomationTypeContextRecord[];
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
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',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
description: '기본 자동화 처리용 유형입니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -37,6 +75,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -45,6 +93,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -53,6 +111,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -60,55 +128,24 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description:
|
||||
'자동화 작업메모로 처리합니다.\n\n## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
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',
|
||||
enabled: true,
|
||||
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) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -137,38 +174,14 @@ function normalizeEnabled(value: unknown) {
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
async function ensureAutomationTypesTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
|
||||
function normalizeLegacyAutomationTypeId(value: unknown) {
|
||||
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
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;
|
||||
if (normalizedValue === 'stock-alert') {
|
||||
return 'general-inquiry';
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['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);
|
||||
});
|
||||
}
|
||||
}
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
|
||||
@@ -196,6 +209,69 @@ function buildNameKey(value: string) {
|
||||
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 {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
@@ -205,13 +281,13 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId ||
|
||||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
rawId || `automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
contexts: sanitizeAutomationContexts(record.contexts),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
@@ -265,6 +341,85 @@ export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] |
|
||||
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) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {} as Record<string, unknown>;
|
||||
@@ -273,11 +428,27 @@ function normalizeConfigRecord(value: 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>) {
|
||||
return normalizeAutomationType({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
contexts: parseContextsFromRow(row),
|
||||
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
@@ -303,7 +474,7 @@ async function readAutomationTypesFromTable() {
|
||||
await ensureAutomationTypesTable();
|
||||
|
||||
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');
|
||||
|
||||
const savedItems = rows
|
||||
@@ -326,6 +497,7 @@ async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
contexts_json: JSON.stringify(item.contexts ?? []),
|
||||
behavior_type: item.behaviorType,
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
@@ -359,7 +531,7 @@ export async function upsertAutomationTypesConfig(items: unknown[]) {
|
||||
}
|
||||
|
||||
export async function resolveAutomationType(input: unknown) {
|
||||
const requestedId = normalizeLegacyAutomationBehaviorType(input);
|
||||
const requestedId = normalizeLegacyAutomationTypeId(input);
|
||||
const automationTypes = await getAutomationTypesConfig();
|
||||
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);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import test from 'node:test';
|
||||
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(
|
||||
buildBoardPostPlanNote(
|
||||
await buildBoardPostPlanNote(
|
||||
' 알림 개선 ',
|
||||
'본문 첫 줄\n본문 둘째 줄\n',
|
||||
[],
|
||||
{
|
||||
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 자동화 접수',
|
||||
'- 선택 자동화 유형: 자동화 메모',
|
||||
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
|
||||
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
|
||||
'',
|
||||
'## 자동화 유형 context',
|
||||
'## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||
'## 자동화 Context',
|
||||
'### 처리 기준\n## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
'본문 첫 줄\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(
|
||||
buildBoardPostPlanNote(
|
||||
await buildBoardPostPlanNote(
|
||||
'작업',
|
||||
'본문',
|
||||
[],
|
||||
{
|
||||
name: '빈 context 유형',
|
||||
description: ' ',
|
||||
},
|
||||
[],
|
||||
[],
|
||||
),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
@@ -47,10 +62,10 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
|
||||
'- 게시판 제목: 작업',
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'- 선택 자동화 유형: 빈 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(
|
||||
buildBoardPostPlanNote(
|
||||
await buildBoardPostPlanNote(
|
||||
'작업',
|
||||
'본문',
|
||||
[
|
||||
@@ -74,16 +89,18 @@ test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
|
||||
},
|
||||
],
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
'- 게시판 제목: 작업',
|
||||
'- 메모 출처: 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('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'));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
|
||||
import {
|
||||
ensurePlanTable,
|
||||
normalizePlanAutomationType,
|
||||
normalizePlanWorkId,
|
||||
PLAN_TABLE,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-service.js';
|
||||
@@ -11,6 +12,12 @@ import {
|
||||
resolveStoredAutomationTypeId,
|
||||
type AutomationTypeRecord,
|
||||
} 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';
|
||||
|
||||
@@ -28,6 +35,7 @@ export const boardPostPayloadSchema = z.object({
|
||||
}),
|
||||
).max(20).default([]),
|
||||
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 = {
|
||||
@@ -39,6 +47,7 @@ export type BoardPostItem = {
|
||||
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
automationContextIds: string[];
|
||||
createdAt: 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) {
|
||||
const normalized = content
|
||||
.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
|
||||
? null
|
||||
: String(row.automation_received_at),
|
||||
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
@@ -121,34 +134,52 @@ function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloa
|
||||
return ['## 첨부 파일', ...lines];
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(
|
||||
export async function buildBoardPostPlanNote(
|
||||
title: string,
|
||||
content: string,
|
||||
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();
|
||||
const normalizedContent = content.trim();
|
||||
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
|
||||
const normalizedAutomationContext = String(automationType?.description ?? '').trim();
|
||||
return buildAutomationNoteSections({
|
||||
title: title.trim(),
|
||||
sourceLabel: 'board_posts 자동화 접수',
|
||||
requestContent: content.trim(),
|
||||
attachments: attachments.length ? buildBoardAttachmentSection(attachments).slice(1) : [],
|
||||
automationType,
|
||||
availableContexts,
|
||||
selectedContextIds: automationContextIds,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
`- 게시판 제목: ${normalizedTitle}`,
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null,
|
||||
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
|
||||
'',
|
||||
'## 자동화 유형 context',
|
||||
normalizedAutomationContext || '선택된 자동화 유형 context 없음',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
function buildSequencedPlanWorkId(baseWorkId: string, sequence: number, suffixLabel?: string | null) {
|
||||
const normalizedBaseWorkId = normalizePlanWorkId(baseWorkId);
|
||||
const normalizedSuffixLabel = String(suffixLabel ?? '').trim().replace(/^-+|-+$/g, '');
|
||||
const suffix = normalizedSuffixLabel ? `-${normalizedSuffixLabel}-${sequence}` : `-${sequence}`;
|
||||
const maxBaseLength = Math.max(1, PLAN_WORK_ID_MAX_LENGTH - suffix.length);
|
||||
const trimmedBaseWorkId = normalizedBaseWorkId.slice(0, maxBaseLength).trimEnd() || '작업ID';
|
||||
return `${trimmedBaseWorkId}${suffix}`;
|
||||
}
|
||||
|
||||
export function resolveSequencedPlanWorkId(
|
||||
baseWorkId: string,
|
||||
existingWorkIds: Iterable<string>,
|
||||
suffixLabel?: string | null,
|
||||
) {
|
||||
const existingWorkIdSet = new Set(
|
||||
Array.from(existingWorkIds, (value) => String(value ?? '').trim()).filter(Boolean),
|
||||
);
|
||||
|
||||
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 {
|
||||
@@ -223,6 +254,7 @@ export async function ensureBoardPostsTable() {
|
||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
|
||||
['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_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())],
|
||||
@@ -282,6 +314,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
|
||||
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
|
||||
created_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);
|
||||
}
|
||||
|
||||
export async function receiveBoardPostAutomation(id: number) {
|
||||
export async function receiveBoardPostAutomation(
|
||||
id: number,
|
||||
options?: {
|
||||
planWorkIdBase?: string | null;
|
||||
planWorkIdSuffixLabel?: string | null;
|
||||
suppressWebPush?: boolean;
|
||||
},
|
||||
) {
|
||||
await ensureBoardPostsTable();
|
||||
await ensurePlanTable();
|
||||
|
||||
@@ -327,17 +367,26 @@ export async function receiveBoardPostAutomation(id: number) {
|
||||
const title = String(currentRow.title ?? '').trim();
|
||||
const content = String(currentRow.content ?? '').trim();
|
||||
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 insertQuery = trx(PLAN_TABLE).insert({
|
||||
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_id: currentRow.automation_type_id ?? currentRow.automation_type,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(automationContextIds),
|
||||
status: '등록',
|
||||
release_target: 'release',
|
||||
jangsing_processing_required: true,
|
||||
auto_deploy_to_main: false,
|
||||
suppress_web_push: options?.suppressWebPush ?? false,
|
||||
worker_status: '대기',
|
||||
last_error: null,
|
||||
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),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
|
||||
216
etc/servers/work-server/src/services/chat-message-parts.ts
Normal file
216
etc/servers/work-server/src/services/chat-message-parts.ts
Normal 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 [];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.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_MESSAGE_TABLE = 'chat_conversation_messages';
|
||||
@@ -28,6 +29,7 @@ const conversationMessagePayloadSchema = z.object({
|
||||
text: z.string().max(200000),
|
||||
timestamp: z.string().trim().max(40),
|
||||
clientRequestId: z.string().trim().max(120).nullable().optional(),
|
||||
parts: z.array(z.custom<ChatMessagePart>()).optional(),
|
||||
});
|
||||
|
||||
export type ChatConversationItem = {
|
||||
@@ -57,6 +59,7 @@ export type StoredChatMessage = {
|
||||
text: string;
|
||||
timestamp: string;
|
||||
clientRequestId?: string | null;
|
||||
parts?: ChatMessagePart[];
|
||||
};
|
||||
|
||||
export type ChatConversationRequestStatus =
|
||||
@@ -199,6 +202,7 @@ function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
|
||||
text: String(row.text ?? ''),
|
||||
timestamp: String(row.display_timestamp ?? ''),
|
||||
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.string('author', 20).notNullable();
|
||||
table.text('text').notNullable();
|
||||
table.text('parts_json').notNullable().defaultTo('[]');
|
||||
table.string('display_timestamp', 40).notNullable().defaultTo('');
|
||||
table.string('client_request_id', 120).nullable();
|
||||
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()],
|
||||
['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')],
|
||||
['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('')],
|
||||
['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())],
|
||||
@@ -1608,6 +1614,7 @@ export async function appendChatConversationMessage(
|
||||
message_id: message.messageId,
|
||||
author: message.author,
|
||||
text: message.text,
|
||||
parts_json: stringifyChatMessageParts(message.parts),
|
||||
display_timestamp: message.timestamp,
|
||||
client_request_id: resolvedClientRequestId,
|
||||
created_at: db.fn.now(),
|
||||
@@ -1616,6 +1623,7 @@ export async function appendChatConversationMessage(
|
||||
.merge({
|
||||
author: message.author,
|
||||
text: message.text,
|
||||
parts_json: stringifyChatMessageParts(message.parts),
|
||||
display_timestamp: message.timestamp,
|
||||
client_request_id: resolvedClientRequestId,
|
||||
});
|
||||
@@ -1971,7 +1979,7 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
|
||||
.orderBy('request_id', 'asc');
|
||||
const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
|
||||
.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('message_id', 'asc')
|
||||
.orderBy('id', 'asc');
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
shouldUseTemplateMacroReply,
|
||||
validateAgenticCodexRuntime,
|
||||
} from './chat-service.js';
|
||||
import { extractChatMessageParts } from './chat-message-parts.js';
|
||||
|
||||
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
|
||||
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, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
||||
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
|
||||
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', () => {
|
||||
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot
|
||||
import { hasErrorLogViewAccessToken } from './error-log-service.js';
|
||||
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
|
||||
import { createNotificationMessage } from './notification-message-service.js';
|
||||
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
||||
import {
|
||||
findLatestPlanItem,
|
||||
findPlanItemByPreviewUrl,
|
||||
@@ -47,6 +48,7 @@ type ChatMessage = {
|
||||
text: string;
|
||||
timestamp: string;
|
||||
clientRequestId?: string | null;
|
||||
parts?: ChatMessagePart[];
|
||||
};
|
||||
|
||||
type ChatContext = {
|
||||
@@ -392,13 +394,14 @@ function createChatMessageId() {
|
||||
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 {
|
||||
id: createChatMessageId(),
|
||||
author,
|
||||
text,
|
||||
timestamp: formatTime(new Date()),
|
||||
clientRequestId: clientRequestId?.trim() || null,
|
||||
parts: Array.isArray(parts) ? parts : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1511,6 +1514,7 @@ export function buildAgenticCodexPrompt(
|
||||
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
|
||||
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
|
||||
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
|
||||
'- 링크를 본문과 분리된 결과 컴포넌트로 보여줘야 하면 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
|
||||
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
|
||||
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
|
||||
'- 한국어로 간결하게 답하세요.',
|
||||
@@ -2405,6 +2409,7 @@ export class ChatService {
|
||||
text: message.text,
|
||||
timestamp: message.timestamp,
|
||||
clientRequestId: message.clientRequestId ?? null,
|
||||
parts: message.parts ?? [],
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -3402,7 +3407,13 @@ export class ChatService {
|
||||
|
||||
const finalCodexReplyMessage = {
|
||||
...codexReplyMessage,
|
||||
text: reply,
|
||||
...(() => {
|
||||
const extracted = extractChatMessageParts(reply);
|
||||
return {
|
||||
text: extracted.strippedText,
|
||||
parts: extracted.parts,
|
||||
};
|
||||
})(),
|
||||
timestamp: resolveResponseTimestamp(request.requestedAtMs),
|
||||
};
|
||||
|
||||
@@ -3455,9 +3466,11 @@ export class ChatService {
|
||||
error instanceof ChatRuntimeExecutionError ? error.responseText : '';
|
||||
|
||||
if (failureResponseText) {
|
||||
const extractedFailureReply = extractChatMessageParts(failureResponseText);
|
||||
const failedCodexReplyMessage = {
|
||||
...codexReplyMessage,
|
||||
text: failureResponseText,
|
||||
text: extractedFailureReply.strippedText,
|
||||
parts: extractedFailureReply.parts,
|
||||
timestamp: resolveResponseTimestamp(request.requestedAtMs),
|
||||
};
|
||||
|
||||
|
||||
@@ -439,6 +439,7 @@ export async function registerErrorLogBoardPosts(args?: {
|
||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||
attachments: [],
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
});
|
||||
|
||||
createdPosts.push({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
168
etc/servers/work-server/src/services/managed-schedule-service.ts
Normal file
168
etc/servers/work-server/src/services/managed-schedule-service.ts
Normal 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, '');
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export const registerAutomationNotificationPreferenceSchema = z.object({
|
||||
export const registerIosTokenSchema = z.object({
|
||||
token: z.string().trim().min(1),
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -50,6 +52,8 @@ export const registerWebPushSubscriptionSchema = z.object({
|
||||
}),
|
||||
deviceId: z.string().trim().min(1).max(200).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),
|
||||
});
|
||||
|
||||
@@ -62,6 +66,9 @@ export const sendIosNotificationSchema = z.object({
|
||||
body: z.string().trim().min(1),
|
||||
data: z.record(z.string(), z.string()).default({}),
|
||||
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>;
|
||||
@@ -73,6 +80,84 @@ type NotificationPreferenceTarget = {
|
||||
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 = {
|
||||
endpoint: string;
|
||||
statusCode?: number;
|
||||
@@ -244,6 +329,8 @@ async function ensureNotificationTokenTable() {
|
||||
table.string('platform', 20).notNullable().defaultTo('ios');
|
||||
table.string('device_token', 255).notNullable().unique();
|
||||
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.timestamp('last_registered_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')],
|
||||
['device_token', (table) => table.string('device_token', 255).notNullable()],
|
||||
['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)],
|
||||
[
|
||||
'last_registered_at',
|
||||
@@ -287,6 +376,8 @@ async function ensureWebPushSubscriptionTable() {
|
||||
table.jsonb('subscription_json').notNullable();
|
||||
table.string('device_id', 200).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.timestamp('last_registered_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('{}')],
|
||||
['device_id', (table) => table.string('device_id', 200).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)],
|
||||
[
|
||||
'last_registered_at',
|
||||
@@ -389,6 +482,8 @@ export async function listIosNotificationTokens() {
|
||||
platform: row.platform,
|
||||
token: row.device_token,
|
||||
deviceId: row.device_id,
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
enabled: row.is_enabled,
|
||||
lastRegisteredAt: row.last_registered_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>) {
|
||||
await ensureNotificationTokenTable();
|
||||
const appOrigin = normalizeAppOrigin(payload.appOrigin);
|
||||
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterIosNotificationToken(payload.token);
|
||||
@@ -414,6 +530,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
|
||||
platform: 'ios',
|
||||
device_token: payload.token,
|
||||
device_id: payload.deviceId ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -422,6 +540,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
|
||||
.merge({
|
||||
platform: 'ios',
|
||||
device_id: payload.deviceId ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -519,6 +639,8 @@ export async function registerWebPushSubscription(
|
||||
payload: z.infer<typeof registerWebPushSubscriptionSchema>,
|
||||
) {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
const appOrigin = normalizeAppOrigin(payload.appOrigin);
|
||||
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterWebPushSubscription(payload.subscription.endpoint);
|
||||
@@ -536,6 +658,8 @@ export async function registerWebPushSubscription(
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -545,6 +669,8 @@ export async function registerWebPushSubscription(
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -583,11 +709,13 @@ async function getEnabledIosTokens() {
|
||||
platform: 'ios',
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('device_token', 'device_id');
|
||||
.select('device_token', 'device_id', 'app_origin', 'app_domain');
|
||||
|
||||
return rows.map((row) => ({
|
||||
token: String(row.device_token),
|
||||
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({
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('endpoint', 'subscription_json', 'device_id');
|
||||
.select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
|
||||
|
||||
return rows.map((row) => ({
|
||||
endpoint: String(row.endpoint),
|
||||
subscription: row.subscription_json as WebPushSubscriptionPayload,
|
||||
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) {
|
||||
const env = getEnv();
|
||||
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) {
|
||||
return {
|
||||
@@ -720,6 +853,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
await Promise.all(
|
||||
tokenRows.map(async (row) => ({
|
||||
token: row.token,
|
||||
deviceId: row.deviceId,
|
||||
appOrigin: row.appOrigin,
|
||||
appDomain: row.appDomain,
|
||||
allowed: await isNotificationRecipientAllowed(
|
||||
[
|
||||
{ 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);
|
||||
|
||||
if (!tokens.length) {
|
||||
@@ -778,6 +919,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
return {
|
||||
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) {
|
||||
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([
|
||||
sendIosNotifications(payload),
|
||||
sendWebPushNotifications(payload),
|
||||
options?.disableIos
|
||||
? 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 {
|
||||
ok: ios.ok || web.ok,
|
||||
ok: aggregate.ok,
|
||||
skipped: aggregate.skipped,
|
||||
ios,
|
||||
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() {
|
||||
const provider = await getProvider();
|
||||
provider?.shutdown();
|
||||
|
||||
@@ -110,5 +110,7 @@ export async function notifyPlanEvent(
|
||||
body,
|
||||
threadId: `plan-${planId}`,
|
||||
data: buildPlanNotificationData(planId, String(item.workId), eventType),
|
||||
}, {
|
||||
disableWebPush: Boolean(item.suppressWebPush),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,6 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
parseAutomationContextIds,
|
||||
stringifyAutomationContextIds,
|
||||
} from './automation-context-service.js';
|
||||
import {
|
||||
normalizeLegacyAutomationBehaviorType,
|
||||
resolveAutomationType,
|
||||
@@ -78,9 +82,11 @@ export const createPlanSchema = z.object({
|
||||
workId: z.string().trim().optional().default('작업ID'),
|
||||
note: z.string().default(''),
|
||||
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'),
|
||||
jangsingProcessingRequired: z.boolean().default(true),
|
||||
autoDeployToMain: z.boolean().default(true),
|
||||
suppressWebPush: z.boolean().default(false),
|
||||
repeatRequestEnabled: z.boolean().default(false),
|
||||
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(),
|
||||
note: z.string().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(),
|
||||
jangsingProcessingRequired: z.boolean().optional(),
|
||||
autoDeployToMain: z.boolean().optional(),
|
||||
suppressWebPush: z.boolean().optional(),
|
||||
repeatRequestEnabled: z.boolean().optional(),
|
||||
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(),
|
||||
});
|
||||
@@ -168,7 +176,7 @@ function sanitizeBranchToken(value: string) {
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function normalizePlanWorkId(value?: string | null) {
|
||||
export function normalizePlanWorkId(value?: string | null) {
|
||||
const workId = String(value ?? '').trim();
|
||||
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
@@ -273,6 +281,7 @@ export function mapPlanRow(
|
||||
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
|
||||
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
|
||||
automationBehaviorType: normalizePlanAutomationType(row.automation_type),
|
||||
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
|
||||
releaseReviewNote: options?.releaseReviewNote ?? '',
|
||||
noteMasked: Boolean(options?.noteMasked),
|
||||
status: row.status,
|
||||
@@ -281,6 +290,7 @@ export function mapPlanRow(
|
||||
? row.jangsing_processing_required
|
||||
: row.normal_processing_level === '상',
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
suppressWebPush: Boolean(row.suppress_web_push ?? false),
|
||||
repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false),
|
||||
repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60),
|
||||
assignedBranch: row.assigned_branch,
|
||||
@@ -819,9 +829,15 @@ async function syncPlanColumns() {
|
||||
await ensureColumn('automation_type_id', (table) => {
|
||||
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) => {
|
||||
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) => {
|
||||
table.boolean('repeat_request_enabled').notNullable().defaultTo(false);
|
||||
});
|
||||
@@ -1024,6 +1040,7 @@ export async function ensurePlanTable() {
|
||||
table.string('status', 40).notNullable().defaultTo('등록');
|
||||
table.boolean('jangsing_processing_required').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('release_target', 120).notNullable().defaultTo('release');
|
||||
table.string('worker_status', 80).nullable();
|
||||
@@ -1056,6 +1073,11 @@ export async function ensurePlanTable() {
|
||||
.update({
|
||||
jangsing_processing_required: true,
|
||||
});
|
||||
await db(PLAN_TABLE)
|
||||
.whereNull('suppress_web_push')
|
||||
.update({
|
||||
suppress_web_push: false,
|
||||
});
|
||||
await db(PLAN_TABLE)
|
||||
.whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[])
|
||||
.whereNull('completed_at')
|
||||
@@ -1098,9 +1120,11 @@ export async function createPlanItem(payload: z.infer<typeof createPlanSchema>)
|
||||
status: '등록',
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
suppress_web_push: payload.suppressWebPush,
|
||||
repeat_request_enabled: payload.repeatRequestEnabled,
|
||||
repeat_interval_minutes: payload.repeatIntervalMinutes,
|
||||
worker_status: '대기',
|
||||
@@ -1124,9 +1148,11 @@ export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeo
|
||||
status: '완료',
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
suppress_web_push: payload.suppressWebPush,
|
||||
repeat_request_enabled: payload.repeatRequestEnabled,
|
||||
repeat_interval_minutes: payload.repeatIntervalMinutes,
|
||||
worker_status: '자동완료',
|
||||
@@ -1150,7 +1176,9 @@ export async function upsertAutoPlanItem(args: {
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush?: boolean;
|
||||
automationType?: PlanAutomationType;
|
||||
automationContextIds?: string[];
|
||||
requeue: boolean;
|
||||
}) {
|
||||
await ensurePlanTable();
|
||||
@@ -1168,9 +1196,11 @@ export async function upsertAutoPlanItem(args: {
|
||||
workId,
|
||||
note: args.note,
|
||||
automationType: automationType.id,
|
||||
automationContextIds: args.automationContextIds ?? [],
|
||||
releaseTarget: args.releaseTarget,
|
||||
jangsingProcessingRequired: args.jangsingProcessingRequired,
|
||||
autoDeployToMain: args.autoDeployToMain,
|
||||
suppressWebPush: args.suppressWebPush ?? false,
|
||||
repeatRequestEnabled: false,
|
||||
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 nextJangsingProcessingRequired = args.jangsingProcessingRequired;
|
||||
const nextAutoDeployToMain = args.autoDeployToMain;
|
||||
const nextSuppressWebPush = args.suppressWebPush ?? Boolean(existingRow.suppress_web_push ?? false);
|
||||
const nextNote = args.note;
|
||||
const nextAutomationContextIds = args.automationContextIds ?? parseAutomationContextIds(existingRow.automation_context_ids_json);
|
||||
const currentJangsingProcessingRequired =
|
||||
typeof existingRow.jangsing_processing_required === 'boolean'
|
||||
? existingRow.jangsing_processing_required
|
||||
@@ -1192,6 +1224,8 @@ export async function upsertAutoPlanItem(args: {
|
||||
String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
|
||||
(existingRow.release_target ?? 'release') !== nextReleaseTarget ||
|
||||
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;
|
||||
|
||||
if (!args.requeue) {
|
||||
@@ -1208,9 +1242,11 @@ export async function upsertAutoPlanItem(args: {
|
||||
note: nextNote,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
suppress_web_push: nextSuppressWebPush,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
@@ -1230,9 +1266,11 @@ export async function upsertAutoPlanItem(args: {
|
||||
assigned_branch: null,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
suppress_web_push: nextSuppressWebPush,
|
||||
worker_status: '대기',
|
||||
last_error: null,
|
||||
locked_by: null,
|
||||
@@ -1278,17 +1316,22 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
? currentRow.jangsing_processing_required
|
||||
: currentRow.normal_processing_level === '상');
|
||||
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 nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60;
|
||||
const nextNote = payload.note ?? currentRow.note;
|
||||
const nextAutomationContextIds =
|
||||
payload.automationContextIds ?? parseAutomationContextIds(currentRow.automation_context_ids_json);
|
||||
|
||||
const isOnlyJangsingUpdate =
|
||||
nextWorkId === currentRow.work_id &&
|
||||
nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
|
||||
nextReleaseTarget === (currentRow.release_target ?? 'release') &&
|
||||
nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
|
||||
nextSuppressWebPush === (currentRow.suppress_web_push ?? false) &&
|
||||
nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
|
||||
nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) &&
|
||||
JSON.stringify(parseAutomationContextIds(currentRow.automation_context_ids_json)) === JSON.stringify(nextAutomationContextIds) &&
|
||||
nextNote === currentRow.note;
|
||||
|
||||
if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) {
|
||||
@@ -1307,9 +1350,11 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
status: currentRow.status,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
suppress_web_push: nextSuppressWebPush,
|
||||
repeat_request_enabled: nextRepeatRequestEnabled,
|
||||
repeat_interval_minutes: nextRepeatIntervalMinutes,
|
||||
worker_status: currentRow.worker_status,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 {
|
||||
buildHealthCheckUrls,
|
||||
@@ -88,6 +90,7 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
workServerScript,
|
||||
/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/);
|
||||
});
|
||||
|
||||
@@ -271,3 +274,65 @@ test('listServerCommands marks command-runner online when localhost fallback res
|
||||
assert.equal(runnerCommand.httpStatus, 200);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,6 +134,7 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
|
||||
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
|
||||
'/tmp/ai-code-test-app-dist/assets',
|
||||
] as const;
|
||||
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
|
||||
|
||||
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
|
||||
const allowLocal = options?.allowLocal ?? false;
|
||||
@@ -191,8 +192,17 @@ type SourceChangeInfo = {
|
||||
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> {
|
||||
try {
|
||||
if (isExcludedAppSourcePath(rootPath, targetPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetStat = await stat(targetPath);
|
||||
|
||||
if (targetStat.isFile()) {
|
||||
|
||||
382
etc/servers/work-server/src/services/stock-alert-service.test.ts
Normal file
382
etc/servers/work-server/src/services/stock-alert-service.test.ts
Normal 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');
|
||||
});
|
||||
1427
etc/servers/work-server/src/services/stock-alert-service.ts
Normal file
1427
etc/servers/work-server/src/services/stock-alert-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const TEXT_MEMO_TABLE = 'text_memo_notes';
|
||||
const MEMO_LAYOUT_NAME = '메모';
|
||||
const MAX_NOTE_COUNT = 12;
|
||||
const MAX_BODY_LENGTH = 1200;
|
||||
const CLIENT_ID_MAX_LENGTH = 120;
|
||||
@@ -284,3 +285,103 @@ export async function importTextMemoNotes(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
36
etc/servers/work-server/src/workers/plan-worker.test.ts
Normal file
36
etc/servers/work-server/src/workers/plan-worker.test.ts
Normal 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');
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
claimNextPlanForMerge,
|
||||
claimNextPlanForMainMerge,
|
||||
formatPlanNotificationLabel,
|
||||
getPlanItemById,
|
||||
isPlanLockedByWorker,
|
||||
mapPlanRow,
|
||||
markPlanAsCompleted,
|
||||
@@ -50,9 +51,11 @@ const REPEATED_PROGRESS_NOTIFICATION_MS = 180_000;
|
||||
const STARTED_REQUEST_SUMMARY_LIMIT = 72;
|
||||
const ERROR_SUMMARY_MAX_LENGTH = 500;
|
||||
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 =
|
||||
/^(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_RETRY_DELAY_MS = 5000;
|
||||
const PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN =
|
||||
@@ -133,12 +136,13 @@ function normalizeErrorSummaryLine(line: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function summarizeFailureOutput(output: string, fallback: string) {
|
||||
export function summarizeFailureOutput(output: string, fallback: string) {
|
||||
const normalizedLines = output
|
||||
.split('\n')
|
||||
.map((line) => normalizeErrorSummaryLine(line))
|
||||
.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 =
|
||||
normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
|
||||
@@ -288,11 +292,16 @@ export class PlanWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await getPlanItemById(planId);
|
||||
const disableWebPush = Boolean(item?.suppressWebPush);
|
||||
|
||||
const result = await sendNotifications({
|
||||
title,
|
||||
body,
|
||||
threadId: `plan-${planId}`,
|
||||
data: buildPlanNotificationData(planId, workId, eventType),
|
||||
}, {
|
||||
disableWebPush,
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
@@ -300,6 +309,7 @@ export class PlanWorker {
|
||||
planId,
|
||||
workId,
|
||||
eventType,
|
||||
disableWebPush,
|
||||
ios: {
|
||||
skipped: result.ios.skipped,
|
||||
sentCount: result.ios.sentCount,
|
||||
|
||||
70
package-lock.json
generated
70
package-lock.json
generated
@@ -9,6 +9,8 @@
|
||||
"version": "0.1.0-alpha.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.1",
|
||||
"ag-grid-community": "^35.2.1",
|
||||
"ag-grid-react": "^35.2.1",
|
||||
"antd": "^5.27.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -2595,6 +2597,35 @@
|
||||
"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": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
@@ -4502,7 +4533,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -4862,6 +4892,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -4951,6 +4993,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -5183,6 +5234,23 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.1",
|
||||
"ag-grid-community": "^35.2.1",
|
||||
"ag-grid-react": "^35.2.1",
|
||||
"antd": "^5.27.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
@@ -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 |
@@ -26,11 +26,29 @@ const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024;
|
||||
const ERROR_SUMMARY_MAX_LENGTH = 500;
|
||||
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;
|
||||
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 =
|
||||
/(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 =
|
||||
/^(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_RETRY_DELAY_MS = 3000;
|
||||
const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6;
|
||||
@@ -47,6 +65,7 @@ const CODEX_HOME_RUNTIME_PATHS = [
|
||||
'models_cache.json',
|
||||
'version.json',
|
||||
];
|
||||
const ZERO_TOKEN_USAGE_TEXT = 'total 0 input 0 output 0 cached 0 reasoning 0';
|
||||
|
||||
function parseCodexTokenUsage(output) {
|
||||
const text = stripAnsi(String(output ?? ''));
|
||||
@@ -99,6 +118,11 @@ function parseCodexTokenUsage(output) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildTokenUsageLine(tokenUsage) {
|
||||
const normalized = String(tokenUsage ?? '').trim();
|
||||
return `토큰 사용량: ${normalized || ZERO_TOKEN_USAGE_TEXT}`;
|
||||
}
|
||||
|
||||
function reportProgress(message) {
|
||||
const text = String(message ?? '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
@@ -506,7 +530,8 @@ function summarizeFailureOutput(output, fallback) {
|
||||
.split('\n')
|
||||
.map((line) => normalizeErrorSummaryLine(line))
|
||||
.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 =
|
||||
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()}…`;
|
||||
}
|
||||
|
||||
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) {
|
||||
return encodeURIComponent(String(value ?? '').trim());
|
||||
}
|
||||
@@ -636,7 +673,7 @@ function formatRecentActionHistories(histories) {
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(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');
|
||||
}
|
||||
@@ -649,12 +686,297 @@ function formatRecentIssueHistories(histories) {
|
||||
return histories
|
||||
.slice(0, 5)
|
||||
.map((history, index) => {
|
||||
const actionNote = history.actionNote ? `\n조치이력:\n${String(history.actionNote).trim()}` : '';
|
||||
return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`;
|
||||
const actionNote = history.actionNote ? `\n조치이력:\n${truncateInlineText(history.actionNote, 500)}` : '';
|
||||
return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${truncateInlineText(history.message, 500)}${actionNote}`;
|
||||
})
|
||||
.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(/ /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) {
|
||||
if (command === 'git') {
|
||||
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 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`, {
|
||||
method: 'POST',
|
||||
@@ -1303,6 +1642,7 @@ async function processErrorLogReviewPlan(item) {
|
||||
actionType: '에러로그점검',
|
||||
actionNote: [
|
||||
summary,
|
||||
tokenUsageLine,
|
||||
'',
|
||||
'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.',
|
||||
].join('\n'),
|
||||
@@ -1330,12 +1670,16 @@ async function processPlan(item) {
|
||||
return processErrorLogReviewPlan(item);
|
||||
}
|
||||
|
||||
if (isStockAlertRequest(item.note)) {
|
||||
return processStockAlertPlan(item);
|
||||
}
|
||||
|
||||
const baselineChangedFiles = localMainMode ? await listChangedFiles() : [];
|
||||
const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles));
|
||||
const codexResult = await runCodexForPlan(item);
|
||||
const result = codexResult.message;
|
||||
const tokenUsage = codexResult.tokenUsage;
|
||||
const tokenUsageLine = tokenUsage ? `토큰 사용량: ${tokenUsage}` : null;
|
||||
const tokenUsageLine = buildTokenUsageLine(tokenUsage);
|
||||
const summary =
|
||||
result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.';
|
||||
const boardPost = parseBoardPostResult(result);
|
||||
|
||||
284
src/app/main/AutomationContextManagementPage.tsx
Normal file
284
src/app/main/AutomationContextManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedChatTypeId(null);
|
||||
@@ -151,7 +169,7 @@ export function ChatTypeManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
|
||||
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,13 +215,13 @@ export function ChatTypeManagementPage() {
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Tooltip title="비활성화">
|
||||
<Tooltip title="삭제">
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label="비활성화"
|
||||
aria-label="삭제"
|
||||
onClick={() => void handleDelete()}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -28,6 +28,30 @@
|
||||
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 {
|
||||
z-index: 1400;
|
||||
max-width: 100vw;
|
||||
@@ -663,6 +687,12 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
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__conversation-header .ant-typography {
|
||||
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-count,
|
||||
.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-empty.ant-typography,
|
||||
.app-chat-panel .app-chat-panel__busy-overlay span {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-panel__conversation-item-title,
|
||||
@@ -1180,6 +1214,10 @@
|
||||
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-message__header-meta,
|
||||
.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-more,
|
||||
.app-chat-panel .app-chat-panel__preview-modal-close-label {
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-chat-panel .app-chat-message__body {
|
||||
font-size: 18px;
|
||||
.app-chat-panel .app-chat-message__body,
|
||||
.app-chat-panel .app-chat-message__body.ant-typography {
|
||||
font-size: 20px !important;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1722,6 +1761,37 @@
|
||||
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) {
|
||||
.app-chat-panel {
|
||||
height: 100%;
|
||||
@@ -2041,7 +2111,7 @@
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
min-height: clamp(112px, 18dvh, 160px);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-queue {
|
||||
@@ -2149,15 +2219,15 @@
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
height: clamp(64px, 10dvh, 92px);
|
||||
min-height: clamp(64px, 10dvh, 92px);
|
||||
height: clamp(112px, 18dvh, 160px);
|
||||
min-height: clamp(112px, 18dvh, 160px);
|
||||
padding: 10px 52px 8px 14px;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
|
||||
padding-top: 76px;
|
||||
padding-top: 96px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-topline,
|
||||
@@ -2188,6 +2258,24 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
@@ -2901,14 +2989,18 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__composer textarea.ant-input {
|
||||
height: clamp(56px, 8.5dvh, 72px);
|
||||
min-height: clamp(56px, 8.5dvh, 72px);
|
||||
height: clamp(104px, 16dvh, 136px);
|
||||
min-height: clamp(104px, 16dvh, 136px);
|
||||
padding-top: 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 {
|
||||
padding-top: 64px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip-list {
|
||||
|
||||
@@ -20,7 +20,15 @@ import {
|
||||
import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
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 { useAppStore } from '../../store';
|
||||
import { useAppConfig } from './appConfig';
|
||||
@@ -34,6 +42,7 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie
|
||||
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
|
||||
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
||||
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
||||
import { shouldSkipForegroundResyncAfterExternalLink } from './mainChatPanel/linkNavigation';
|
||||
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
|
||||
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
||||
@@ -66,11 +75,13 @@ import type {
|
||||
ChatViewContext,
|
||||
MainChatPanelProps,
|
||||
} from './mainChatPanel/types';
|
||||
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
|
||||
import { buildChatPath } from './routes';
|
||||
import './MainChatPanel.css';
|
||||
import './MainChatPanel.hotfix.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
const ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
type ChatTypeOption = {
|
||||
value: string;
|
||||
@@ -107,6 +118,21 @@ type PendingContextConfirm = {
|
||||
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_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
const CHAT_RESTART_REQUIRED_PATTERNS = [
|
||||
@@ -950,6 +976,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [previewFindQuery, setPreviewFindQuery] = useState('');
|
||||
const [notificationToggleSessionId, setNotificationToggleSessionId] = 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 [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -959,6 +990,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
||||
const previewSearchMatchIndexRef = useRef(-1);
|
||||
const previewSearchKeyRef = useRef('');
|
||||
const activeConversationResyncPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const previousPreviewModalOpenRef = useRef(false);
|
||||
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
|
||||
const titleClusterRef = useRef<HTMLDivElement | null>(null);
|
||||
const copyFeedbackTimerRef = useRef<number | null>(null);
|
||||
@@ -977,6 +1010,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
|
||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||
const isCreatingImportedDraftConversationRef = useRef(false);
|
||||
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
|
||||
setRequestItemsState((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 {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
@@ -1100,7 +1168,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
};
|
||||
const openCreateConversationModal = () => {
|
||||
if (availableChatTypes.length === 0) {
|
||||
@@ -1239,6 +1310,24 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
},
|
||||
[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 now = Date.now();
|
||||
|
||||
@@ -1250,9 +1339,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
void reloadConversationItems();
|
||||
|
||||
if (activeSessionId.trim()) {
|
||||
void syncConversationFromServer(activeSessionId);
|
||||
void resyncActiveConversationDetail();
|
||||
}
|
||||
}, [activeSessionId, reloadConversationItems, syncConversationFromServer]);
|
||||
}, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]);
|
||||
|
||||
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
|
||||
const sessionId = eventSessionId.trim() || activeSessionId;
|
||||
@@ -1662,6 +1751,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
mapSystemStatusMessage,
|
||||
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({
|
||||
activeSessionId,
|
||||
oldestLoadedMessageId,
|
||||
@@ -1782,6 +1896,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
previewError,
|
||||
previewText,
|
||||
setActivePreviewId,
|
||||
setActivePreviewOverride,
|
||||
setIsPreviewModalOpen,
|
||||
} = useConversationViewController({
|
||||
activeSessionId,
|
||||
@@ -1799,11 +1914,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setMessages,
|
||||
});
|
||||
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);
|
||||
},
|
||||
[setActivePreviewId, setIsPreviewModalOpen],
|
||||
[setActivePreviewId, setActivePreviewOverride, setIsPreviewModalOpen],
|
||||
);
|
||||
const handleCopyActivePreview = useCallback(async () => {
|
||||
if (!activePreview) {
|
||||
@@ -1884,6 +2019,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
}, [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(() => {
|
||||
resetActivePreviewSearchState();
|
||||
clearActivePreviewSearchSelection();
|
||||
@@ -2755,9 +2903,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
|
||||
return;
|
||||
}
|
||||
resyncConversationEntryState();
|
||||
};
|
||||
const handlePageShow = () => {
|
||||
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
|
||||
return;
|
||||
}
|
||||
resyncConversationEntryState();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -2765,6 +2919,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
resyncConversationEntryState();
|
||||
};
|
||||
|
||||
@@ -2777,7 +2935,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [resyncConversationEntryState]);
|
||||
}, [connectionState, resyncConversationEntryState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'disconnected') {
|
||||
@@ -2946,6 +3104,172 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
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) => {
|
||||
await copyText(message.text);
|
||||
setCopiedMessageId(message.id);
|
||||
@@ -3285,8 +3609,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
setSelectedChatTypeId(nextChatTypeId);
|
||||
}}
|
||||
onSend={handleSend}
|
||||
onSendImmediate={handleSendImmediate}
|
||||
onSend={handleComposerSend}
|
||||
onSendImmediate={handleComposerSendImmediate}
|
||||
isSendWithoutContextEnabled={isSendWithoutContextEnabled}
|
||||
onToggleSendWithoutContext={() => {
|
||||
setIsSendWithoutContextEnabled((current) => !current);
|
||||
}}
|
||||
onClearDraft={() => {
|
||||
setDraft('');
|
||||
}}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSearchLayer } from '../../layer';
|
||||
import { useAppStore } from '../../store';
|
||||
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
|
||||
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
|
||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||
import { MainChatPanel } from './MainChatPanel';
|
||||
@@ -174,6 +175,10 @@ export function MainContent({
|
||||
return <AutomationTypeManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:automation-context') {
|
||||
return <AutomationContextManagementPage />;
|
||||
}
|
||||
|
||||
const planStatus = getPlanStatusFromWindowSelection(selectionId);
|
||||
|
||||
if (planStatus) {
|
||||
|
||||
@@ -578,6 +578,12 @@
|
||||
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-main-content.ant-layout-content,
|
||||
.app-main-panel,
|
||||
@@ -602,6 +608,16 @@
|
||||
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) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
|
||||
|
||||
361
src/app/main/automationContextAccess.ts
Normal file
361
src/app/main/automationContextAccess.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -13,10 +13,20 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
contexts: AutomationTypeContextRecord[];
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
@@ -26,6 +36,7 @@ export type AutomationTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
contexts?: Partial<AutomationTypeContextRecord>[];
|
||||
behaviorType?: AutomationBehaviorType;
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -43,11 +54,39 @@ export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string>
|
||||
};
|
||||
|
||||
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',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
description: '기본 자동화 처리용 유형입니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -56,6 +95,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -64,6 +113,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -72,6 +131,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -79,7 +148,18 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'auto_worker',
|
||||
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',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -95,6 +175,10 @@ export function normalizeAutomationTypeId(
|
||||
): PlanAutomationType | BoardAutomationType {
|
||||
const normalized = normalizeText(typeof value === 'string' ? value : '');
|
||||
|
||||
if (normalized === 'stock-alert') {
|
||||
return 'general-inquiry';
|
||||
}
|
||||
|
||||
if (normalized === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
@@ -128,7 +212,67 @@ function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecor
|
||||
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);
|
||||
|
||||
if (!name) {
|
||||
@@ -143,6 +287,7 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
contexts: sanitizeAutomationContexts(record.contexts),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
@@ -217,7 +362,7 @@ 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 hasBody = init?.body !== undefined && init?.body !== null;
|
||||
const controller = new AbortController();
|
||||
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() {
|
||||
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
@@ -38,6 +43,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
enabled: true,
|
||||
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',
|
||||
name: 'API요청',
|
||||
@@ -275,17 +288,7 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
|
||||
return sanitizeChatTypes(chatTypes);
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(
|
||||
chatTypes.map((item) =>
|
||||
item.id === normalizedId
|
||||
? {
|
||||
...item,
|
||||
enabled: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
|
||||
}
|
||||
|
||||
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
||||
|
||||
6
src/app/main/chatTypeDefaults.ts
Normal file
6
src/app/main/chatTypeDefaults.ts
Normal 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- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
|
||||
@@ -64,6 +64,7 @@ export function ConversationRoomPane({
|
||||
isMobileViewport={false}
|
||||
isChatTypeSelectionLocked={true}
|
||||
isComposerAttachmentUploading={false}
|
||||
isSendWithoutContextEnabled={false}
|
||||
onViewportScroll={() => {}}
|
||||
onViewportTouchEnd={() => {}}
|
||||
onViewportTouchMove={() => {}}
|
||||
@@ -74,6 +75,7 @@ export function ConversationRoomPane({
|
||||
onSelectChatType={() => {}}
|
||||
onSend={() => {}}
|
||||
onSendImmediate={() => {}}
|
||||
onToggleSendWithoutContext={() => {}}
|
||||
onClearDraft={() => {}}
|
||||
onScrollToBottom={() => {}}
|
||||
onToggleResourceStrip={() => {}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
@@ -119,67 +119,88 @@ export function useConversationComposerController({
|
||||
sendChatRequest,
|
||||
scrollViewportToBottom,
|
||||
}: UseConversationComposerControllerOptions) {
|
||||
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
|
||||
const activeComposerUploadCountRef = useRef(0);
|
||||
|
||||
const handleComposerFilesPicked = useCallback(
|
||||
async (files: File[]): Promise<ComposerFilePickResult> => {
|
||||
if (files.length === 0 || isComposerAttachmentUploading) {
|
||||
if (files.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(true);
|
||||
const uploadResults = await Promise.allSettled(
|
||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||
);
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedItems: Array<{ fileName: string; reason: string }> = [];
|
||||
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
|
||||
activeComposerUploadCountRef.current += 1;
|
||||
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
if (activeComposerUploadCountRef.current === 1) {
|
||||
setIsComposerAttachmentUploading(true);
|
||||
}
|
||||
|
||||
const fileName = files[index]?.name || `파일 ${index + 1}`;
|
||||
const reason =
|
||||
result.reason instanceof Error && result.reason.message.trim()
|
||||
? result.reason.message.trim()
|
||||
: '업로드 실패';
|
||||
failedItems.push({ fileName, reason });
|
||||
});
|
||||
try {
|
||||
const uploadResults = await Promise.allSettled(
|
||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||
);
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedItems: Array<{ fileName: string; reason: string }> = [];
|
||||
|
||||
if (uploadedItems.length > 0) {
|
||||
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
}
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
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()
|
||||
const fileName = files[index]?.name || `파일 ${index + 1}`;
|
||||
const reason =
|
||||
result.reason instanceof Error && 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,
|
||||
composerUploadQueueRef,
|
||||
createLocalMessage,
|
||||
isComposerAttachmentUploading,
|
||||
mergeComposerAttachments,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
|
||||
@@ -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 type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseConversationListDataOptions = {
|
||||
@@ -16,6 +17,8 @@ type UseConversationListDataResult = {
|
||||
setConversationSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
function mergeConversationItemsPreservingRequestedSession(
|
||||
nextItems: ChatConversationSummary[],
|
||||
previousItems: ChatConversationSummary[],
|
||||
@@ -49,51 +52,117 @@ export function useConversationListData({
|
||||
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
|
||||
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
|
||||
const [conversationSearch, setConversationSearch] = useState('');
|
||||
const isMountedRef = useRef(true);
|
||||
const listRequestIdRef = useRef(0);
|
||||
const pendingRequestRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const loadConversationItems = async () => {
|
||||
setIsConversationListLoading(true);
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
setConversationItems((previous) =>
|
||||
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
|
||||
);
|
||||
} catch {
|
||||
setConversationItems((previous) => previous);
|
||||
} finally {
|
||||
setIsConversationListLoading(false);
|
||||
const loadConversationItems = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (pendingRequestRef.current) {
|
||||
return pendingRequestRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void chatGateway
|
||||
.listConversations()
|
||||
.then((items) => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems((previous) =>
|
||||
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems((previous) => previous);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
isMountedRef.current = true;
|
||||
setIsConversationListLoading(true);
|
||||
void loadConversationItems();
|
||||
|
||||
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 {
|
||||
conversationItems,
|
||||
|
||||
@@ -42,13 +42,14 @@ export function useConversationViewController({
|
||||
}: UseConversationViewControllerOptions) {
|
||||
const previousSessionIdRef = useRef(activeSessionId);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const [activePreviewOverride, setActivePreviewOverride] = useState<PreviewItem | null>(null);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const [previewText, setPreviewText] = useState('');
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = 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(() => {
|
||||
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
|
||||
@@ -64,6 +65,7 @@ export function useConversationViewController({
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setActivePreviewOverride(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
@@ -80,7 +82,7 @@ export function useConversationViewController({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreviewId) {
|
||||
if (!activePreviewId || activePreviewOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export function useConversationViewController({
|
||||
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
}, [activePreviewId, previewItems]);
|
||||
}, [activePreviewId, activePreviewOverride, previewItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen || !activePreview) {
|
||||
@@ -205,6 +207,7 @@ export function useConversationViewController({
|
||||
previewError,
|
||||
previewText,
|
||||
setActivePreviewId,
|
||||
setActivePreviewOverride,
|
||||
setIsPreviewModalOpen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,14 +261,22 @@ export function useConversationViewportController({
|
||||
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
|
||||
if (!viewport || isLoadingOlderMessages) {
|
||||
touchStartYRef.current = null;
|
||||
touchPullActiveRef.current = false;
|
||||
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;
|
||||
touchPullActiveRef.current = true;
|
||||
touchPullActiveRef.current = false;
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
|
||||
|
||||
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
@@ -279,7 +287,15 @@ export function useConversationViewportController({
|
||||
const viewport = viewportRef.current;
|
||||
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;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
@@ -313,7 +329,13 @@ export function useConversationViewportController({
|
||||
if (shouldLoadOlder) {
|
||||
void onLoadOlderMessages();
|
||||
}
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
|
||||
}, [
|
||||
hasOlderMessages,
|
||||
isLoadingOlderMessages,
|
||||
isPullToLoadArmed,
|
||||
onLoadOlderMessages,
|
||||
resetPullToLoad,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'disconnected') {
|
||||
|
||||
67
src/app/main/codexLiveDraftBridge.ts
Normal file
67
src/app/main/codexLiveDraftBridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ function parseRoute(pathname: string): {
|
||||
first === 'schedule' ||
|
||||
first === 'history' ||
|
||||
first === 'automation-type' ||
|
||||
first === 'automation-context' ||
|
||||
first === 'server-command')
|
||||
) {
|
||||
return {
|
||||
@@ -155,8 +156,16 @@ function getIsMobileViewport() {
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) {
|
||||
if (!isMobileViewport) {
|
||||
function getIsSidebarOverlayViewport(topMenu: TopMenuKey) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -208,7 +217,10 @@ export function MainLayout() {
|
||||
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
|
||||
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 [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
|
||||
@@ -218,7 +230,7 @@ export function MainLayout() {
|
||||
'working' | 'release-pending-main' | 'automation-failed' | null
|
||||
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -240,8 +252,22 @@ export function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu));
|
||||
}, [isMobileViewport, routeState.topMenu]);
|
||||
const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)');
|
||||
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(() => {
|
||||
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
|
||||
@@ -256,10 +282,10 @@ export function MainLayout() {
|
||||
useEffect(() => {
|
||||
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, routeState.playMenu, savedLayouts]);
|
||||
}, [navigate, routeState.playMenu, savedLayouts, savedLayoutsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
|
||||
@@ -407,6 +433,7 @@ export function MainLayout() {
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
savedLayouts,
|
||||
savedLayoutsReady,
|
||||
setSavedLayouts,
|
||||
searchOptions,
|
||||
}}
|
||||
@@ -427,21 +454,21 @@ export function MainLayout() {
|
||||
}}
|
||||
onChangeTopMenu={(menu) => {
|
||||
navigate(resolveTopMenuPath(menu, currentDocsFolder));
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu));
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu), menu));
|
||||
}}
|
||||
onOpenPlanQuickFilter={(filter) => {
|
||||
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
||||
setActivePlanQuickFilter(filter);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(targetPlanMenu));
|
||||
setSidebarCollapsed(isMobileViewport);
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans'), 'plans'));
|
||||
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Layout>
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
|
||||
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
@@ -461,13 +488,13 @@ export function MainLayout() {
|
||||
onOpenKeysChange={setSidebarOpenKeys}
|
||||
onSelectApiMenu={(key) => {
|
||||
navigate(buildApisPath(key as ApiSectionKey));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectDocsMenu={(key) => {
|
||||
navigate(buildDocsPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
@@ -475,20 +502,20 @@ export function MainLayout() {
|
||||
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectChatMenu={(key) => {
|
||||
navigate(buildChatPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlayMenu={(key) => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
|
||||
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
|
||||
if (isMobileViewport) {
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
@@ -498,7 +525,7 @@ export function MainLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobileViewport && !sidebarCollapsed ? null : (
|
||||
{isSidebarOverlayViewport && !sidebarCollapsed ? null : (
|
||||
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ export type MainLayoutContextValue = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
widgetSamples: LoadedSampleEntry[];
|
||||
savedLayouts: SavedLayoutRecord[];
|
||||
savedLayoutsReady: boolean;
|
||||
setSavedLayouts: (layouts: SavedLayoutRecord[]) => void;
|
||||
searchOptions: SearchKeywordOption[];
|
||||
};
|
||||
|
||||
@@ -157,6 +157,18 @@ export function buildSearchOptions({
|
||||
},
|
||||
onSelectWindow,
|
||||
} 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',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useMainLayoutData() {
|
||||
const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]);
|
||||
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
|
||||
const [savedLayoutsReady, setSavedLayoutsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -47,11 +48,13 @@ export function useMainLayoutData() {
|
||||
.then((layouts) => {
|
||||
if (mounted) {
|
||||
setSavedLayouts(layouts);
|
||||
setSavedLayoutsReady(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setSavedLayouts([]);
|
||||
setSavedLayoutsReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -98,6 +101,7 @@ export function useMainLayoutData() {
|
||||
widgetSamples,
|
||||
docsDocuments,
|
||||
savedLayouts,
|
||||
savedLayoutsReady,
|
||||
setSavedLayouts,
|
||||
docFolders,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DisconnectOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
@@ -33,11 +34,16 @@ import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
||||
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||
import { triggerResourceDownload } from './downloadUtils';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
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_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
@@ -80,6 +86,16 @@ type InlinePreviewTarget = {
|
||||
kind: InlinePreviewKind;
|
||||
};
|
||||
|
||||
type OpenPreviewTarget =
|
||||
| string
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: InlinePreviewKind;
|
||||
source?: 'message' | 'context';
|
||||
};
|
||||
|
||||
type PendingComposerUpload = {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -102,8 +118,14 @@ type MessageRenderPayload = {
|
||||
previewSourceText: string;
|
||||
visibleText: 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) {
|
||||
return normalizeChatResourceUrl(value);
|
||||
}
|
||||
@@ -167,7 +189,7 @@ function buildInlinePreviewLabel(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewFileName(item: PreviewOption) {
|
||||
function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
|
||||
try {
|
||||
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();
|
||||
@@ -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) {
|
||||
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> {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
let responseMessage = '';
|
||||
@@ -252,7 +467,15 @@ function renderMessageInlineParts(line: string): ReactNode[] {
|
||||
|
||||
const href = normalizeInlinePreviewUrl(rawHref.trim());
|
||||
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}
|
||||
</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))
|
||||
.map((match) => match[1]?.trim())
|
||||
.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);
|
||||
|
||||
return {
|
||||
previewSourceText,
|
||||
visibleText,
|
||||
diffBlocks,
|
||||
rankedLinkTargets,
|
||||
linkCardTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,6 +553,10 @@ function summarizeQueuedText(text: string) {
|
||||
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function normalizeAttachmentName(value: string) {
|
||||
return String(value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isActivityLogMessage(message: ChatMessage) {
|
||||
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
||||
}
|
||||
@@ -552,8 +789,11 @@ function InlineMessagePreview({
|
||||
className="app-chat-preview-card__action"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label="preview 다운로드"
|
||||
href={target.url}
|
||||
download
|
||||
onClick={() => {
|
||||
void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -699,6 +939,7 @@ type ChatConversationViewProps = {
|
||||
isMobileViewport: boolean;
|
||||
isChatTypeSelectionLocked: boolean;
|
||||
isComposerAttachmentUploading: boolean;
|
||||
isSendWithoutContextEnabled: boolean;
|
||||
onViewportScroll: () => void;
|
||||
onViewportTouchEnd: () => void;
|
||||
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
||||
@@ -709,10 +950,11 @@ type ChatConversationViewProps = {
|
||||
onSelectChatType: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onSendImmediate: () => void;
|
||||
onToggleSendWithoutContext: () => void;
|
||||
onClearDraft: () => void;
|
||||
onScrollToBottom: () => void;
|
||||
onToggleResourceStrip: () => void;
|
||||
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
|
||||
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
|
||||
onCopyMessage: (message: ChatMessage) => void;
|
||||
onRetryMessage: (message: ChatMessage) => void;
|
||||
onCancelMessage: (message: ChatMessage) => void;
|
||||
@@ -746,6 +988,7 @@ export function ChatConversationView({
|
||||
isMobileViewport,
|
||||
isChatTypeSelectionLocked,
|
||||
isComposerAttachmentUploading,
|
||||
isSendWithoutContextEnabled,
|
||||
onViewportScroll,
|
||||
onViewportTouchEnd,
|
||||
onViewportTouchMove,
|
||||
@@ -756,6 +999,7 @@ export function ChatConversationView({
|
||||
onSelectChatType,
|
||||
onSend,
|
||||
onSendImmediate,
|
||||
onToggleSendWithoutContext,
|
||||
onClearDraft,
|
||||
onScrollToBottom,
|
||||
onToggleResourceStrip,
|
||||
@@ -1056,11 +1300,17 @@ export function ChatConversationView({
|
||||
}
|
||||
|
||||
const uploadedAttachmentNames = new Set(
|
||||
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
|
||||
);
|
||||
const resolvedUploads = pendingComposerUploads.filter(
|
||||
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
|
||||
composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
|
||||
);
|
||||
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) {
|
||||
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
|
||||
@@ -1071,6 +1321,7 @@ export function ChatConversationView({
|
||||
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
||||
|
||||
const syncPendingComposerUploads = async (files: File[]) => {
|
||||
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
|
||||
const nextPendingUploads = files.map((file) => ({
|
||||
key: buildComposerFilePickKey(file),
|
||||
name: file.name,
|
||||
@@ -1079,7 +1330,7 @@ export function ChatConversationView({
|
||||
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
||||
|
||||
setPendingComposerUploads((current) => [
|
||||
...current.filter((item) => !pendingKeys.has(item.key)),
|
||||
...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
|
||||
...nextPendingUploads,
|
||||
]);
|
||||
|
||||
@@ -1135,24 +1386,14 @@ export function ChatConversationView({
|
||||
if (!clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 ?? []);
|
||||
const files = resolveComposerPasteFiles(clipboardData);
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const uniqueFiles = Array.from(
|
||||
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
|
||||
);
|
||||
|
||||
void syncPendingComposerUploads(uniqueFiles);
|
||||
void syncPendingComposerUploads(files);
|
||||
};
|
||||
|
||||
const dismissPendingComposerUpload = (key: string) => {
|
||||
@@ -1398,14 +1639,15 @@ export function ChatConversationView({
|
||||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||||
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)) {
|
||||
return renderActivityCard(message);
|
||||
}
|
||||
|
||||
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 =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
const stackClassName = [
|
||||
@@ -1534,6 +1776,12 @@ export function ChatConversationView({
|
||||
)}
|
||||
{hasPreviewCards ? (
|
||||
<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) => {
|
||||
const previewKey = `${message.id}-diff-${index}`;
|
||||
|
||||
@@ -1577,12 +1825,20 @@ export function ChatConversationView({
|
||||
key={previewKey}
|
||||
target={target}
|
||||
isExpanded={expandedPreviewKey === previewKey}
|
||||
hasModalPreview={Boolean(matchedPreview)}
|
||||
hasModalPreview
|
||||
onOpenModalPreview={() => {
|
||||
if (matchedPreview) {
|
||||
onOpenPreview(matchedPreview.id, { fullscreen: true });
|
||||
return;
|
||||
}
|
||||
onOpenPreview(
|
||||
matchedPreview
|
||||
? matchedPreview.id
|
||||
: {
|
||||
id: previewKey,
|
||||
label: target.label,
|
||||
url: target.url,
|
||||
kind: target.kind,
|
||||
source: 'message',
|
||||
},
|
||||
{ fullscreen: true },
|
||||
);
|
||||
}}
|
||||
onToggle={() => {
|
||||
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
|
||||
@@ -1595,7 +1851,6 @@ export function ChatConversationView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
{activeSystemStatus ? (
|
||||
@@ -1656,6 +1911,17 @@ export function ChatConversationView({
|
||||
</div>
|
||||
<div className="app-chat-panel__composer-actions">
|
||||
<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
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label="즉시 요청"
|
||||
|
||||
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal file
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} 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 { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
||||
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" />;
|
||||
}
|
||||
|
||||
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') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-file">
|
||||
@@ -334,15 +341,7 @@ export function ChatPreviewBody({
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
@@ -414,15 +413,7 @@ export function ChatPreviewBody({
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal file
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,74 @@ export function shouldOpenDownloadInNewWindow() {
|
||||
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') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
@@ -45,3 +112,57 @@ export function triggerResourceDownload(url: string, fileName?: string) {
|
||||
link.click();
|
||||
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);
|
||||
}
|
||||
|
||||
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal file
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal 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();
|
||||
}
|
||||
164
src/app/main/mainChatPanel/messageParts.ts
Normal file
164
src/app/main/mainChatPanel/messageParts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
import { extractHiddenPreviewUrls } from './previewMarkers';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
@@ -106,7 +107,21 @@ export function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
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) => {
|
||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatMessagePart =
|
||||
| {
|
||||
type: 'link_card';
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
author: 'codex' | 'system' | 'user';
|
||||
@@ -8,6 +16,7 @@ export type ChatMessage = {
|
||||
clientRequestId?: string | null;
|
||||
deliveryStatus?: 'retrying' | 'failed' | null;
|
||||
retryCount?: number;
|
||||
parts?: ChatMessagePart[];
|
||||
};
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'automation-context': 'Context 유형',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
@@ -52,6 +53,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'automation-context': 'plan-menu-automation-context',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
|
||||
@@ -113,6 +113,18 @@ export function buildMainViewSearchOptions({
|
||||
},
|
||||
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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -77,6 +77,9 @@ export type ClientNotificationPayload = {
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
threadId?: string;
|
||||
targetClientIds?: string[];
|
||||
targetAppOrigins?: string[];
|
||||
targetAppDomains?: string[];
|
||||
};
|
||||
|
||||
export type ClientNotificationSendResult = {
|
||||
@@ -100,8 +103,26 @@ export type ClientNotificationSendResult = {
|
||||
export type PwaNotificationTokenPayload = {
|
||||
token: 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 NotificationMessageListStatus = 'all' | 'unread';
|
||||
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
|
||||
@@ -724,6 +745,8 @@ export async function registerWebPushSubscription(
|
||||
subscription,
|
||||
deviceId,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
appOrigin: getCurrentAppOrigin(),
|
||||
appDomain: getCurrentAppDomain(),
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
@@ -744,6 +767,8 @@ export async function registerPwaNotificationToken(payload: PwaNotificationToken
|
||||
body: JSON.stringify({
|
||||
token: payload.token,
|
||||
deviceId: payload.deviceId,
|
||||
appOrigin: payload.appOrigin || getCurrentAppOrigin(),
|
||||
appDomain: payload.appDomain || getCurrentAppDomain(),
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from '../AutomationContextManagementPage';
|
||||
import { BoardPage } from '../../../features/board';
|
||||
import { HistoryPage } from '../../../features/history';
|
||||
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') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
|
||||
import { MemoLayoutPage } from '../../../features/layout/memo';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
|
||||
|
||||
export function PlayPage() {
|
||||
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
|
||||
const { selectedPlayMenu, savedLayouts, setSavedLayouts } = useMainLayoutContext();
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className={panelClassName}>
|
||||
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
|
||||
{selectedSavedLayoutId ? (
|
||||
{selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
|
||||
{selectedSavedLayoutId && !isMemoLayout ? (
|
||||
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PlanSectionKey =
|
||||
| 'schedule'
|
||||
| 'history'
|
||||
| 'automation-type'
|
||||
| 'automation-context'
|
||||
| 'server-command';
|
||||
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
|
||||
export type PlaySectionKey = 'layout';
|
||||
@@ -49,6 +50,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'automation-context': 'Context 유형',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
@@ -68,6 +70,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'automation-context': 'plan-menu-automation-context',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
@@ -203,6 +206,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
|
||||
key: 'automation-type',
|
||||
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
|
||||
},
|
||||
{
|
||||
key: 'automation-context',
|
||||
label: renderPlanMenuLabel('automation-context', PLAN_SIDEBAR_LABELS['automation-context']),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { SampleMeta } from '../../../../../widgets/core';
|
||||
import { InputUI } from '../InputUI';
|
||||
import './BaseSample.css';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'input-base',
|
||||
@@ -18,12 +19,15 @@ export function Sample() {
|
||||
const [value, setValue] = useState('초기값');
|
||||
|
||||
return (
|
||||
<InputUI
|
||||
value={value}
|
||||
placeholder="입력 후 Enter 또는 blur"
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="input-base-sample-preview">
|
||||
<InputUI
|
||||
className="input-base-sample-preview__control"
|
||||
value={value}
|
||||
placeholder="입력 후 Enter 또는 blur"
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Sample() {
|
||||
}}
|
||||
/>
|
||||
<Text>확정 값: {committedValue}</Text>
|
||||
<Text type="secondary">확정 횟수: {commitCount}</Text>
|
||||
<Text type="secondary">{`확정 횟수: ${commitCount}`}</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export function SelectUI({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
formatLabel,
|
||||
showSearch = true,
|
||||
allowClear = true,
|
||||
placeholder = '항목을 선택하세요',
|
||||
@@ -21,10 +22,10 @@ export function SelectUI({
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
value: item.code,
|
||||
label: item.value,
|
||||
label: formatLabel ? formatLabel(item) : item.value,
|
||||
item,
|
||||
})),
|
||||
[data],
|
||||
[data, formatLabel],
|
||||
);
|
||||
|
||||
const itemMap = useMemo(
|
||||
|
||||
@@ -13,4 +13,5 @@ export type SelectUIProps = Omit<
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (code?: string, item?: SelectOptionItem) => void;
|
||||
formatLabel?: (item: SelectOptionItem) => string;
|
||||
};
|
||||
|
||||
@@ -7,8 +7,33 @@
|
||||
- 컴포넌트 샘플 레이아웃
|
||||
- 위젯 샘플 레이아웃
|
||||
- Markdown preview 리스트 레이아웃
|
||||
- `Layout Editor`와 저장 레이아웃 흐름
|
||||
|
||||
## 규칙
|
||||
|
||||
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
|
||||
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
|
||||
|
||||
## Layout Editor 기준
|
||||
|
||||
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
|
||||
|
||||
용어 기준:
|
||||
|
||||
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
|
||||
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
|
||||
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
|
||||
|
||||
허용 범위:
|
||||
|
||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
||||
|
||||
금지 해석:
|
||||
|
||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
||||
|
||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
||||
|
||||
240
src/features/layout/memo/MemoLayoutPage.css
Normal file
240
src/features/layout/memo/MemoLayoutPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
345
src/features/layout/memo/MemoLayoutPage.tsx
Normal file
345
src/features/layout/memo/MemoLayoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/memo/index.ts
Normal file
1
src/features/layout/memo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MemoLayoutPage } from './MemoLayoutPage';
|
||||
97
src/features/layout/stock-alert/StockAlertLayout.css
Normal file
97
src/features/layout/stock-alert/StockAlertLayout.css
Normal 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);
|
||||
}
|
||||
702
src/features/layout/stock-alert/StockAlertLayout.tsx
Normal file
702
src/features/layout/stock-alert/StockAlertLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/stock-alert/index.ts
Normal file
1
src/features/layout/stock-alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from './StockAlertLayout';
|
||||
171
src/features/layout/stock-alert/stockAlertApi.ts
Normal file
171
src/features/layout/stock-alert/stockAlertApi.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -30,12 +30,19 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
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 {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
useAutomationTypeRegistry,
|
||||
} 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 { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
|
||||
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
|
||||
@@ -607,9 +614,11 @@ function createEmptyDraft(appConfig: AppConfig): PlanDraft {
|
||||
workId: '',
|
||||
note: '',
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
status: '등록',
|
||||
jangsingProcessingRequired: appConfig.planDefaults.jangsingProcessingRequired,
|
||||
autoDeployToMain: appConfig.planDefaults.autoDeployToMain,
|
||||
suppressWebPush: false,
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
};
|
||||
@@ -630,8 +639,10 @@ export function PlanBoardPage({
|
||||
initialSelectedPlanId = null,
|
||||
initialSelectedWorkId = null,
|
||||
}: PlanBoardPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const { automationContexts } = useAutomationContextRegistry();
|
||||
const appConfig = useAppConfig();
|
||||
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
@@ -1186,7 +1197,10 @@ export function PlanBoardPage({
|
||||
}
|
||||
|
||||
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
|
||||
setDraft(createEmptyDraft(appConfig));
|
||||
setDraft({
|
||||
...createEmptyDraft(appConfig),
|
||||
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
|
||||
});
|
||||
setResolveLatestIssue(false);
|
||||
setRetryLatestIssue(true);
|
||||
setEditorOpen(true);
|
||||
@@ -1598,6 +1612,10 @@ export function PlanBoardPage({
|
||||
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const automationContextOptions = useMemo(
|
||||
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
|
||||
[automationContexts, draft.automationContextIds],
|
||||
);
|
||||
const automationTypeLabel = useMemo(
|
||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
@@ -2102,6 +2120,42 @@ export function PlanBoardPage({
|
||||
)}
|
||||
</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>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>메모</Text>
|
||||
@@ -2201,6 +2255,25 @@ export function PlanBoardPage({
|
||||
) : null}
|
||||
</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>
|
||||
<Text strong>접수 / 처리 시각</Text>
|
||||
<Space direction="vertical" size={4} style={{ display: 'flex' }}>
|
||||
@@ -3248,9 +3321,11 @@ function toDraft(item: PlanItem): PlanDraft {
|
||||
workId: item.workId,
|
||||
note: item.note,
|
||||
automationType: item.automationType,
|
||||
automationContextIds: item.automationContextIds ?? [],
|
||||
status: item.status,
|
||||
jangsingProcessingRequired: item.jangsingProcessingRequired,
|
||||
autoDeployToMain: item.autoDeployToMain,
|
||||
suppressWebPush: item.suppressWebPush,
|
||||
repeatRequestEnabled: item.repeatRequestEnabled,
|
||||
repeatIntervalMinutes: item.repeatIntervalMinutes,
|
||||
};
|
||||
@@ -3949,6 +4024,9 @@ function summarizeAutomationUsageSnapshotTokens(snapshot: PlanAutomationUsageSna
|
||||
const validEntries = entries.filter(([value]) => Number.isFinite(value) && value > 0);
|
||||
|
||||
if (validEntries.length === 0) {
|
||||
if (Number(snapshot.sourceWorkCount ?? 0) > 0) {
|
||||
return '총 0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,17 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
useAutomationTypeRegistry,
|
||||
} 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 './planBoard.css';
|
||||
import './planSchedule.css';
|
||||
@@ -32,21 +39,28 @@ import {
|
||||
deletePlanScheduledTask,
|
||||
fetchPlanScheduledTasks,
|
||||
setupPlanBoard,
|
||||
type PlanScheduleExecutionMode,
|
||||
updatePlanScheduledTask,
|
||||
type PlanScheduleMode,
|
||||
type PlanScheduleRepeatUnit,
|
||||
type PlanScheduledTask,
|
||||
type PlanScheduledTaskDraft,
|
||||
type PlanScheduledTaskSaveResult,
|
||||
} from './api';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
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 }[] = [
|
||||
{ key: 'interval', label: '반복 주기' },
|
||||
{ key: 'daily', label: '매일 시간' },
|
||||
];
|
||||
const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [
|
||||
{ label: '초', value: 'second' },
|
||||
{ label: '분', value: 'minute' },
|
||||
{ label: '시간', value: 'hour' },
|
||||
{ label: '일', value: 'day' },
|
||||
@@ -54,6 +68,7 @@ const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] =
|
||||
{ label: '월', value: 'month' },
|
||||
];
|
||||
const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
|
||||
second: '초',
|
||||
minute: '분',
|
||||
hour: '시간',
|
||||
day: '일',
|
||||
@@ -61,7 +76,9 @@ const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
|
||||
month: '개월',
|
||||
};
|
||||
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: '1시간', value: 1, 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 KST_TIME_ZONE = 'Asia/Seoul';
|
||||
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));
|
||||
|
||||
if (unit === 'second') {
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
if (unit === 'day') {
|
||||
return normalizedValue * 24 * 60;
|
||||
return normalizedValue * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'week') {
|
||||
return normalizedValue * 7 * 24 * 60;
|
||||
return normalizedValue * 7 * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
if (unit === 'month') {
|
||||
return normalizedValue * 30 * 24 * 60;
|
||||
return normalizedValue * 30 * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (unit === 'second') {
|
||||
return 86400;
|
||||
}
|
||||
|
||||
if (unit === 'month') {
|
||||
return 12;
|
||||
}
|
||||
@@ -122,14 +152,27 @@ function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
|
||||
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) {
|
||||
const roundedValue = Math.max(1, Math.round(Number(value) || 1));
|
||||
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) {
|
||||
return `${fallbackMinutes}분마다`;
|
||||
return `${fallbackSeconds}초마다`;
|
||||
}
|
||||
|
||||
return `${value}${REPEAT_UNIT_LABELS[unit]}마다`;
|
||||
@@ -140,7 +183,7 @@ function normalizeScheduleMode(value: PlanScheduleMode | null | undefined): Plan
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -148,6 +191,42 @@ function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValu
|
||||
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) {
|
||||
const scheduleMode = normalizeScheduleMode(item.scheduleMode);
|
||||
|
||||
@@ -155,7 +234,7 @@ function formatScheduleCycle(item: PlanScheduledTask) {
|
||||
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) {
|
||||
@@ -227,7 +306,7 @@ function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date())
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -238,16 +317,24 @@ function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanSchedul
|
||||
workId: '',
|
||||
note: '',
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
releaseTarget: defaultReleaseTarget,
|
||||
jangsingProcessingRequired: true,
|
||||
autoDeployToMain: true,
|
||||
suppressWebPush: false,
|
||||
enabled: true,
|
||||
immediateRunEnabled: true,
|
||||
refreshContextSnapshotOnNextRun: false,
|
||||
executionMode: 'codex',
|
||||
recreateManagedServiceOnNextSave: false,
|
||||
scheduleMode: 'interval',
|
||||
repeatIntervalValue: 60,
|
||||
repeatIntervalUnit: 'minute',
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(60, 'minute'),
|
||||
repeatIntervalMinutes: 60,
|
||||
dailyRunTime: DEFAULT_DAILY_RUN_TIME,
|
||||
repeatWindowStartTime: null,
|
||||
repeatWindowEndTime: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,16 +350,24 @@ function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
|
||||
workId: item.workId,
|
||||
note: item.note,
|
||||
automationType: item.automationType,
|
||||
automationContextIds: item.automationContextIds ?? [],
|
||||
releaseTarget: item.releaseTarget,
|
||||
jangsingProcessingRequired: item.jangsingProcessingRequired,
|
||||
autoDeployToMain: item.autoDeployToMain,
|
||||
suppressWebPush: item.suppressWebPush,
|
||||
enabled: item.enabled,
|
||||
immediateRunEnabled: item.immediateRunEnabled,
|
||||
refreshContextSnapshotOnNextRun: item.refreshContextSnapshotOnNextRun,
|
||||
executionMode: item.executionMode ?? 'codex',
|
||||
recreateManagedServiceOnNextSave: item.recreateManagedServiceOnNextSave ?? false,
|
||||
scheduleMode: normalizeScheduleMode(item.scheduleMode),
|
||||
repeatIntervalValue,
|
||||
repeatIntervalUnit,
|
||||
repeatIntervalSeconds: item.repeatIntervalSeconds ?? getRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
|
||||
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
|
||||
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
|
||||
repeatWindowStartTime: normalizeOptionalTimeOfDay(item.repeatWindowStartTime),
|
||||
repeatWindowEndTime: normalizeOptionalTimeOfDay(item.repeatWindowEndTime),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,10 +404,6 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
|
||||
messages.push('반복 등록할 작업 메모를 입력하세요.');
|
||||
}
|
||||
|
||||
if (draft.scheduleMode === 'interval' && getRepeatIntervalMinutes(draft.repeatIntervalValue, draft.repeatIntervalUnit) < 10) {
|
||||
messages.push('반복 주기는 최소 10분 이상으로 설정하세요.');
|
||||
}
|
||||
|
||||
if (!draft.enabled) {
|
||||
messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
|
||||
}
|
||||
@@ -323,6 +414,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
|
||||
export function PlanSchedulePage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const { automationContexts } = useAutomationContextRegistry();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||
@@ -335,15 +427,22 @@ export function PlanSchedulePage() {
|
||||
[draft.id, items],
|
||||
);
|
||||
const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]);
|
||||
const automationContextOptions = useMemo(
|
||||
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
|
||||
[automationContexts, draft.automationContextIds],
|
||||
);
|
||||
|
||||
async function loadItems() {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
setItems(await fetchPlanScheduledTasks());
|
||||
const nextItems = await fetchPlanScheduledTasks();
|
||||
setItems(nextItems);
|
||||
return nextItems;
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.');
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -388,12 +487,20 @@ export function PlanSchedulePage() {
|
||||
const draftToSave = {
|
||||
...draft,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(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);
|
||||
messageApi.success(draft.id ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.');
|
||||
setDraft(toDraft(savedItem));
|
||||
await loadItems();
|
||||
const saveResult = draft.id
|
||||
? await updatePlanScheduledTask(draftToSave)
|
||||
: await createPlanScheduledTask(draftToSave);
|
||||
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) {
|
||||
messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -420,7 +527,10 @@ export function PlanSchedulePage() {
|
||||
try {
|
||||
await deletePlanScheduledTask(draft.id);
|
||||
messageApi.success('스케줄을 삭제했습니다.');
|
||||
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
|
||||
setDraft({
|
||||
...createEmptyScheduleDraft(draft.releaseTarget),
|
||||
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
|
||||
});
|
||||
setEditorOpen(false);
|
||||
await loadItems();
|
||||
} catch (error) {
|
||||
@@ -440,7 +550,10 @@ export function PlanSchedulePage() {
|
||||
}
|
||||
|
||||
function handleCreateNew() {
|
||||
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
|
||||
setDraft({
|
||||
...createEmptyScheduleDraft(draft.releaseTarget),
|
||||
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
|
||||
});
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
@@ -533,6 +646,7 @@ export function PlanSchedulePage() {
|
||||
detailContent={
|
||||
<PlanScheduleDetail
|
||||
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
|
||||
automationContextOptions={automationContextOptions}
|
||||
draft={draft}
|
||||
hasAccess={hasAccess}
|
||||
selectedItem={selectedItem}
|
||||
@@ -584,10 +698,18 @@ const PlanScheduleList = memo(function PlanScheduleList({
|
||||
{item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'}
|
||||
</Paragraph>
|
||||
<Space wrap size={8}>
|
||||
<Tag color={item.executionMode === 'managed-service' ? 'geekblue' : 'default'}>
|
||||
{item.executionMode === 'managed-service' ? '외부 서비스 관리형' : 'Codex 직접'}
|
||||
</Tag>
|
||||
<Tag>{formatScheduleCycle(item)}</Tag>
|
||||
<Tag color="blue">다음 실행 {formatNextPlanScheduleRunAt(item)}</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>
|
||||
{item.suppressWebPush ? <Tag color="gold">웹푸쉬 끔</Tag> : null}
|
||||
<Tag>기능동작확인 {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag>
|
||||
</Space>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}>
|
||||
@@ -603,6 +725,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
|
||||
|
||||
function PlanScheduleDetail({
|
||||
automationTypeOptions,
|
||||
automationContextOptions,
|
||||
draft,
|
||||
hasAccess,
|
||||
selectedItem,
|
||||
@@ -611,6 +734,7 @@ function PlanScheduleDetail({
|
||||
onCopyText,
|
||||
}: {
|
||||
automationTypeOptions: Array<{ label: string; value: string }>;
|
||||
automationContextOptions: Array<{ label: string; value: string }>;
|
||||
draft: PlanScheduledTaskDraft;
|
||||
hasAccess: boolean;
|
||||
selectedItem: PlanScheduledTask | null;
|
||||
@@ -618,6 +742,8 @@ function PlanScheduleDetail({
|
||||
onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>;
|
||||
onCopyText: (text: string) => Promise<void>;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="plan-schedule-page__detail">
|
||||
{selectedItem ? (
|
||||
@@ -629,7 +755,15 @@ function PlanScheduleDetail({
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<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>
|
||||
{selectedItem.executionMode === 'managed-service' ? (
|
||||
<Text>서비스 재생성: {selectedItem.managedServiceGeneratedAt ? formatPlanScheduleDateTime(selectedItem.managedServiceGeneratedAt) : '미생성'}</Text>
|
||||
) : null}
|
||||
<Text>생성: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text>
|
||||
<Text>수정: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text>
|
||||
</Space>
|
||||
@@ -704,7 +838,58 @@ function PlanScheduleDetail({
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
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>
|
||||
@@ -743,6 +928,7 @@ function PlanScheduleDetail({
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
@@ -760,6 +946,7 @@ function PlanScheduleDetail({
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
@@ -782,6 +969,7 @@ function PlanScheduleDetail({
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, previous.repeatIntervalUnit),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit),
|
||||
}));
|
||||
}}
|
||||
@@ -798,6 +986,10 @@ function PlanScheduleDetail({
|
||||
...previous,
|
||||
repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
repeatIntervalUnit: value,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(
|
||||
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
value,
|
||||
),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(
|
||||
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
value,
|
||||
@@ -819,6 +1011,7 @@ function PlanScheduleDetail({
|
||||
scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval',
|
||||
repeatIntervalValue: option.value,
|
||||
repeatIntervalUnit: option.unit,
|
||||
repeatIntervalSeconds: getRepeatIntervalSeconds(option.value, option.unit),
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit),
|
||||
}))
|
||||
}
|
||||
@@ -827,6 +1020,77 @@ function PlanScheduleDetail({
|
||||
</Button>
|
||||
))}
|
||||
</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>
|
||||
@@ -841,6 +1105,53 @@ function PlanScheduleDetail({
|
||||
즉시실행 여부
|
||||
</Checkbox>
|
||||
</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/<id></Text>
|
||||
{' '}
|
||||
서비스 결과물을 다음 실행부터 사용합니다.
|
||||
</Text>
|
||||
</div>
|
||||
{selectedItem?.managedServiceDirectory ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text code>{selectedItem.managedServiceDirectory}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.autoDeployToMain}
|
||||
@@ -850,6 +1161,18 @@ function PlanScheduleDetail({
|
||||
메인까지 자동등록
|
||||
</Checkbox>
|
||||
</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>
|
||||
<Text strong>기능동작확인</Text>
|
||||
<Segmented
|
||||
|
||||
@@ -270,8 +270,10 @@ export async function createPlanItem(draft: PlanDraft) {
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -289,8 +291,10 @@ export async function updatePlanItem(draft: PlanDraft) {
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -382,6 +386,9 @@ function normalizePlanItem(item: PlanItem): PlanItem {
|
||||
...item,
|
||||
automationType: normalizePlanAutomationType(item.automationType),
|
||||
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 : '',
|
||||
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
|
||||
};
|
||||
@@ -451,39 +458,67 @@ export type PlanScheduledTask = {
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationContextIds: string[];
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
enabled: 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;
|
||||
repeatIntervalValue: number;
|
||||
repeatIntervalUnit: PlanScheduleRepeatUnit;
|
||||
repeatIntervalSeconds: number;
|
||||
repeatIntervalMinutes: number;
|
||||
dailyRunTime: string;
|
||||
repeatWindowStartTime: string | null;
|
||||
repeatWindowEndTime: string | null;
|
||||
lastRegisteredAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlanScheduleExecutionMode = 'codex' | 'managed-service';
|
||||
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 = {
|
||||
id: number | null;
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationContextIds: string[];
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
enabled: boolean;
|
||||
immediateRunEnabled: boolean;
|
||||
refreshContextSnapshotOnNextRun: boolean;
|
||||
executionMode: PlanScheduleExecutionMode;
|
||||
recreateManagedServiceOnNextSave: boolean;
|
||||
scheduleMode: PlanScheduleMode;
|
||||
repeatIntervalValue: number;
|
||||
repeatIntervalUnit: PlanScheduleRepeatUnit;
|
||||
repeatIntervalSeconds: number;
|
||||
repeatIntervalMinutes: number;
|
||||
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) {
|
||||
@@ -521,33 +556,60 @@ export async function fetchPlanScheduledTasks() {
|
||||
return response.items.map((item) => ({
|
||||
...item,
|
||||
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) {
|
||||
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',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
releaseTarget: draft.releaseTarget,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
enabled: draft.enabled,
|
||||
immediateRunEnabled: draft.immediateRunEnabled,
|
||||
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
|
||||
executionMode: draft.executionMode,
|
||||
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
|
||||
scheduleMode: draft.scheduleMode,
|
||||
repeatIntervalValue: draft.repeatIntervalValue,
|
||||
repeatIntervalUnit: draft.repeatIntervalUnit,
|
||||
repeatIntervalSeconds: draft.repeatIntervalSeconds,
|
||||
repeatIntervalMinutes: draft.repeatIntervalMinutes,
|
||||
dailyRunTime: draft.dailyRunTime,
|
||||
repeatWindowStartTime: draft.repeatWindowStartTime,
|
||||
repeatWindowEndTime: draft.repeatWindowEndTime,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
};
|
||||
item: {
|
||||
...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) {
|
||||
@@ -555,29 +617,51 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
|
||||
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',
|
||||
body: JSON.stringify({
|
||||
workId: draft.workId,
|
||||
note: draft.note,
|
||||
automationType: draft.automationType,
|
||||
automationContextIds: draft.automationContextIds,
|
||||
releaseTarget: draft.releaseTarget,
|
||||
jangsingProcessingRequired: draft.jangsingProcessingRequired,
|
||||
autoDeployToMain: draft.autoDeployToMain,
|
||||
suppressWebPush: draft.suppressWebPush,
|
||||
enabled: draft.enabled,
|
||||
immediateRunEnabled: draft.immediateRunEnabled,
|
||||
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
|
||||
executionMode: draft.executionMode,
|
||||
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
|
||||
scheduleMode: draft.scheduleMode,
|
||||
repeatIntervalValue: draft.repeatIntervalValue,
|
||||
repeatIntervalUnit: draft.repeatIntervalUnit,
|
||||
repeatIntervalSeconds: draft.repeatIntervalSeconds,
|
||||
repeatIntervalMinutes: draft.repeatIntervalMinutes,
|
||||
dailyRunTime: draft.dailyRunTime,
|
||||
repeatWindowStartTime: draft.repeatWindowStartTime,
|
||||
repeatWindowEndTime: draft.repeatWindowEndTime,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
automationType: normalizePlanAutomationType(response.item.automationType),
|
||||
};
|
||||
item: {
|
||||
...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) {
|
||||
|
||||
@@ -111,11 +111,13 @@ export type PlanItem = {
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationBehaviorType?: string;
|
||||
automationContextIds: string[];
|
||||
releaseReviewNote: string;
|
||||
noteMasked?: boolean;
|
||||
status: PlanStatus;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
assignedBranch: string | null;
|
||||
@@ -137,9 +139,11 @@ export type PlanDraft = {
|
||||
workId: string;
|
||||
note: string;
|
||||
automationType: PlanAutomationType;
|
||||
automationContextIds: string[];
|
||||
status: PlanStatus;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush: boolean;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ export * from './layer';
|
||||
export * from './store';
|
||||
|
||||
export * from './widgets/core';
|
||||
export * from './widgets/ag-grid-widget';
|
||||
export * from './widgets/api-sample-card';
|
||||
export * from './widgets/dashboard-report-card';
|
||||
export * from './widgets/gps-sample-card';
|
||||
|
||||
52
src/sw.js
52
src/sw.js
@@ -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) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
@@ -46,18 +91,13 @@ self.addEventListener('push', (event) => {
|
||||
|
||||
const title = payload.title || 'AI Code App';
|
||||
const body = payload.body || '새 알림이 도착했습니다.';
|
||||
const notificationScope = payload.data?.notificationScope;
|
||||
const notificationKey =
|
||||
payload.data?.notificationKey ||
|
||||
[payload.threadId ?? 'ai-code-app-notification', payload.data?.eventType ?? 'event', Date.now()].join(':');
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.getNotifications().then((notifications) => {
|
||||
if (notificationScope === 'automation') {
|
||||
notifications
|
||||
.filter((notification) => notification.data?.notificationScope === 'automation')
|
||||
.forEach((notification) => notification.close());
|
||||
}
|
||||
notifications.filter((notification) => shouldCloseExistingNotification(notification, payload)).forEach((notification) => notification.close());
|
||||
|
||||
return self.registration.showNotification(title, {
|
||||
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
Reference in New Issue
Block a user