merge: sync origin main

This commit is contained in:
2026-04-24 08:53:56 +09:00
47 changed files with 2902 additions and 560 deletions

View File

@@ -16,9 +16,6 @@
* `test.sm-home.cloud``/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다
* 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다
* `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
* `자동화 작업메모`, `자동화 메모`, 자동화 접수된 작업메모는 **항상 신규 `feature/*` 브랜치를 생성해 작업**하고, 이후 `release` 반영과 `main` 일괄반영까지 진행한다
* 자동화 메모 작업을 실제 진행할 때 **현재 작업중인 자동화가 없다면**, 자동화 작업공간의 `release` 브랜치를 먼저 `main` 기준 최신 상태로 맞춘 뒤 다음 자동화 작업을 시작한다
* 자동화 작업메모의 `main` 일괄반영이 끝나면 **프로젝트 루트에서 최신 `main``pull --ff-only`로 동기화**한다
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
@@ -31,7 +28,7 @@
* 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다
* 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다
* 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석하고, 신규 `feature/*` 브랜치 생성부터 `release` 반영, `main` 일괄반영, 프로젝트 루트 `pull`까지 포함한 흐름을 적용한다
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석한다
* 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다
* 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다
* 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다
@@ -50,13 +47,10 @@
## Codex Live / 채팅 / 작업 메모 규칙
* `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
* 자동화 작업메모 반영 요청은 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull`** 순서를 기본으로 처리한다
* 자동화 작업메모를 시작하기 직전에 **진행 중인 자동화가 없으면**, 자동화 작업공간의 `release``main`과 먼저 맞춘 뒤 `feature/*` 작업을 시작한다
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
* 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다
* 자동화 작업메모는 Git flow를 기본으로 적용하며, 일반 채팅/수동 작업은 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
@@ -67,9 +61,8 @@
## Plan / 자동화 메모
* 일반 수동 작업에는 여전히 로컬 `main` 직접 수정 원칙을 유지한다
* 다만 자동화 작업메모와 Plan 자동화에는 기존 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 기본 규칙으로 다시 적용한
* 추가로 자동화 큐가 비어 있는 시점에 새 자동화 메모 작업을 시작하면, 자동화 작업공간의 `release``main`과 동기화한 뒤 같은 흐름을 이어간
* `hotfix/*` 흐름은 기존 예외 규칙을 유지하고, 자동화 대상이 아닌 일반 요청에는 자동 적용하지 않는다
* 자동화 작업메모와 Plan 자동화도 별도 Git 흐름을 기본 전제로 문서화하지 않는
* 자동화 처리 세부 절차가 필요하면 해당 기능 문서나 서버 설정이 아니라, 실제 요청 문맥과 현재 운영 설정을 기준으로 다시 확인한
---
@@ -77,8 +70,7 @@
👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다
👉 자동화가 비어 있으면 다음 자동화 시작 전 작업공간 `release``main`과 먼저 맞춘
👉 자동화 작업메모는 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 탄다
👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는
👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
---

View File

@@ -8,7 +8,7 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
- Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다.
- Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다.
- `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
- 단, 자동화 접수된 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름을 사용합니다.
- 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
- 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다.
## 시작하기

View File

@@ -1,63 +1,4 @@
services:
photoprism:
image: photoprism/photoprism:latest
container_name: photoprism
logging:
driver: json-file
options:
max-size: "200m"
max-file: "2"
depends_on:
- photoprism-db
ports:
- '127.0.0.1:${PHOTOPRISM_PORT:-2342}:2342'
volumes:
- type: bind
source: ${PHOTOPRISM_ORIGINALS_SOURCE:-/mnt/usb/photos}
target: /photoprism/originals
read_only: true
bind:
create_host_path: false
- photoprism-storage:/photoprism/storage
environment:
PHOTOPRISM_ADMIN_USER: ${PHOTOPRISM_ADMIN_USER:-admin}
PHOTOPRISM_ADMIN_PASSWORD: ${PHOTOPRISM_ADMIN_PASSWORD:-ChangeMe1234}
PHOTOPRISM_SITE_URL: ${PHOTOPRISM_SITE_URL:-https://photo.sm-home.cloud/}
PHOTOPRISM_ORIGINALS_PATH: /photoprism/originals
PHOTOPRISM_STORAGE_PATH: /photoprism/storage
PHOTOPRISM_READONLY: ${PHOTOPRISM_READONLY:-true}
PHOTOPRISM_DATABASE_DRIVER: mysql
PHOTOPRISM_DATABASE_SERVER: photoprism-db:3306
PHOTOPRISM_DATABASE_NAME: ${PHOTOPRISM_DATABASE_NAME:-photoprism}
PHOTOPRISM_DATABASE_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism}
PHOTOPRISM_DATABASE_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism}
restart: unless-stopped
networks:
- default
- work-backend
photoprism-db:
image: mariadb:11
container_name: photoprism-db
logging:
driver: json-file
options:
max-size: "200m"
max-file: "2"
command: --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DATABASE: ${PHOTOPRISM_DATABASE_NAME:-photoprism}
MARIADB_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism}
MARIADB_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism}
MARIADB_ROOT_PASSWORD: ${PHOTOPRISM_DATABASE_ROOT_PASSWORD:-photoprism-root}
volumes:
- photoprism-db:/var/lib/mysql
restart: unless-stopped
networks:
- default
- work-backend
prod-app:
image: node:${NODE_VERSION:-22.22.2}-bookworm
container_name: ai-code-app-prod
@@ -125,12 +66,12 @@ services:
user: "0:0"
cpus: 1.0
mem_limit: 1536m
working_dir: /release-app
working_dir: /workspace/auto_codex/repo
ports:
- '127.0.0.1:5175:5173'
volumes:
- ${RELEASE_APP_SOURCE:-.}:/release-app
- ./.docker/release-app/node_modules:/release-app/node_modules
- ./.auto_codex/repo:/workspace/auto_codex/repo
- ./.docker/release-app/node_modules:/workspace/auto_codex/repo/node_modules
- ./.docker/release-app/home:/home/how2ice
networks:
- default
@@ -138,6 +79,9 @@ services:
environment:
HOME: /home/how2ice
NPM_CONFIG_CACHE: /home/how2ice/.npm
VITE_PUBLIC_HMR_HOST: rel.sm-home.cloud
VITE_PUBLIC_HMR_PROTOCOL: wss
VITE_PUBLIC_HMR_CLIENT_PORT: 443
command: >
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173"
@@ -148,5 +92,3 @@ networks:
volumes:
app-node-modules:
app-home:
photoprism-storage:
photoprism-db:

View File

@@ -7,7 +7,8 @@
- 현재 저장소는 당분간 로컬 전용으로 운영합니다.
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
- `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
- 자동화 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름으로 처리합니다.
- 자동화 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
- 자동화와 `Codex Live`는 별개로 취급하며, 자동화는 선택된 자동화 유형 context만 우선 참조합니다.
- Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
## 1. 작업일지

View File

@@ -4,7 +4,7 @@
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
현재 운영 규칙에서 자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치에서 시작하며, `release` 반영과 `main` 일괄반영, 프로젝트 루트 `pull --ff-only`까지 이어집니다. 또한 새 자동화 메모 작업을 시작할 때 현재 작업중인 자동화가 없다면, 자동화 작업공간의 `release`를 먼저 `main` 기준 최신 상태로 맞춘 뒤 다음 작업을 진행합니다. 반면 `Codex Live`나 일반 수동 요청은 여전히 로컬 `main` 직접 수정 기준을 유지합니다.
현재 문서는 자동화 브랜치 전략 자체를 고정 규칙으로 설명하지 않습니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
## 구현 위치
@@ -38,6 +38,12 @@ Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연
기존 저장값인 `plan_registration`, `general_development`는 서버에서 각각 `plan`, `auto_worker`로 정규화합니다.
자동화 context 해석 규칙:
- 자동화 실행 시 기본 문맥은 선택된 자동화 유형 description/context만 사용
- `Codex Live` 채팅 문맥, 일반 채팅 문맥은 자동화 기본 context로 섞지 않음
- 추가 지시가 필요하면 요청 본문에 명시적으로 적어 전달
## API 연동 방식
기본 API 베이스 URL 규칙:
@@ -116,9 +122,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
자동화 메모 작업을 시작하는 시점에 진행 중인 다른 자동화가 없다면, worker는 먼저 자동화 작업공간의 `release``main`과 맞춰 기준 브랜치를 정리한 뒤 새 `feature/*` 작업을 시작해야 합니다. 이 단계는 유휴 상태에서만 수행하고, 이미 다른 자동화가 돌고 있는 동안에는 중간 기준 브랜치를 임의로 재정렬하지 않습니다.
자동화 작업메모가 `main` 반영 단계까지 끝나면 worker는 `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`를 수행해 실제 작업본을 최신 `main`으로 맞춥니다.
자동화 작업의 Git 절차나 동기화 순서는 이 문서에 고정 규칙으로 적지 않습니다. 필요 시 실제 worker 구현과 현재 설정값을 함께 확인합니다.
## 차트 집계 방식

View File

@@ -49,7 +49,7 @@
2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다.
3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다.
4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다.
5. main 반영이 끝나면 프로젝트 루트 `pull --ff-only`까지 수행한 뒤 최종 완료 흐름으로 정리됩니다.
5. 최종 완료 처리는 현재 worker 상태와 운영 설정을 기준으로 정리됩니다.
## 목록 기능
@@ -88,7 +88,7 @@
- `작업취소`
- `main 일괄 반영 요청`
자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치로 시작하며, `release` 반영과 `main` 일괄 반영 뒤 프로젝트 루트 동기화까지 포함합니다.
자동화 작업메모(`auto_worker`)의 Git 절차는 이 문서에서 고정 규칙으로 설명하지 않습니다. 세부 흐름은 실제 worker 구현과 현재 운영 설정을 확인합니다.
세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다.

View File

@@ -57,15 +57,9 @@ npm run server-command:runner
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다.
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
단, 자동화 작업메모(`auto_worker`)예외적으로 아래 Git 흐름을 기본 동작으로 사용합니다.
- 신규 `feature/*` 브랜치 생성
- 자동 작업 수행
- `release` 브랜치 반영
- `main` 일괄반영
- `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`
`Codex Live``Plan` 자동화는 별개입니다. `Codex Live`채팅 유형과 현재 화면 문맥을 기준으로 동작하고, 자동화 유형 context를 기본 문맥으로 섞지 않습니다.
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud``rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
@@ -77,15 +71,9 @@ npm run server-command:runner
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다.
현재 운영 기준에서 자동화 작업메모의 세부 Git 절차는 이 문서에 고정하지 않습니다. 상태 전이와 실제 처리 흐름은 worker 구현, 환경 변수, 현재 운영 정책을 함께 확인해야 합니다.
- `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성
- 성공 시: `작업중`, `브랜치준비`
- 실패 시: `이슈`, 최근 오류 기록
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도
- 병합 성공 시: `완료`
- 병합 실패 시: `이슈`
- `main` 반영 성공 시: `PLAN_MAIN_PROJECT_REPO_PATH`에서 `main` 브랜치 `pull --ff-only`까지 수행
자동화 실행기는 선택된 자동화 유형의 description/context만 우선 참조합니다. `Codex Live`나 일반 채팅 문맥은 자동화 기본 context로 사용하지 않습니다.
안전 조건:

View File

@@ -12,7 +12,6 @@ services:
user: "0:0"
group_add:
- "${SERVER_COMMAND_DOCKER_GID:-984}"
cpus: 1.5
mem_limit: 2048m
working_dir: /app
env_file:

View File

@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => {
@@ -21,6 +22,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/automation-types', async () => {
const automationTypes = await getAutomationTypesConfig();
return {
ok: true,
automationTypes,
};
});
app.put('/api/chat-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
@@ -50,6 +60,35 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
});
app.put('/api/automation-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
automationTypes: z.array(z.unknown()),
}).parse(payload ?? {});
const savedAutomationTypes = await upsertAutomationTypesConfig(parsed.automationTypes);
return {
ok: true,
automationTypes: savedAutomationTypes,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.',
});
}
});
app.put('/api/app-config', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};

View File

@@ -9,6 +9,8 @@ import { PlanWorker } from './workers/plan-worker.js';
const app = createApp();
const planWorker = new PlanWorker(app.log);
const chatService = new ChatService(app.log);
const startedAt = Date.now();
let shutdownPromise: Promise<void> | null = null;
app.server.on('upgrade', chatService.attachUpgradeHandler());
async function start() {
@@ -27,14 +29,32 @@ async function start() {
}
async function shutdown(signal: string) {
app.log.info(`Received ${signal}, closing server`);
if (shutdownPromise) {
return shutdownPromise;
}
await planWorker.stop();
chatService.close();
await app.close();
await shutdownNotificationProvider();
await db.destroy();
process.exit(0);
shutdownPromise = (async () => {
app.log.warn({
signal,
pid: process.pid,
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
rssBytes: process.memoryUsage().rss,
}, 'Received shutdown signal');
try {
await planWorker.stop();
chatService.close();
await app.close();
await shutdownNotificationProvider();
await db.destroy();
process.exitCode = 0;
} catch (error) {
app.log.error({ error, signal }, 'Failed to shut down cleanly');
process.exitCode = 1;
}
})();
return shutdownPromise;
}
process.on('SIGINT', () => {

View File

@@ -3,6 +3,37 @@ import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
type ChatPermissionRole = 'guest' | 'token-user';
type ChatTypeRecord = {
id: string;
name: string;
description: string;
permissions: ChatPermissionRole[];
enabled: boolean;
updatedAt: string;
};
const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'api-request-template',
name: 'API요청',
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
];
async function ensureAppConfigTable() {
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
@@ -58,6 +89,134 @@ function normalizeConfigRecord(value: unknown) {
return value as Record<string, unknown>;
}
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizePermissions(value: unknown): ChatPermissionRole[] {
if (!Array.isArray(value)) {
return ['token-user'];
}
const permissions = Array.from(
new Set(
value.filter(
(item): item is ChatPermissionRole => item === 'guest' || item === 'token-user',
),
),
);
return permissions.length > 0 ? permissions : ['token-user'];
}
function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Partial<ChatTypeRecord>;
const name = normalizeText(record.name);
if (!name) {
return null;
}
return {
id: normalizeText(record.id) || `chat-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
name,
description: normalizeText(record.description),
permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
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 sanitizeChatTypes(items: unknown[]) {
const normalized = items
.map((item) => normalizeChatTypeRecord(item))
.filter((item): item is ChatTypeRecord => Boolean(item));
const byId = new Map<string, ChatTypeRecord>();
const bySemanticKey = new Map<string, ChatTypeRecord>();
for (const item of normalized) {
const current = byId.get(item.id);
if (!current || compareUpdatedAt(current, item) <= 0) {
byId.set(item.id, item);
}
}
for (const item of byId.values()) {
const semanticKey = buildChatTypeSemanticKey(item);
const current = bySemanticKey.get(semanticKey);
if (!current || compareUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
}
function mergeDefaultChatTypes(items: unknown[]) {
const savedItems = sanitizeChatTypes(items);
const byId = new Map(savedItems.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_CHAT_TYPES) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: defaultItem.description,
permissions: defaultItem.permissions,
});
}
return sanitizeChatTypes(Array.from(byId.values()));
}
function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
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.enabled === target.enabled &&
item.updatedAt === target.updatedAt &&
item.permissions.length === target.permissions.length &&
item.permissions.every((permission, permissionIndex) => permission === target.permissions[permissionIndex])
);
});
}
export type AppConfigSnapshot = {
chat?: {
maxContextMessages?: number;
@@ -149,16 +308,30 @@ export async function getChatTypesConfig() {
const config = await getAppConfig();
const normalized = normalizeConfigRecord(config);
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
return Array.isArray(chatTypes) ? chatTypes : null;
if (chatTypes == null) {
return null;
}
const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) {
await upsertAppConfig({
[CHAT_TYPES_CONFIG_KEY]: mergedChatTypes,
});
}
return mergedChatTypes;
}
export async function upsertChatTypesConfig(chatTypes: unknown[]) {
const current = normalizeConfigRecord(await getAppConfig());
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
const nextConfig = {
...current,
[CHAT_TYPES_CONFIG_KEY]: Array.isArray(chatTypes) ? chatTypes : [],
[CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
};
await upsertAppConfig(nextConfig);
return nextConfig[CHAT_TYPES_CONFIG_KEY] as unknown[];
return resolvedChatTypes;
}

View File

@@ -0,0 +1,390 @@
import { db } from '../db/client.js';
import { getAppConfig } from './app-config-service.js';
const AUTOMATION_TYPES_TABLE = 'automation_types';
const AUTOMATION_TYPES_CONFIG_KEY = 'automationTypes';
export const AUTOMATION_BEHAVIOR_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
] as const;
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeRecord = {
id: string;
name: string;
description: string;
behaviorType: AutomationBehaviorType;
enabled: boolean;
updatedAt: string;
};
export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'plan',
name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
behaviorType: 'plan',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'command_execution',
name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'non_source_work',
name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
behaviorType: 'non_source_work',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'auto_worker',
name: 'autoWorker',
description:
'자동화 작업메모로 처리합니다.\n\n## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
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: 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() : '';
}
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;
}
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.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('')],
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
createColumn(table);
});
}
}
}
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
return AUTOMATION_BEHAVIOR_TYPES.includes(normalizedValue as AutomationBehaviorType)
? (normalizedValue as AutomationBehaviorType)
: 'none';
}
export function normalizeLegacyAutomationBehaviorType(value: unknown): string {
const normalizedValue = normalizeText(value);
if (normalizedValue === 'plan_registration') {
return 'plan';
}
if (normalizedValue === 'general_development') {
return 'auto_worker';
}
return normalizedValue;
}
function buildNameKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
const name = normalizeText(record.name);
if (!name) {
return null;
}
const rawId = normalizeText(record.id);
const normalizedId =
rawId ||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
name,
description: normalizeText(record.description),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: normalizeEnabled(record.enabled),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecord) {
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 dedupeAutomationTypes(items: AutomationTypeRecord[]) {
const byId = new Map<string, AutomationTypeRecord>();
const bySemanticKey = new Map<string, AutomationTypeRecord>();
for (const item of items) {
const currentById = byId.get(item.id);
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
}
for (const item of byId.values()) {
const semanticKey = `${item.behaviorType}:${buildNameKey(item.name)}`;
const current = bySemanticKey.get(semanticKey);
if (!current || compareUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
}
export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] | null | undefined) {
const normalized = (items ?? [])
.map((item) => normalizeAutomationType(item))
.filter((item): item is AutomationTypeRecord => Boolean(item));
if (normalized.length === 0) {
return DEFAULT_AUTOMATION_TYPES;
}
return dedupeAutomationTypes(normalized);
}
function normalizeConfigRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
}
return value as Record<string, unknown>;
}
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,
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
enabled: normalizeEnabled(row.enabled),
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
});
}
async function seedAutomationTypesFromLegacyConfig() {
const config = normalizeConfigRecord(await getAppConfig());
const raw = config[AUTOMATION_TYPES_CONFIG_KEY];
if (!Array.isArray(raw) || raw.length === 0) {
return mergeDefaultAutomationTypes(DEFAULT_AUTOMATION_TYPES);
}
const legacyItems = mergeDefaultAutomationTypes(
sanitizeAutomationTypes(raw as Partial<AutomationTypeRecord>[]),
);
await replaceAutomationTypesInTable(legacyItems);
return legacyItems;
}
async function readAutomationTypesFromTable() {
await ensureAutomationTypesTable();
const rows = await db(AUTOMATION_TYPES_TABLE)
.select('id', 'name', 'description', 'behavior_type', 'enabled', 'updated_at')
.orderBy('name', 'asc');
const savedItems = rows
.map((row) => toAutomationTypeRecord(row as Record<string, unknown>))
.filter((item): item is AutomationTypeRecord => Boolean(item));
return sanitizeAutomationTypes(savedItems);
}
async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
await ensureAutomationTypesTable();
const nextItems = sanitizeAutomationTypes(items);
await db.transaction(async (trx) => {
await trx(AUTOMATION_TYPES_TABLE).del();
await trx(AUTOMATION_TYPES_TABLE).insert(
nextItems.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
behavior_type: item.behaviorType,
enabled: item.enabled,
updated_at: item.updatedAt,
})),
);
});
return nextItems;
}
export async function getAutomationTypesConfig() {
const savedAutomationTypes = await readAutomationTypesFromTable();
const automationTypes = mergeDefaultAutomationTypes(savedAutomationTypes);
if (automationTypes.length === 0 || automationTypes === DEFAULT_AUTOMATION_TYPES) {
return seedAutomationTypesFromLegacyConfig();
}
if (!isSameAutomationTypeList(savedAutomationTypes, automationTypes)) {
await replaceAutomationTypesInTable(automationTypes);
}
return automationTypes;
}
export async function upsertAutomationTypesConfig(items: unknown[]) {
const nextAutomationTypes = mergeDefaultAutomationTypes(
sanitizeAutomationTypes(Array.isArray(items) ? (items as Partial<AutomationTypeRecord>[]) : []),
);
return replaceAutomationTypesInTable(nextAutomationTypes);
}
export async function resolveAutomationType(input: unknown) {
const requestedId = normalizeLegacyAutomationBehaviorType(input);
const automationTypes = await getAutomationTypesConfig();
const matched = automationTypes.find((item) => item.id === requestedId);
if (matched) {
return matched;
}
const matchedByBehavior = automationTypes.find((item) => item.behaviorType === normalizeBehaviorType(requestedId));
if (matchedByBehavior) {
return matchedByBehavior;
}
return (
DEFAULT_AUTOMATION_TYPES.find((item) => item.id === normalizeBehaviorType(requestedId)) ??
DEFAULT_AUTOMATION_TYPES[0]
);
}
export function resolveStoredAutomationTypeId(row: Record<string, unknown>) {
const automationTypeId = normalizeText(row.automation_type_id);
if (automationTypeId) {
return normalizeLegacyAutomationBehaviorType(automationTypeId);
}
return normalizeLegacyAutomationBehaviorType(row.automation_type) || 'none';
}

View File

@@ -4,12 +4,20 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
assert.equal(
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n'),
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
}),
[
'# 자동화 작업메모',
'',
'- 게시판 제목: 알림 개선',
'- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 자동화 메모',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
'## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
'',
'## 요청 본문',
'본문 첫 줄\n본문 둘째 줄',
@@ -17,6 +25,29 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
);
});
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
assert.equal(
buildBoardPostPlanNote('작업', '본문', {
name: '빈 context 유형',
description: ' ',
}),
[
'# 자동화 작업메모',
'',
'- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 빈 context 유형',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
'선택된 자동화 유형 context 없음',
'',
'## 요청 본문',
'본문',
].join('\n'),
);
});
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');

View File

@@ -6,6 +6,11 @@ import {
PLAN_TABLE,
planAutomationTypeSchema,
} from './plan-service.js';
import {
resolveAutomationType,
resolveStoredAutomationTypeId,
type AutomationTypeRecord,
} from './automation-type-config-service.js';
export const BOARD_POSTS_TABLE = 'board_posts';
@@ -58,7 +63,7 @@ function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
title: String(row.title ?? ''),
content,
preview: createPreview(content),
automationType: normalizePlanAutomationType(row.automation_type),
automationType: resolveStoredAutomationTypeId(row),
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
? null
: Number(row.automation_plan_item_id),
@@ -74,19 +79,28 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
return Boolean(row.automation_received_at || row.automation_plan_item_id);
}
export function buildBoardPostPlanNote(title: string, content: string) {
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null) {
const normalizedTitle = title.trim();
const normalizedContent = content.trim();
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
const normalizedAutomationContext = String(automationType?.description ?? '').trim();
return [
'# 자동화 작업메모',
'',
`- 게시판 제목: ${normalizedTitle}`,
'- 메모 출처: board_posts 자동화 접수',
normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null,
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
normalizedAutomationContext || '선택된 자동화 유형 context 없음',
'',
'## 요청 본문',
normalizedContent,
].join('\n');
]
.filter((line): line is string => line !== null)
.join('\n');
}
function resolveInsertedId(result: unknown): number | null {
@@ -159,6 +173,7 @@ export async function ensureBoardPostsTable() {
['title', (table) => table.string('title', 200).notNullable().defaultTo('제목 없음')],
['content', (table) => table.text('content').notNullable().defaultTo('')],
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -187,6 +202,11 @@ export async function ensureBoardPostsTable() {
await db(BOARD_POSTS_TABLE)
.where({ automation_type: 'general_development' })
.update({ automation_type: 'auto_worker' });
await db(BOARD_POSTS_TABLE)
.whereNull('automation_type_id')
.update({
automation_type_id: db.raw('automation_type'),
});
}
export async function listBoardPosts() {
@@ -206,10 +226,12 @@ export async function getBoardPost(id: number) {
export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSchema>) {
await ensureBoardPostsTable();
const parsedPayload = boardPostPayloadSchema.parse(payload);
const automationType = await resolveAutomationType(parsedPayload.automationType);
const insertQuery = db(BOARD_POSTS_TABLE).insert({
title: parsedPayload.title,
content: parsedPayload.content,
automation_type: parsedPayload.automationType,
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
created_at: db.fn.now(),
updated_at: db.fn.now(),
});
@@ -255,10 +277,12 @@ export async function receiveBoardPostAutomation(id: number) {
const title = String(currentRow.title ?? '').trim();
const content = String(currentRow.content ?? '').trim();
const workId = `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),
note: buildBoardPostPlanNote(title, content, automationType),
automation_type: normalizePlanAutomationType(currentRow.automation_type),
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
status: '등록',
release_target: 'release',
jangsing_processing_required: true,
@@ -303,6 +327,7 @@ export async function receiveBoardPostAutomation(id: number) {
export async function updateBoardPost(id: number, payload: z.infer<typeof boardPostPayloadSchema>) {
await ensureBoardPostsTable();
const parsedPayload = boardPostPayloadSchema.parse(payload);
const automationType = await resolveAutomationType(parsedPayload.automationType);
return db.transaction(async (trx) => {
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
@@ -319,7 +344,8 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
.update({
title: parsedPayload.title,
content: parsedPayload.content,
automation_type: parsedPayload.automationType,
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
updated_at: trx.fn.now(),
});

View File

@@ -40,7 +40,7 @@ test('shouldUseAgenticCodexReply keeps fast-path responses for automation regist
assert.equal(shouldUseAgenticCodexReply('오늘 자동화 등록 총 건수'), false);
});
test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => {
test('shouldUseTemplateMacroReply is disabled for chat types', () => {
assert.equal(
shouldUseTemplateMacroReply(
{
@@ -49,13 +49,12 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope
topMenu: 'chat',
focusedComponentId: null,
pageUrl: 'https://test.sm-home.cloud/chat/live',
chatTypeLabel: 'API 요청 템플릿',
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
chatTypeIsTemplate: true,
chatTypeLabel: 'API요청',
chatTypeDescription: 'API 요청 본문을 정리합니다.',
},
'이 템플릿 예시 보여줘',
),
true,
false,
);
assert.equal(
@@ -66,9 +65,8 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope
topMenu: 'chat',
focusedComponentId: null,
pageUrl: 'https://test.sm-home.cloud/chat/live',
chatTypeLabel: 'API 요청 템플릿',
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
chatTypeIsTemplate: true,
chatTypeLabel: 'API요청',
chatTypeDescription: 'API 요청 본문을 정리합니다.',
},
'아이패드 말풍선 폰트 조금 줄여줘',
),
@@ -85,7 +83,6 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope
pageUrl: 'https://test.sm-home.cloud/chat/live',
chatTypeLabel: '일반 요청',
chatTypeDescription: '일반 요청',
chatTypeIsTemplate: false,
},
'템플릿 예시 보여줘',
),

View File

@@ -60,7 +60,6 @@ type ChatContext = {
chatTypeId?: string | null;
chatTypeLabel?: string;
chatTypeDescription?: string;
chatTypeIsTemplate?: boolean;
};
type ChatInboundMessage =
@@ -89,7 +88,6 @@ type ChatInboundMessage =
chatTypeId?: string | null;
chatTypeLabel?: string;
chatTypeDescription?: string;
chatTypeIsTemplate?: boolean;
};
}
| {
@@ -875,45 +873,10 @@ export function shouldUseAgenticCodexReply(input: string) {
);
}
function extractTemplateKeywords(context: ChatContext | null) {
const raw = `${context?.chatTypeLabel ?? ''} ${context?.chatTypeDescription ?? ''}`;
return Array.from(
new Set(
raw
.split(/[^0-9A-Za-z가-힣]+/)
.map((keyword) => keyword.trim())
.filter((keyword) => keyword.length >= 2)
.filter(
(keyword) =>
!['템플릿', 'template', 'chat', 'codex', 'live', '요청', '일반', '기본', '유형'].includes(keyword.toLowerCase()),
),
),
);
}
export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) {
if (context?.chatTypeIsTemplate !== true) {
return false;
}
const normalized = input.toLowerCase();
if (
input.includes('템플릿') ||
input.includes('양식') ||
input.includes('포맷') ||
input.includes('형식') ||
input.includes('예시') ||
input.includes('샘플') ||
normalized.includes('template') ||
normalized.includes('format') ||
normalized.includes('sample')
) {
return true;
}
return extractTemplateKeywords(context).some((keyword) => normalized.includes(keyword.toLowerCase()));
void context;
void input;
return false;
}
function summarizeCodexOutput(output: string) {
@@ -1466,10 +1429,10 @@ function buildAgenticCodexPrompt(
const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`;
const recentHistoryLines = promptContext?.recentHistoryLines ?? [];
const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0);
const isTemplateRequest = context?.chatTypeIsTemplate === true;
return [
'당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.',
'Codex Live는 Plan 자동화와 별개입니다. 자동화 유형 context를 Codex Live 기본 문맥으로 섞지 마세요.',
`저장소 루트(main_project): ${repoPath}`,
'반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
'가능한 작업 범위:',
@@ -1477,7 +1440,7 @@ function buildAgenticCodexPrompt(
'- 필요 시 DB 직접 조회',
'- 필요 시 로컬 API 응답 확인',
'- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정',
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md의 브랜치 전략을 먼저 확인하고 그 규칙 안에서만 작업하세요.',
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md 먼저 확인하고 그 규칙 안에서만 작업하세요.',
`- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`,
`- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`,
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
@@ -1494,8 +1457,8 @@ function buildAgenticCodexPrompt(
'채팅 유형 문맥(우선 적용):',
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
'',
'참고 화면 정보:',
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
@@ -1503,20 +1466,15 @@ function buildAgenticCodexPrompt(
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
'',
isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:',
...(isTemplateRequest
'최근 대화 문맥:',
...(recentHistoryLines.length > 0
? [
'- 이 요청은 템플릿 유형입니다.',
'- 이전 채팅방 내용은 참조하지 말고, 현재 화면 문맥/유형 설명/사용자 요청만 기준으로 처리하세요.',
...recentHistoryLines.map((line) => `- ${line}`),
...(omittedHistoryCount > 0
? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
: []),
]
: recentHistoryLines.length > 0
? [
...recentHistoryLines.map((line) => `- ${line}`),
...(omittedHistoryCount > 0
? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
: []),
]
: ['- 참조할 최근 대화가 없습니다.']),
: ['- 참조할 최근 대화가 없습니다.']),
'',
'사용자 요청:',
input,
@@ -1669,13 +1627,10 @@ async function runAgenticCodexReply(
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const appConfig = await getAppConfigSnapshot();
const recentHistory =
context?.chatTypeIsTemplate === true
? { items: [] as string[], omittedCount: 0 }
: await buildRecentChatPromptHistory(sessionId, requestId, {
maxMessages: appConfig.chat?.maxContextMessages,
maxChars: appConfig.chat?.maxContextChars,
});
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
maxMessages: appConfig.chat?.maxContextMessages,
maxChars: appConfig.chat?.maxContextChars,
});
const prompt = buildAgenticCodexPrompt(context, input, sessionId, {
recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount,
@@ -2980,7 +2935,6 @@ export class ChatService {
chatTypeId: message.payload.chatTypeId ?? null,
chatTypeLabel: message.payload.chatTypeLabel,
chatTypeDescription: message.payload.chatTypeDescription,
chatTypeIsTemplate: message.payload.chatTypeIsTemplate,
},
).catch((error: unknown) => {
this.logger.error(error, 'chat reply build failed');

View File

@@ -1,5 +1,6 @@
import { z } from 'zod';
import { db } from '../db/client.js';
import { resolveAutomationType, resolveStoredAutomationTypeId } from './automation-type-config-service.js';
import {
createCompletedPlanExecutionLogItem,
createPlanActionHistory,
@@ -186,7 +187,7 @@ export function mapPlanScheduledTaskRow(row: Record<string, unknown>) {
id: row.id,
workId: row.work_id,
note: row.note,
automationType: normalizePlanAutomationType(row.automation_type),
automationType: resolveStoredAutomationTypeId(row),
releaseTarget: row.release_target,
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
@@ -229,6 +230,7 @@ export async function ensurePlanScheduledTaskTable() {
table.string('work_id', 120).notNullable().defaultTo('반복작업');
table.text('note').notNullable().defaultTo('');
table.string('automation_type', 40).notNullable().defaultTo('none');
table.string('automation_type_id', 120).nullable();
table.string('release_target', 120).notNullable().defaultTo('release');
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
@@ -252,6 +254,9 @@ export async function ensurePlanScheduledTaskTable() {
await ensurePlanScheduledTaskColumn('automation_type', (table) => {
table.string('automation_type', 40).notNullable().defaultTo('none');
});
await ensurePlanScheduledTaskColumn('automation_type_id', (table) => {
table.string('automation_type_id', 120).nullable();
});
await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => {
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
});
@@ -295,6 +300,11 @@ export async function ensurePlanScheduledTaskTable() {
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ automation_type: 'general_development' })
.update({ automation_type: 'auto_worker' });
await db(PLAN_SCHEDULED_TASK_TABLE)
.whereNull('automation_type_id')
.update({
automation_type_id: db.raw('automation_type'),
});
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ repeat_interval_unit: 'minute' })
@@ -320,12 +330,14 @@ export async function createPlanScheduledTask(payload: z.infer<typeof createPlan
const scheduleMode = normalizeScheduleMode(payload.scheduleMode);
const repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue);
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit);
const automationType = await resolveAutomationType(payload.automationType);
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
.insert({
work_id: normalizeScheduledWorkId(payload.workId),
note: payload.note,
automation_type: normalizePlanAutomationType(payload.automationType),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
@@ -358,13 +370,17 @@ export async function updatePlanScheduledTask(id: number, payload: z.infer<typeo
payload.repeatIntervalValue ?? currentRow.repeat_interval_value ?? currentRow.repeat_interval_minutes ?? 60,
);
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit ?? currentRow.repeat_interval_unit);
const automationType = await resolveAutomationType(
payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type,
);
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id })
.update({
work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId),
note: payload.note ?? currentRow.note,
automation_type: normalizePlanAutomationType(payload.automationType ?? currentRow.automation_type),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
release_target: payload.releaseTarget ?? currentRow.release_target ?? 'release',
jangsing_processing_required:
payload.jangsingProcessingRequired ?? currentRow.jangsing_processing_required ?? true,
@@ -458,7 +474,7 @@ async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: D
const executionLog = await createCompletedPlanExecutionLogItem({
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
note: executionLogNoteLines.join('\n'),
automationType: 'plan',
automationType: String(row.automation_type_id ?? row.automation_type ?? 'plan'),
releaseTarget: String(row.release_target ?? 'release'),
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
@@ -500,7 +516,7 @@ async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: D
const createdPlan = await createPlanItem({
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
note: String(row.note ?? ''),
automationType: normalizePlanAutomationType(row.automation_type),
automationType: String(row.automation_type_id ?? row.automation_type ?? 'none'),
releaseTarget: String(row.release_target ?? 'release'),
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),

View File

@@ -1,6 +1,12 @@
import { z } from 'zod';
import { getEnv } from '../config/env.js';
import { db } from '../db/client.js';
import {
normalizeLegacyAutomationBehaviorType,
resolveAutomationType,
resolveStoredAutomationTypeId,
type AutomationBehaviorType,
} from './automation-type-config-service.js';
import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js';
export const PLAN_TABLE = 'plan_items';
@@ -53,6 +59,7 @@ type PlanRowOptions = {
maskNote?: boolean;
noteMasked?: boolean;
releaseReviewNote?: string;
exposeConfiguredAutomationType?: boolean;
};
export const statusSchema = z.enum(planStatuses);
@@ -62,32 +69,15 @@ export const setupSchema = z.object({
});
function resolvePlanAutomationTypeAlias(value: unknown) {
if (typeof value !== 'string') {
return value;
}
const normalizedValue = value.trim();
if (normalizedValue === 'plan_registration') {
return 'plan';
}
if (normalizedValue === 'general_development') {
return 'auto_worker';
}
return normalizedValue;
return normalizeLegacyAutomationBehaviorType(value);
}
export const planAutomationTypeSchema = z.preprocess(
resolvePlanAutomationTypeAlias,
z.enum(planAutomationTypes),
);
export const planAutomationTypeSchema = z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120));
export const createPlanSchema = z.object({
workId: z.string().trim().optional().default('작업ID'),
note: z.string().default(''),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).default('none')),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')),
releaseTarget: z.string().trim().min(1).default('release'),
jangsingProcessingRequired: z.boolean().default(true),
autoDeployToMain: z.boolean().default(true),
@@ -98,7 +88,7 @@ export const createPlanSchema = z.object({
export const updatePlanSchema = z.object({
workId: z.string().trim().optional(),
note: z.string().optional(),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).optional()),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()),
releaseTarget: z.string().trim().min(1).optional(),
jangsingProcessingRequired: z.boolean().optional(),
autoDeployToMain: z.boolean().optional(),
@@ -135,7 +125,7 @@ export const listPlanQuerySchema = z.object({
});
export type PlanStatus = (typeof planStatuses)[number];
export type PlanAutomationType = (typeof planAutomationTypes)[number];
export type PlanAutomationType = string;
export type PlanWorkerStatus = (typeof planWorkerStatuses)[number];
export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number];
@@ -191,8 +181,8 @@ function normalizePlanWorkId(value?: string | null) {
export function normalizePlanAutomationType(value: unknown): PlanAutomationType {
const normalizedValue = resolvePlanAutomationTypeAlias(value);
return planAutomationTypes.includes(normalizedValue as PlanAutomationType)
? (normalizedValue as PlanAutomationType)
return planAutomationTypes.includes(normalizedValue as AutomationBehaviorType)
? (normalizedValue as AutomationBehaviorType)
: 'none';
}
@@ -281,7 +271,8 @@ export function mapPlanRow(
id: row.id,
workId: row.work_id,
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
automationType: normalizePlanAutomationType(row.automation_type),
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
automationBehaviorType: normalizePlanAutomationType(row.automation_type),
releaseReviewNote: options?.releaseReviewNote ?? '',
noteMasked: Boolean(options?.noteMasked),
status: row.status,
@@ -825,6 +816,9 @@ async function syncPlanColumns() {
await ensureColumn('automation_type', (table) => {
table.string('automation_type', 40).notNullable().defaultTo('none');
});
await ensureColumn('automation_type_id', (table) => {
table.string('automation_type_id', 120).nullable();
});
await ensureColumn('auto_deploy_to_main', (table) => {
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
});
@@ -871,6 +865,11 @@ async function syncPlanColumns() {
await db(PLAN_TABLE)
.where({ automation_type: 'general_development' })
.update({ automation_type: 'auto_worker' });
await db(PLAN_TABLE)
.whereNull('automation_type_id')
.update({
automation_type_id: db.raw('automation_type'),
});
}
async function dropPlanWorkIdUniqueConstraint() {
@@ -1090,13 +1089,15 @@ export async function ensurePlanTable() {
export async function createPlanItem(payload: z.infer<typeof createPlanSchema>) {
await ensurePlanTable();
const workId = normalizePlanWorkId(payload.workId);
const automationType = await resolveAutomationType(payload.automationType);
const rows = await db(PLAN_TABLE)
.insert({
work_id: workId,
note: payload.note,
status: '등록',
automation_type: normalizePlanAutomationType(payload.automationType),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
@@ -1114,13 +1115,15 @@ export async function createPlanItem(payload: z.infer<typeof createPlanSchema>)
export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeof createPlanSchema>) {
await ensurePlanTable();
const workId = normalizePlanWorkId(payload.workId);
const automationType = await resolveAutomationType(payload.automationType);
const rows = await db(PLAN_TABLE)
.insert({
work_id: workId,
note: payload.note,
status: '완료',
automation_type: normalizePlanAutomationType(payload.automationType),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
@@ -1159,11 +1162,12 @@ export async function upsertAutoPlanItem(args: {
.first();
if (!existingRow) {
const automationType = await resolveAutomationType(args.automationType);
return {
row: await createPlanItem({
workId,
note: args.note,
automationType: normalizePlanAutomationType(args.automationType),
automationType: automationType.id,
releaseTarget: args.releaseTarget,
jangsingProcessingRequired: args.jangsingProcessingRequired,
autoDeployToMain: args.autoDeployToMain,
@@ -1175,7 +1179,7 @@ export async function upsertAutoPlanItem(args: {
}
const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release';
const nextAutomationType = normalizePlanAutomationType(args.automationType ?? existingRow.automation_type);
const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type);
const nextJangsingProcessingRequired = args.jangsingProcessingRequired;
const nextAutoDeployToMain = args.autoDeployToMain;
const nextNote = args.note;
@@ -1185,7 +1189,7 @@ export async function upsertAutoPlanItem(args: {
: existingRow.normal_processing_level === '상';
const hasPayloadChange =
existingRow.note !== nextNote ||
normalizePlanAutomationType(existingRow.automation_type) !== nextAutomationType ||
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(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired;
@@ -1202,7 +1206,8 @@ export async function upsertAutoPlanItem(args: {
.where({ id: existingRow.id })
.update({
note: nextNote,
automation_type: nextAutomationType,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
@@ -1223,7 +1228,8 @@ export async function upsertAutoPlanItem(args: {
note: nextNote,
status: '등록',
assigned_branch: null,
automation_type: nextAutomationType,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
@@ -1262,7 +1268,9 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
const nextWorkId =
payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId);
const nextAutomationType = payload.automationType ?? normalizePlanAutomationType(currentRow.automation_type);
const nextAutomationType = await resolveAutomationType(
payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type,
);
const nextReleaseTarget = payload.releaseTarget ?? currentRow.release_target ?? 'release';
const nextJangsingProcessingRequired =
payload.jangsingProcessingRequired ??
@@ -1276,7 +1284,7 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
const isOnlyJangsingUpdate =
nextWorkId === currentRow.work_id &&
nextAutomationType === normalizePlanAutomationType(currentRow.automation_type) &&
nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
nextReleaseTarget === (currentRow.release_target ?? 'release') &&
nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
@@ -1297,7 +1305,8 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
work_id: nextWorkId,
note: nextNote,
status: currentRow.status,
automation_type: nextAutomationType,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
@@ -2261,6 +2270,7 @@ export async function listPlanReleaseReviewBoardItems(options?: Pick<PlanRowOpti
...(issueSummaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }),
maskNote: options?.maskNote,
noteMasked: options?.maskNote,
exposeConfiguredAutomationType: true,
});
const latestSourceWork = latestSourceWorkMap.get(planItemId) ?? null;
const reviewRow = reviewRowMap.get(planItemId);
@@ -2818,6 +2828,7 @@ export async function listPlanItems(status?: PlanStatus, options?: Pick<PlanRowO
maskNote: options?.maskNote,
noteMasked: options?.maskNote,
releaseReviewNote: releaseReviewNoteMap.get(Number(row.id)) ?? '',
exposeConfiguredAutomationType: true,
}),
);
}
@@ -2843,6 +2854,7 @@ export async function getPlanItemById(id: number, options?: Pick<PlanRowOptions,
maskNote: options?.maskNote,
noteMasked: options?.maskNote,
releaseReviewNote: releaseReviewNoteMap.get(id) ?? '',
exposeConfiguredAutomationType: true,
});
}

View File

@@ -0,0 +1,465 @@
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ShrinkOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteAutomationType,
upsertAutomationType,
useAutomationTypeRegistry,
type AutomationBehaviorType,
type AutomationTypeRecord,
} from './automationTypeAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Text, Title } = Typography;
type AutomationTypeFormValue = {
id?: string;
name: string;
description: string;
behaviorType: AutomationBehaviorType;
enabled: boolean;
};
const EMPTY_FORM_VALUE: AutomationTypeFormValue = {
name: '',
description: '',
behaviorType: 'none',
enabled: true,
};
function toFormValue(automationType: AutomationTypeRecord | null): AutomationTypeFormValue {
if (!automationType) {
return EMPTY_FORM_VALUE;
}
return {
id: automationType.id,
name: automationType.name,
description: automationType.description,
behaviorType: automationType.behaviorType,
enabled: automationType.enabled,
};
}
export function AutomationTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { automationTypes, setAutomationTypes, isLoading, errorMessage } = useAutomationTypeRegistry();
const [selectedAutomationTypeId, setSelectedAutomationTypeId] = useState<string | null>(automationTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationTypeFormValue>();
const isPaneMaximized = maximizedPane !== 'none';
const selectedAutomationType = useMemo(
() => automationTypes.find((item) => item.id === selectedAutomationTypeId) ?? null,
[automationTypes, selectedAutomationTypeId],
);
useEffect(() => {
if (selectedAutomationTypeId && automationTypes.some((item) => item.id === selectedAutomationTypeId)) {
return;
}
setSelectedAutomationTypeId(automationTypes[0]?.id ?? null);
}, [automationTypes, selectedAutomationTypeId]);
useEffect(() => {
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [form, isCreating, selectedAutomationType]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
if (isCreating || selectedAutomationType) {
return;
}
setDetailMode('list');
}, [detailMode, isCreating, selectedAutomationType]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const openCreateForm = () => {
setIsCreating(true);
setSelectedAutomationTypeId(null);
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (automationTypeId: string) => {
setIsCreating(false);
setSelectedAutomationTypeId(automationTypeId);
setDetailMode('detail');
setMaximizedPane('none');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
};
const handleDelete = async () => {
if (!selectedAutomationType) {
return;
}
if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) {
return;
}
const nextAutomationTypes = deleteAutomationType(automationTypes, selectedAutomationType.id);
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationTypes = await setAutomationTypes(nextAutomationTypes);
setSelectedAutomationTypeId(savedAutomationTypes[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형 삭제에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
if (!hasAccess) {
return (
<Card title="자동화 유형 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
/>
</Card>
);
}
return (
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}`}
>
{detailMode === 'list' ? (
<Card
title="자동화 유형 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
</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}> </Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${automationTypes.length}`}</Text>
</div>
{automationTypes.length > 0 ? (
<List
dataSource={automationTypes}
renderItem={(item) => {
const itemClassName =
item.id === selectedAutomationTypeId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item';
return (
<List.Item
className={itemClassName}
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.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.description ? <MarkdownPreviewContent content={item.description} maxBlocks={3} /> : '설명 없음'}
</div>
</div>
</List.Item>
);
}}
/>
) : (
<Empty description="등록된 자동화 유형이 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
>
<div className="chat-type-management-page__editor">
{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}>{isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
</div>
<Form
className="chat-type-management-page__editor-form"
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={async (values) => {
const nextAutomationTypes = upsertAutomationType(automationTypes, {
...values,
behaviorType:
selectedAutomationType?.behaviorType ?? values.behaviorType ?? EMPTY_FORM_VALUE.behaviorType,
});
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationTypes = await setAutomationTypes(nextAutomationTypes);
const savedAutomationType = savedAutomationTypes.find(
(item) => item.id === values.id || item.name === values.name,
);
setIsCreating(false);
setSelectedAutomationTypeId(savedAutomationType?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
}}
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="유형명"
name="name"
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
>
<Input placeholder="예: 자동화 메모" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedAutomationType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>
)}
</div>
);
}

View File

@@ -33,7 +33,6 @@ export function ChatRuntimeBridgeV2() {
chatTypeId: null,
chatTypeLabel: '',
chatTypeDescription: '',
chatTypeIsTemplate: false,
}),
[currentPage, focusedComponentId],
);

View File

@@ -1,27 +1,79 @@
.chat-type-management-page {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.chat-type-management-page--detail {
container-type: inline-size;
}
.chat-type-management-page .ant-card,
.chat-type-management-page .ant-card-body,
.chat-type-management-page__card {
width: 100%;
height: 100%;
min-height: 0;
}
.chat-type-management-page .ant-card-head {
min-height: 52px;
padding: 0 14px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra {
padding: 10px 0;
}
.chat-type-management-page .ant-card-body {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px 14px;
}
.chat-type-management-page__list,
.chat-type-management-page__editor {
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
height: 100%;
overflow: hidden;
}
.chat-type-management-page__list .ant-list {
flex: 1;
min-height: 0;
overflow: auto;
}
.chat-type-management-page__editor-form {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__editor-form .ant-form-item {
margin-bottom: 8px;
}
.chat-type-management-page__list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
}
.chat-type-management-page__list-header .ant-typography {
margin-bottom: 0;
}
.chat-type-management-page__item {
@@ -44,3 +96,246 @@
.chat-type-management-page__item-description.ant-typography {
margin: 8px 0 10px;
}
.chat-type-management-page__item-description {
margin: 8px 0 10px;
}
.chat-type-management-page__item-description .markdown-preview > :last-child {
margin-bottom: 0;
}
.chat-type-management-page__markdown-field {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__field-label {
flex: 0 0 auto;
line-height: 1.2;
}
.chat-type-management-page__markdown-editor {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__mobile-toggle {
display: none;
}
.chat-type-management-page__editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.chat-type-management-page__markdown-grid {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
align-items: stretch;
flex: 1;
min-height: 0;
overflow: hidden;
}
.chat-type-management-page__markdown-grid--maximized {
grid-template-columns: minmax(0, 1fr);
}
.chat-type-management-page__markdown-pane {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__markdown-pane .ant-form-item {
flex: 1;
min-height: 0;
margin-bottom: 0;
}
.chat-type-management-page__markdown-pane--desktop-hidden {
display: none;
}
.chat-type-management-page__markdown-pane-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.chat-type-management-page__markdown-pane .ant-input-textarea,
.chat-type-management-page__markdown-pane .ant-input {
height: 100%;
}
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 180px;
resize: none;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 180px;
overflow: auto !important;
resize: none;
}
.chat-type-management-page__markdown-preview {
width: 100%;
height: 100%;
min-height: 0;
border: 1px solid #f0f0f0;
border-radius: 12px;
background: #fafafa;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__markdown-preview-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
margin-bottom: 0;
}
.chat-type-management-page__form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 2px;
}
.chat-type-management-page__form-actions--compact {
padding-top: 0;
}
.chat-type-management-page__meta-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: start;
}
.chat-type-management-page__meta-grid--hidden {
display: none;
}
.chat-type-management-page__meta-item {
min-width: 0;
}
.chat-type-management-page__meta-item .ant-form-item-label {
padding-bottom: 4px;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
min-height: 40px;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
flex: 1;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
display: none;
}
.chat-type-management-page__card--pane-maximized .ant-card-body {
padding-bottom: 10px;
}
@media (max-width: 960px) {
.chat-type-management-page,
.chat-type-management-page .ant-card,
.chat-type-management-page .ant-card-body,
.chat-type-management-page__card,
.chat-type-management-page__list,
.chat-type-management-page__editor,
.chat-type-management-page__editor-form,
.chat-type-management-page__markdown-field,
.chat-type-management-page__markdown-editor,
.chat-type-management-page__markdown-grid,
.chat-type-management-page__markdown-pane,
.chat-type-management-page__markdown-preview {
min-height: 0;
}
.chat-type-management-page__list-header {
align-items: flex-start;
}
.chat-type-management-page .ant-card-head {
min-height: 48px;
padding: 0 10px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra,
.chat-type-management-page .ant-card-body {
padding: 10px;
}
.chat-type-management-page__mobile-toggle {
display: inline-flex;
}
.chat-type-management-page__editor-toolbar {
flex-wrap: wrap;
}
.chat-type-management-page__meta-grid {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.chat-type-management-page__markdown-grid {
grid-template-columns: minmax(0, 1fr);
}
.chat-type-management-page__markdown-pane--mobile-hidden {
display: none;
}
.chat-type-management-page__markdown-textarea,
.chat-type-management-page__markdown-textarea textarea,
.chat-type-management-page__markdown-preview-body {
min-height: 0;
}
.chat-type-management-page__form-actions {
flex-wrap: wrap;
}
}

View File

@@ -1,6 +1,14 @@
import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd';
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ShrinkOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
canUseChatType,
CHAT_PERMISSION_ROLE_LABELS,
@@ -14,13 +22,12 @@ import {
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Paragraph, Text, Title } = Typography;
const { Text, Title } = Typography;
type ChatTypeFormValue = {
id?: string;
name: string;
description: string;
isTemplate: boolean;
permissions: ChatPermissionRole[];
enabled: boolean;
};
@@ -28,7 +35,6 @@ type ChatTypeFormValue = {
const EMPTY_FORM_VALUE: ChatTypeFormValue = {
name: '',
description: '',
isTemplate: false,
permissions: ['token-user'],
enabled: true,
};
@@ -42,7 +48,6 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
id: chatType.id,
name: chatType.name,
description: chatType.description,
isTemplate: chatType.isTemplate,
permissions: chatType.permissions,
enabled: chatType.enabled,
};
@@ -52,12 +57,16 @@ export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none';
const selectedChatType = useMemo(
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
@@ -88,10 +97,32 @@ export function ChatTypeManagementPage() {
setDetailMode('list');
}, [detailMode, isCreating, selectedChatType]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const openCreateForm = () => {
setIsCreating(true);
setSelectedChatTypeId(null);
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
@@ -100,11 +131,13 @@ export function ChatTypeManagementPage() {
setIsCreating(false);
setSelectedChatTypeId(chatTypeId);
setDetailMode('detail');
setMaximizedPane('none');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
};
const handleDelete = async () => {
@@ -148,7 +181,11 @@ export function ChatTypeManagementPage() {
}
return (
<div className="chat-type-management-page">
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}`}
>
{detailMode === 'list' ? (
<Card
title="컨텍스트 권한 관리"
@@ -200,14 +237,17 @@ export function ChatTypeManagementPage() {
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
{item.isTemplate ? <Tag color="gold">릿</Tag> : null}
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
</Tag>
</Space>
<Paragraph className="chat-type-management-page__item-description">
{item.description || '기본 문맥 설명 없음'}
</Paragraph>
<div className="chat-type-management-page__item-description">
{item.description ? (
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
) : (
'기본 문맥 설명 없음'
)}
</div>
<Space size={[6, 6]} wrap>
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
@@ -226,29 +266,17 @@ export function ChatTypeManagementPage() {
) : (
<Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
className="chat-type-management-page__card"
extra={
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
>
<div className="chat-type-management-page__editor">
{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}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
<Text type="secondary"> , .</Text>
</div>
<Form
className="chat-type-management-page__editor-form"
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
@@ -273,41 +301,187 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<Form.Item
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
>
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
label="권한 대상"
name="permissions"
>
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다."
/>
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item label="기본 문맥 설명" name="description">
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 8 }}
placeholder="이 컨텍스트에서 기본으로 참고해야 할 문맥을 입력하세요."
/>
</Form.Item>
<Form.Item label="템플릿 요청 여부" name="isTemplate" valuePropName="checked">
<Switch checkedChildren="템플릿" unCheckedChildren="일반" />
</Form.Item>
<Form.Item label="권한 대상" name="permissions">
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item label="사용 여부" name="enabled" valuePropName="checked">
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>

View File

@@ -76,11 +76,10 @@ type ChatTypeOption = {
value: string;
label: string;
description: string;
isTemplate: boolean;
disabled?: boolean;
};
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
type PreviewItem = {
id: string;
@@ -98,7 +97,6 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
@@ -109,7 +107,6 @@ type PendingContextConfirm = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number;
omittedContextCount: number;
};
@@ -660,6 +657,21 @@ function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
}
function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
@@ -675,6 +687,10 @@ function classifyPreviewKind(url: string): PreviewKind {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
@@ -687,6 +703,10 @@ function classifyPreviewKind(url: string): PreviewKind {
return 'pdf';
}
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file';
}
@@ -869,7 +889,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
value: item.id,
label: item.name,
description: item.description,
isTemplate: item.isTemplate,
disabled: !isAllowed,
};
}),
@@ -959,7 +978,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeId: selectedChatType?.id ?? null,
chatTypeLabel: selectedChatType?.name ?? '',
chatTypeDescription: selectedChatType?.description ?? '',
chatTypeIsTemplate: selectedChatType?.isTemplate ?? false,
};
const {
conversationItems,
@@ -2124,7 +2142,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeId: request.chatTypeId,
chatTypeLabel: request.chatTypeLabel,
chatTypeDescription: request.chatTypeDescription,
chatTypeIsTemplate: request.chatTypeIsTemplate,
requestId: request.requestId,
mode: request.mode,
},

View File

@@ -10,6 +10,7 @@ import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage
import { useSearchLayer } from '../../layer';
import { useAppStore } from '../../store';
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
@@ -169,6 +170,10 @@ export function MainContent({
return <HistoryPage />;
}
if (selectionId === 'page:plans:automation-type') {
return <AutomationTypeManagementPage />;
}
const planStatus = getPlanStatusFromWindowSelection(selectionId);
if (planStatus) {

View File

@@ -3,19 +3,20 @@ import {
BellOutlined,
ClockCircleOutlined,
CopyOutlined,
DownOutlined,
FileMarkdownOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ProfileOutlined,
ReloadOutlined,
RightOutlined,
SettingOutlined,
} from '@ant-design/icons';
import {
Alert,
Button,
Checkbox,
Divider,
Drawer,
Dropdown,
Grid,
@@ -884,6 +885,7 @@ export function MainHeader({
const navigate = useNavigate();
const location = useLocation();
const [settingsOpen, setSettingsOpen] = useState(false);
const [automationGroupExpanded, setAutomationGroupExpanded] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [activeSettingsModal, setActiveSettingsModal] = useState<SettingsModalKey>('appSettings');
const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation');
@@ -926,9 +928,7 @@ export function MainHeader({
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [serverRestartingKey, setServerRestartingKey] = useState<
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
>(null);
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null);
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess();
@@ -961,6 +961,8 @@ export function MainHeader({
const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
const totalAutomationShortcutCount =
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
const settingsStatusClassName =
totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive'
@@ -1698,50 +1700,6 @@ export function MainHeader({
});
};
const handleRestartCommandRunner = async () => {
if (!hasAccess || serverRestartingKey) {
return;
}
setServerRestartCopyFeedback(null);
setServerRestartFeedback(null);
setServerRestartingKey('command-runner');
try {
const result = await restartServerCommand('command-runner');
setServerRestartFeedback({
tone: 'success',
message:
result.restartState === 'accepted'
? 'Command runner 배포 및 재기동 요청을 접수했습니다.'
: 'Command runner 배포 및 재기동을 완료했습니다.',
});
} catch (error) {
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.',
});
} finally {
setServerRestartingKey(null);
}
};
const handleConfirmRestartCommandRunner = () => {
if (!hasAccess || serverRestartingKey) {
return;
}
modalApi.confirm({
title: 'Command runner 배포 및 재기동',
content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?',
okText: '배포 및 재기동',
cancelText: '취소',
onOk: async () => {
await handleRestartCommandRunner();
},
});
};
const handleRestartBothServers = async () => {
if (!hasAccess || serverRestartingKey) {
return;
@@ -2668,68 +2626,20 @@ export function MainHeader({
const settingsMenu = (
<div className="app-header__settings-menu">
{hasAccess ? (
<>
<div className="app-header__settings-group">
<button
type="button"
className="app-header__settings-item"
aria-expanded={automationGroupExpanded}
onClick={() => {
setSettingsOpen(false);
onOpenPlanQuickFilter('working');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.working > 0
? 'app-header__status-dot--active'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
<Text type="secondary"> {planShortcutCounts.working}</Text>
</span>
</button>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
setSettingsOpen(false);
onOpenPlanQuickFilter('release-pending-main');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.releasePendingMain > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
release
<Text type="secondary"> {planShortcutCounts.releasePendingMain}</Text>
</span>
</button>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
setSettingsOpen(false);
onOpenPlanQuickFilter('automation-failed');
setAutomationGroupExpanded((current) => !current);
}}
>
<span className="app-header__settings-icon">
<ReloadOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.automationFailed > 0
totalAutomationShortcutCount > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
@@ -2737,11 +2647,93 @@ export function MainHeader({
/>
</span>
<span className="app-header__settings-label">
<Text type="secondary"> {planShortcutCounts.automationFailed}</Text>
<Text type="secondary"> {totalAutomationShortcutCount}</Text>
</span>
<span className="app-header__settings-group-arrow" aria-hidden="true">
{automationGroupExpanded ? <DownOutlined /> : <RightOutlined />}
</span>
</button>
</>
{automationGroupExpanded ? (
<div className="app-header__settings-group-children">
<button
type="button"
className="app-header__settings-item app-header__settings-item--nested"
onClick={() => {
setSettingsOpen(false);
setAutomationGroupExpanded(false);
onOpenPlanQuickFilter('working');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.working > 0
? 'app-header__status-dot--active'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
<Text type="secondary"> {planShortcutCounts.working}</Text>
</span>
</button>
<button
type="button"
className="app-header__settings-item app-header__settings-item--nested"
onClick={() => {
setSettingsOpen(false);
setAutomationGroupExpanded(false);
onOpenPlanQuickFilter('release-pending-main');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.releasePendingMain > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
release
<Text type="secondary"> {planShortcutCounts.releasePendingMain}</Text>
</span>
</button>
<button
type="button"
className="app-header__settings-item app-header__settings-item--nested"
onClick={() => {
setSettingsOpen(false);
setAutomationGroupExpanded(false);
onOpenPlanQuickFilter('automation-failed');
}}
>
<span className="app-header__settings-icon">
<ReloadOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.automationFailed > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
<Text type="secondary"> {planShortcutCounts.automationFailed}</Text>
</span>
</button>
</div>
) : null}
</div>
) : null}
<button
type="button"
@@ -3120,24 +3112,6 @@ export function MainHeader({
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
<Divider style={{ marginBlock: 4 }} />
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Command runner</Text>
<Text type="secondary">
command runner .
</Text>
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
<Button
block
type="primary"
icon={serverRestartingKey === 'command-runner' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'command-runner'}
disabled={!canRestartServers}
onClick={handleConfirmRestartCommandRunner}
>
command runner
</Button>
</Space>
</>
) : null}
{activeSettingsModal === 'notification' ? (

View File

@@ -174,10 +174,23 @@
gap: 8px;
}
.app-header__settings-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.app-header__settings-item:hover {
background: #f3f7ff;
}
.app-header__settings-item--nested {
margin-left: 12px;
min-width: 0;
padding-left: 14px;
background: #f8fbff;
}
.app-header__settings-icon {
position: relative;
display: inline-flex;
@@ -241,10 +254,23 @@
}
.app-header__settings-label {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 1;
font-size: 14px;
font-weight: 600;
}
.app-header__settings-group-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
color: #64748b;
font-size: 12px;
}
.app-header__update-progress {
display: flex;
flex-direction: column;
@@ -363,11 +389,15 @@
position: fixed;
inset: 72px 0 0;
z-index: 40;
width: 100% !important;
max-width: 100%;
width: 100vw !important;
min-width: 100vw !important;
max-width: 100vw;
flex: 0 0 100vw !important;
height: calc(100vh - 72px);
border-right: 0;
background: rgba(255, 255, 255, 0.98);
transition: none !important;
overflow: hidden;
}
.app-sider--mobile-inline.ant-layout-sider {
@@ -385,6 +415,9 @@
gap: 12px;
height: 100%;
padding: 12px 10px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.app-sider__intro {

View File

@@ -0,0 +1,401 @@
import { useEffect, useRef, useState } from 'react';
import type { BoardAutomationType } from '../../features/board/types';
import type { PlanAutomationType } from '../../features/planBoard/types';
import { appendClientIdHeader } from './clientIdentity';
export const AUTOMATION_BEHAVIOR_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
] as const;
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeRecord = {
id: string;
name: string;
description: string;
behaviorType: AutomationBehaviorType;
enabled: boolean;
updatedAt: string;
};
export type AutomationTypeInput = {
id?: string;
name: string;
description?: string;
behaviorType?: AutomationBehaviorType;
enabled?: boolean;
};
const AUTOMATION_TYPES_API_PATH = '/automation-types';
const AUTOMATION_TYPE_SYNC_EVENT = 'work-app:automation-types-changed';
const AUTOMATION_TYPE_REQUEST_TIMEOUT_MS = 8000;
export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string> = {
none: '기본유형',
plan: '작업 요청 등록',
command_execution: 'Command 실행',
non_source_work: '비 소스작업',
auto_worker: 'autoWorker',
};
const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'plan',
name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
behaviorType: 'plan',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'command_execution',
name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'non_source_work',
name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
behaviorType: 'non_source_work',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'auto_worker',
name: 'autoWorker',
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.',
behaviorType: 'auto_worker',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
export function normalizeAutomationTypeId(
value: unknown,
): PlanAutomationType | BoardAutomationType {
const normalized = normalizeText(typeof value === 'string' ? value : '');
if (normalized === 'plan_registration') {
return 'plan';
}
if (normalized === 'general_development') {
return 'auto_worker';
}
return normalized || 'none';
}
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
const normalized = normalizeAutomationTypeId(value);
return AUTOMATION_BEHAVIOR_TYPES.includes(normalized as AutomationBehaviorType)
? (normalized as AutomationBehaviorType)
: 'none';
}
function getSemanticKey(record: Pick<AutomationTypeRecord, 'name' | 'behaviorType'>) {
return `${record.behaviorType}:${normalizeText(record.name).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR')}`;
}
function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecord) {
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 normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
const name = normalizeText(record.name);
if (!name) {
return null;
}
const id =
normalizeText(record.id) ||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
name,
description: normalizeText(record.description),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[]) {
const byId = new Map<string, AutomationTypeRecord>();
const bySemanticKey = new Map<string, AutomationTypeRecord>();
items
.map((item) => normalizeAutomationType(item))
.filter((item): item is AutomationTypeRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = getSemanticKey(item);
const current = bySemanticKey.get(semanticKey);
if (!current || compareUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_TYPES;
}
function emitAutomationTypesChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(AUTOMATION_TYPE_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_TYPE_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(`${baseUrl}${AUTOMATION_TYPES_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() || '자동화 처리 유형 요청에 실패했습니다.');
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestAutomationTypes<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 loadAutomationTypesFromServer() {
const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial<AutomationTypeRecord>[] | null }>({
method: 'GET',
});
if (response.automationTypes == null) {
return DEFAULT_AUTOMATION_TYPES;
}
return sanitizeAutomationTypes(response.automationTypes);
}
async function saveAutomationTypesToServer(items: AutomationTypeRecord[]) {
const resolved = sanitizeAutomationTypes(items);
const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial<AutomationTypeRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ automationTypes: resolved }),
});
return sanitizeAutomationTypes(response.automationTypes);
}
export function upsertAutomationType(items: AutomationTypeRecord[], input: AutomationTypeInput) {
const nextItem = normalizeAutomationType(input);
if (!nextItem) {
return sanitizeAutomationTypes(items);
}
const nextItems = items.filter((item) => item.id !== nextItem.id);
nextItems.push(nextItem);
return sanitizeAutomationTypes(nextItems);
}
export function deleteAutomationType(items: AutomationTypeRecord[], automationTypeId: string) {
const normalizedId = normalizeText(automationTypeId);
if (!normalizedId) {
return sanitizeAutomationTypes(items);
}
return sanitizeAutomationTypes(items.filter((item) => item.id !== normalizedId));
}
export function resolveAutomationTypeLabel(items: AutomationTypeRecord[], automationTypeId: string | null | undefined) {
const normalizedId = normalizeAutomationTypeId(automationTypeId);
return items.find((item) => item.id === normalizedId)?.name ?? normalizedId;
}
export function buildAutomationTypeOptions(
items: AutomationTypeRecord[],
value?: string | null,
) {
const normalizedValue = normalizeAutomationTypeId(value);
const enabledItems = items.filter((item) => item.enabled);
const currentItem = items.find((item) => item.id === normalizedValue);
const source = currentItem && !enabledItems.some((item) => item.id === currentItem.id) ? [...enabledItems, currentItem] : enabledItems;
if (source.length === 0) {
return [{ label: '선택 안함', value: 'none' }];
}
return source.map((item) => ({
label: item.name,
value: item.id,
}));
}
export function useAutomationTypeRegistry() {
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
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 nextAutomationTypes = await loadAutomationTypesFromServer();
if (!cancelled && mountedRef.current) {
setAutomationTypesState(nextAutomationTypes);
}
} catch (error) {
if (!cancelled && mountedRef.current) {
setAutomationTypesState(DEFAULT_AUTOMATION_TYPES);
setErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형을 불러오지 못했습니다.');
}
} finally {
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
}
};
void load();
const handleSync = () => {
void load();
};
window.addEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
return () => {
cancelled = true;
window.removeEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
};
}, []);
const setAutomationTypes = async (nextItems: AutomationTypeRecord[]) => {
const saved = await saveAutomationTypesToServer(nextItems);
if (mountedRef.current) {
setAutomationTypesState(saved);
setErrorMessage('');
}
emitAutomationTypesChange();
return saved;
};
return {
automationTypes,
setAutomationTypes,
isLoading,
errorMessage,
};
}

View File

@@ -7,7 +7,6 @@ export type ChatTypeRecord = {
id: string;
name: string;
description: string;
isTemplate: boolean;
permissions: ChatPermissionRole[];
enabled: boolean;
updatedAt: string;
@@ -17,7 +16,6 @@ export type ChatTypeInput = {
id?: string;
name: string;
description?: string;
isTemplate?: boolean;
permissions: ChatPermissionRole[];
enabled?: boolean;
};
@@ -39,8 +37,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request',
name: '일반 요청',
description:
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.',
isTemplate: false,
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
@@ -48,8 +45,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{
id: 'api-request-template',
name: 'API요청',
description: 'API요청만 진행 (자동화, 작업요청, 스케줄 등 호출 가능한 API)',
isTemplate: true,
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
@@ -90,15 +86,14 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
id,
name,
description: normalizeText(record.description),
isTemplate: record.isTemplate === true,
permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false,
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(),
};
}
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) {
return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`;
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
return buildChatTypeNameKey(record.name);
}
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
@@ -314,7 +309,6 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
id: input.id,
name: input.name,
description: input.description,
isTemplate: input.isTemplate,
permissions: input.permissions,
enabled: input.enabled,
updatedAt: new Date().toISOString(),

View File

@@ -10,7 +10,6 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
@@ -21,7 +20,6 @@ type PendingContextConfirm = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number;
omittedContextCount: number;
};
@@ -30,7 +28,6 @@ type SelectedChatType = {
id: string;
name: string;
description: string;
isTemplate: boolean;
} | null;
type RecentContextSummary = {
@@ -170,7 +167,7 @@ export function useConversationComposerController({
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request;
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription } = request;
const requestId = `client-${Date.now().toString(36)}`;
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
@@ -180,7 +177,6 @@ export function useConversationComposerController({
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeIsTemplate,
retryCount: 0,
failed: false,
};
@@ -335,26 +331,23 @@ export function useConversationComposerController({
return;
}
if (!selectedChatType.isTemplate) {
const recentContext = summarizeRecentContext(
messagesRef.current,
appConfigChat.maxContextMessages,
appConfigChat.maxContextChars,
);
const recentContext = summarizeRecentContext(
messagesRef.current,
appConfigChat.maxContextMessages,
appConfigChat.maxContextChars,
);
if (recentContext.omittedCount > 0) {
setPendingContextConfirm({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: false,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
}
if (recentContext.omittedCount > 0) {
setPendingContextConfirm({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
}
executeSendMessage({
@@ -363,7 +356,6 @@ export function useConversationComposerController({
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: selectedChatType.isTemplate,
includedContextCount: 0,
omittedContextCount: 0,
});

View File

@@ -17,7 +17,6 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};

View File

@@ -5,7 +5,7 @@ type PreviewItem = {
id: string;
label: string;
url: string;
kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
source: 'message' | 'context';
};

View File

@@ -87,6 +87,7 @@ function parseRoute(pathname: string): {
first === 'charts' ||
first === 'schedule' ||
first === 'history' ||
first === 'automation-type' ||
first === 'server-command')
) {
return {
@@ -146,6 +147,22 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
return !hasAccess && topMenu !== 'docs';
}
function getIsMobileViewport() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) {
if (!isMobileViewport) {
return false;
}
return topMenu !== 'docs';
}
function resolveSidebarOpenKeys(
topMenu: TopMenuKey,
hasAccess: boolean,
@@ -189,9 +206,11 @@ export function MainLayout() {
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
const layoutData = useMainLayoutData();
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu),
);
const [contentExpanded, setContentExpanded] = useState(false);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu),
);
@@ -221,12 +240,7 @@ export function MainLayout() {
}, []);
useEffect(() => {
if (!isMobileViewport) {
setSidebarCollapsed(false);
return;
}
setSidebarCollapsed(routeState.topMenu !== 'docs');
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu));
}, [isMobileViewport, routeState.topMenu]);
useEffect(() => {
@@ -370,7 +384,6 @@ export function MainLayout() {
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs';
const initialSelectedPlanId = Number(searchParams.get('planId'));
const initialSelectedWorkId = searchParams.get('workId');
@@ -414,7 +427,7 @@ export function MainLayout() {
}}
onChangeTopMenu={(menu) => {
navigate(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(false);
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu));
}}
onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
@@ -428,13 +441,12 @@ export function MainLayout() {
)}
<Layout>
{contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : (
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
<MainSidebar
activeTopMenu={routeState.topMenu}
hasAccess={hasAccess}
sidebarCollapsed={sidebarCollapsed}
isMobileViewport={isMobileViewport}
mobileInline={showInlineMobileDocsSidebar}
openKeys={sidebarOpenKeys}
apiMenuItems={apiMenuItems}
docsMenuItems={docsMenuItems}
@@ -455,7 +467,7 @@ export function MainLayout() {
}}
onSelectDocsMenu={(key) => {
navigate(buildDocsPath(key));
if (isMobileViewport && !showInlineMobileDocsSidebar) {
if (isMobileViewport) {
setSidebarCollapsed(true);
}
}}
@@ -486,7 +498,7 @@ export function MainLayout() {
/>
)}
{isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : (
{isMobileViewport && !sidebarCollapsed ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet />
</MainContent>

View File

@@ -145,6 +145,18 @@ export function buildSearchOptions({
},
...(hasAccess
? [
{
id: 'page:plans:automation-type',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`,
group: 'Page',
keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlansPath('automation-type'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{
id: 'page:plans:history',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,

View File

@@ -30,7 +30,6 @@ export type ChatViewContext = {
chatTypeId: string | null;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
};
export type ChatConversationSummary = {

View File

@@ -202,7 +202,6 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
chatTypeIsTemplate: context.chatTypeIsTemplate,
},
}),
);

View File

@@ -27,6 +27,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
charts: '차트',
schedule: '스케줄',
history: '이력',
'automation-type': '자동화 유형',
'server-command': 'Command',
};
@@ -50,6 +51,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
charts: 'plan-menu-charts',
schedule: 'plan-menu-schedule',
history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'server-command': 'plan-menu-server-command',
};

View File

@@ -101,6 +101,18 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
},
{
id: 'page:plans:automation-type',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`,
group: 'Page',
keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'],
onSelect: () => {
setActiveTopMenu('plans');
setSelectedPlanMenu('automation-type');
setFocusedComponentId(null);
},
onSelectWindow,
},
...(hasAccess
? [
{

View File

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

View File

@@ -7,7 +7,16 @@ import type { PlanFilterStatus } from '../../features/planBoard';
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
export type HeaderTopMenuKey = 'docs' | 'plans';
export type ApiSectionKey = 'components' | 'widgets';
export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command';
export type PlanSectionKey =
| PlanFilterStatus
| 'release'
| 'release-review'
| 'board'
| 'charts'
| 'schedule'
| 'history'
| 'automation-type'
| 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
@@ -39,6 +48,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
charts: '차트',
schedule: '스케줄',
history: '이력',
'automation-type': '자동화 유형',
'server-command': 'Command',
};
@@ -57,6 +67,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
charts: 'plan-menu-charts',
schedule: 'plan-menu-schedule',
history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'server-command': 'plan-menu-server-command',
};
@@ -188,6 +199,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
key: 'history',
label: renderPlanMenuLabel('history', PLAN_SIDEBAR_LABELS.history),
},
{
key: 'automation-type',
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
},
],
},
{

View File

@@ -13,6 +13,11 @@ import {
} from '@ant-design/icons';
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
createBoardPost,
@@ -22,7 +27,7 @@ import {
setupBoard,
updateBoardPost,
} from './api';
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
import type { BoardDraft, BoardPost } from './types';
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
@@ -34,18 +39,6 @@ const EMPTY_DRAFT: BoardDraft = {
automationType: 'none',
};
const BOARD_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: BoardAutomationType }> = [
{ label: '선택 안함', value: 'none' },
{ label: 'Plan', value: 'plan' },
{ label: 'Command 실행', value: 'command_execution' },
{ label: '비 소스작업', value: 'non_source_work' },
{ label: 'autoWorker', value: 'auto_worker' },
];
const BOARD_AUTOMATION_TYPE_LABELS = new Map(
BOARD_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
);
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
@@ -128,6 +121,7 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
export function BoardPage() {
const [messageApi, contextHolder] = message.useMessage();
const { automationTypes } = useAutomationTypeRegistry();
const [items, setItems] = useState<BoardPost[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
@@ -216,7 +210,14 @@ export function BoardPage() {
);
const dirtyDraftId = draftDirty && draft.id ? draft.id : null;
const automationStatus = resolveBoardAutomationStatus(draft.id, automationReceived, draftDirty, automationReceiveError);
const automationTypeLabel = BOARD_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType;
const automationTypeOptions = useMemo(
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const automationTypeLabel = useMemo(
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const receivableIds = useMemo(
() =>
items
@@ -671,17 +672,17 @@ export function BoardPage() {
<Select
className="board-page__automation-select"
value={draft.automationType}
options={BOARD_AUTOMATION_TYPE_OPTIONS}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
options={automationTypeOptions}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked}
onChange={(automationType) => {
setDraft((previous) => ({
...previous,
automationType,
}));
}}
/>
automationType,
}));
}}
/>
)}
</div>
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>

View File

@@ -1,4 +1,5 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
@@ -13,16 +14,7 @@ class BoardApiError extends Error {
}
function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
return value === 'plan' ||
value === 'command_execution' ||
value === 'non_source_work' ||
value === 'auto_worker'
? value
: value === 'plan_registration'
? 'plan'
: value === 'general_development'
? 'auto_worker'
: 'none';
return normalizeAutomationTypeId(value);
}
function resolveBoardApiBaseUrl() {

View File

@@ -1,12 +1,4 @@
export const BOARD_AUTOMATION_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
] as const;
export type BoardAutomationType = (typeof BOARD_AUTOMATION_TYPES)[number];
export type BoardAutomationType = string;
export type BoardPost = {
id: number;

View File

@@ -6,6 +6,7 @@ import {
DownOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
PaperClipOutlined,
UpOutlined,
} from '@ant-design/icons';
import {
@@ -28,8 +29,16 @@ import {
Typography,
message,
} from 'antd';
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
import { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css';
import {
@@ -67,7 +76,6 @@ import { maskNotePreviewByWord } from './noteMasking';
import type {
PlanActionHistory,
PlanActionType,
PlanAutomationType,
PlanAutomationUsageSnapshot,
PlanDraft,
PlanFilterStatus,
@@ -130,16 +138,6 @@ const MAIN_STATE_FILTER_OPTIONS: Array<{ label: string; value: MainStateFilter }
{ label: 'main 실패', value: 'failed' },
{ label: 'main 미대상', value: 'not-targeted' },
];
const PLAN_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: PlanAutomationType }> = [
{ label: '선택 안함', value: 'none' },
{ label: '작업 요청 등록', value: 'plan' },
{ label: 'Command 실행', value: 'command_execution' },
{ label: '비 소스작업', value: 'non_source_work' },
{ label: 'autoWorker', value: 'auto_worker' },
];
const PLAN_AUTOMATION_TYPE_LABELS = new Map(
PLAN_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
);
const ISSUE_STATE_FILTER_OPTIONS: Array<{ label: string; value: IssueStateFilter }> = [
{ label: '이슈 전체', value: 'all' },
{ label: '열린 이슈', value: 'open' },
@@ -160,6 +158,191 @@ type ReviewListIndicator = {
totalCount: number;
};
type PlanNoteResource = {
id: string;
label: string;
sourcePath: string;
publicUrl: string;
previewType: 'image' | 'document' | 'link';
};
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
/^\s*-\s+(.+?):\s+((?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+))\s*$/;
const PLAN_NOTE_RESOURCE_GLOBAL_PATTERN =
/(?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)/g;
const PLAN_NOTE_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']);
const PLAN_NOTE_DOCUMENT_EXTENSIONS = new Set([
'pdf',
'txt',
'md',
'json',
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
'css',
'html',
'diff',
'log',
]);
function createPlanNoteAttachmentSessionId() {
return `plan-note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function normalizePlanNoteResourceSourcePath(value: string) {
return String(value ?? '')
.trim()
.replace(/[)>.,]+$/, '')
.replace(/^\/+/, '/')
.replace(/^public\/(?=\.codex_chat\/)/, '');
}
function normalizePlanNoteResourceUrl(value: string) {
const normalizedSourcePath = normalizePlanNoteResourceSourcePath(value);
if (!normalizedSourcePath) {
return '';
}
if (normalizedSourcePath.startsWith('/api/chat/resources/')) {
return normalizeChatResourceUrl(normalizedSourcePath);
}
if (normalizedSourcePath.startsWith('/.codex_chat/')) {
return normalizeChatResourceUrl(normalizedSourcePath);
}
if (normalizedSourcePath.startsWith('.codex_chat/')) {
return normalizeChatResourceUrl(`/${normalizedSourcePath}`);
}
return normalizeChatResourceUrl(normalizedSourcePath);
}
function getPlanNoteResourceBaseName(sourcePath: string) {
const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, '');
const segments = normalized.split('/').filter(Boolean);
return segments.at(-1) ?? normalized;
}
function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResource['previewType'] {
const baseName = getPlanNoteResourceBaseName(sourcePath);
const extension = baseName.includes('.') ? baseName.split('.').at(-1)?.toLowerCase() ?? '' : '';
if (PLAN_NOTE_IMAGE_EXTENSIONS.has(extension)) {
return 'image';
}
if (PLAN_NOTE_DOCUMENT_EXTENSIONS.has(extension)) {
return 'document';
}
return 'link';
}
function extractPlanNoteResources(note: string) {
const normalizedNote = String(note ?? '');
const lineEntries = normalizedNote
.split(/\r?\n/)
.map((line) => {
const matched = line.match(PLAN_NOTE_RESOURCE_LINE_PATTERN);
if (!matched) {
return null;
}
return {
label: matched[1]?.trim() || getPlanNoteResourceBaseName(matched[2] ?? ''),
sourcePath: normalizePlanNoteResourceSourcePath(matched[2] ?? ''),
};
})
.filter((item): item is { label: string; sourcePath: string } => Boolean(item?.sourcePath));
const seen = new Set(lineEntries.map((item) => item.sourcePath));
const genericEntries = Array.from(normalizedNote.matchAll(PLAN_NOTE_RESOURCE_GLOBAL_PATTERN))
.map((matched) => normalizePlanNoteResourceSourcePath(matched[0] ?? ''))
.filter(Boolean)
.filter((sourcePath) => {
if (seen.has(sourcePath)) {
return false;
}
seen.add(sourcePath);
return true;
})
.map((sourcePath) => ({
label: getPlanNoteResourceBaseName(sourcePath),
sourcePath,
}));
return [...lineEntries, ...genericEntries].map((item, index) => ({
id: `${index}-${item.sourcePath}`,
label: item.label,
sourcePath: item.sourcePath,
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
}));
}
function appendPlanNoteAttachments(note: string, attachments: ChatComposerAttachment[]) {
if (attachments.length === 0) {
return note;
}
const existingSourcePaths = new Set(extractPlanNoteResources(note).map((item) => item.sourcePath));
const nextLines = attachments
.map((attachment) => {
const sourcePath = normalizePlanNoteResourceSourcePath(attachment.path);
return {
label: attachment.name.trim() || getPlanNoteResourceBaseName(sourcePath),
sourcePath,
};
})
.filter((item) => item.sourcePath)
.filter((item) => {
if (existingSourcePaths.has(item.sourcePath)) {
return false;
}
existingSourcePaths.add(item.sourcePath);
return true;
})
.map((item) => `- ${item.label}: ${item.sourcePath}`);
if (nextLines.length === 0) {
return note;
}
const currentNote = note.trimEnd();
const attachmentSectionPattern = /(^|\n)첨부 파일:\n(?:- .+\n?)*/;
const matchedSection = currentNote.match(attachmentSectionPattern);
if (!matchedSection || matchedSection.index === undefined) {
return `${currentNote}${currentNote ? '\n\n' : ''}첨부 파일:\n${nextLines.join('\n')}`;
}
const startIndex = matchedSection.index;
const matchedText = matchedSection[0];
const insertIndex = startIndex + matchedText.length;
const prefix = currentNote.slice(0, insertIndex).replace(/\n*$/, '\n');
const suffix = currentNote.slice(insertIndex).replace(/^\n+/, '\n');
return `${prefix}${nextLines.join('\n')}${suffix}`;
}
function resolvePlanNoteAttachmentSessionId(
draftId: number | null,
fallbackSessionId: string,
) {
if (draftId) {
return `plan-note-${draftId}`;
}
return fallbackSessionId;
}
function isPlanItemRequestLocked(item: Pick<PlanItem, 'startedAt'> | null | undefined) {
return Boolean(item?.startedAt);
}
@@ -317,6 +500,59 @@ function ExpandableDetailText({
);
}
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
return (
<div className="plan-board-page__note-resources">
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong> </Text>
<Text type="secondary">{resources.length}</Text>
</Flex>
<div className="plan-board-page__note-resource-list">
{resources.map((resource) => (
<div key={resource.id} className="plan-board-page__note-resource-card">
<Flex justify="space-between" align="start" gap={12} wrap>
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
<Text strong ellipsis={{ tooltip: resource.label }}>
{resource.label}
</Text>
<Text type="secondary" className="plan-board-page__note-resource-path">
{resource.sourcePath}
</Text>
</Space>
<Button
size="small"
type="primary"
href={resource.publicUrl}
target="_blank"
rel="noreferrer"
>
</Button>
</Flex>
{resource.previewType === 'image' ? (
<img
className="plan-board-page__note-resource-image"
src={resource.publicUrl}
alt={resource.label}
loading="lazy"
/>
) : null}
{resource.previewType === 'document' ? (
<iframe
className="plan-board-page__note-resource-frame"
src={resource.publicUrl}
title={resource.label}
loading="lazy"
referrerPolicy="no-referrer"
/>
) : null}
</div>
))}
</div>
</div>
);
}
type ActionButton = {
key: PlanActionType;
label: string;
@@ -352,6 +588,7 @@ export function PlanBoardPage({
initialSelectedWorkId = null,
}: PlanBoardPageProps) {
const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const appConfig = useAppConfig();
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
const [messageApi, contextHolder] = message.useMessage();
@@ -365,6 +602,7 @@ export function PlanBoardPage({
const [selectedSourceWork, setSelectedSourceWork] = useState<PlanSourceWorkHistory | null>(null);
const [draft, setDraft] = useState<PlanDraft>(() => createEmptyDraft(appConfig));
const [noteInputValue, setNoteInputValue] = useState('');
const [noteAttachmentUploading, setNoteAttachmentUploading] = useState(false);
const [actionNote, setActionNote] = useState('');
const [issueActionNote, setIssueActionNote] = useState('');
const [resolveLatestIssue, setResolveLatestIssue] = useState(false);
@@ -393,6 +631,8 @@ export function PlanBoardPage({
workId: initialSelectedWorkId,
});
const draftRef = useRef(draft);
const noteAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const noteAttachmentSessionIdRef = useRef(createPlanNoteAttachmentSessionId());
const savingRef = useRef(saving);
const previousWorkerStatusMapRef = useRef<Map<number, string | null>>(new Map());
const notifiedAutomationStartKeysRef = useRef<Set<string>>(new Set());
@@ -404,6 +644,10 @@ export function PlanBoardPage({
);
const isAutoRefreshRunning = hasPendingAutomation && autoRefreshEnabled;
const autoRefreshCountdownSeconds = Math.max(1, Math.ceil(autoRefreshRemainingMs / 1000));
const noteResources = useMemo(
() => (hasAccess ? extractPlanNoteResources(noteInputValue) : []),
[hasAccess, noteInputValue],
);
draftRef.current = draft;
savingRef.current = saving;
@@ -898,6 +1142,7 @@ export function PlanBoardPage({
return;
}
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
setDraft(createEmptyDraft(appConfig));
setResolveLatestIssue(false);
setRetryLatestIssue(true);
@@ -907,6 +1152,7 @@ export function PlanBoardPage({
}
function handleSelectItem(item: PlanItem) {
noteAttachmentSessionIdRef.current = resolvePlanNoteAttachmentSessionId(item.id, noteAttachmentSessionIdRef.current);
setDraft(toDraft(item));
setResolveLatestIssue(false);
setRetryLatestIssue(true);
@@ -1042,6 +1288,58 @@ export function PlanBoardPage({
};
}
async function handleNoteAttachmentFilesPicked(files: File[]) {
if (files.length === 0 || noteAttachmentUploading) {
return;
}
if (!hasAccess) {
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
return;
}
if (isPlanItemRequestLocked(selectedItem)) {
messageApi.warning('자동화 접수된 항목은 첨부 파일을 추가할 수 없습니다.');
return;
}
setNoteAttachmentUploading(true);
try {
const sessionId = resolvePlanNoteAttachmentSessionId(draftRef.current.id, noteAttachmentSessionIdRef.current);
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
const uploadedItems: ChatComposerAttachment[] = [];
const failedFileNames: string[] = [];
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
});
if (uploadedItems.length > 0) {
const nextNote = appendPlanNoteAttachments(noteInputValue, uploadedItems);
handleNoteChange(nextNote);
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 메모에 추가했습니다.`);
}
if (failedFileNames.length > 0) {
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
}
} finally {
setNoteAttachmentUploading(false);
}
}
function handleNoteAttachmentInputChange(event: ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
void handleNoteAttachmentFilesPicked(files);
}
async function handleDelete() {
if (savingRef.current || !draftRef.current.id) {
return;
@@ -1253,7 +1551,14 @@ export function PlanBoardPage({
hasAccess && selectedItem && !isRequestLocked && isFunctionCheckEditableStatus(selectedItem.status),
);
const canSave = hasAccess && !isRequestLocked;
const automationTypeLabel = PLAN_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType;
const automationTypeOptions = useMemo(
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const automationTypeLabel = useMemo(
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const latestActionHistory = actionHistories[0] ?? null;
const latestIssueHistory = issueHistories[0] ?? null;
const releaseCompletedTimestamps = useMemo(
@@ -1740,7 +2045,7 @@ export function PlanBoardPage({
<Select
className="plan-board-page__select plan-board-page__select--automation"
value={draft.automationType}
options={PLAN_AUTOMATION_TYPE_OPTIONS}
options={automationTypeOptions}
popupClassName="plan-board-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
@@ -1757,14 +2062,27 @@ export function PlanBoardPage({
<div>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong></Text>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!hasAccess}
onClick={() => void handleCopyText(noteInputValue)}
>
</Button>
<Space size={8} wrap>
<Button
size="small"
icon={<PaperClipOutlined />}
disabled={!hasAccess || isRequestLocked}
loading={noteAttachmentUploading}
onClick={() => {
noteAttachmentInputRef.current?.click();
}}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!hasAccess}
onClick={() => void handleCopyText(noteInputValue)}
>
</Button>
</Space>
</Flex>
<div className="plan-board-page__notepad-frame">
<TextArea
@@ -1778,11 +2096,19 @@ export function PlanBoardPage({
}}
/>
</div>
<input
ref={noteAttachmentInputRef}
type="file"
multiple
className="plan-board-page__hidden-file-input"
onChange={handleNoteAttachmentInputChange}
/>
{isRequestLocked ? (
<Text type="secondary">
{hasAccess ? '자동화 접수된 항목은 본문을 수정할 수 없습니다.' : '조회 화면에서는 작업 메모를 40% 마스킹해 표시합니다.'}
</Text>
) : null}
{noteResources.length ? <PlanNoteResourcePanel resources={noteResources} /> : null}
</div>
{selectedReleaseReviewNote.trim() ? (

View File

@@ -18,12 +18,15 @@ import {
message,
} from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import {
buildAutomationTypeOptions,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css';
import './planSchedule.css';
import { maskNotePreviewByWord } from './noteMasking';
import { PlanListDetailLayout } from './PlanListDetailLayout';
import type { PlanAutomationType } from './types';
import {
createPlanScheduledTask,
deletePlanScheduledTask,
@@ -77,14 +80,6 @@ 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;
const PLAN_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: PlanAutomationType }> = [
{ label: '선택 안함', value: 'none' },
{ label: '작업 요청 등록', value: 'plan' },
{ label: 'Command 실행', value: 'command_execution' },
{ label: '비 소스작업', value: 'non_source_work' },
{ label: 'autoWorker', value: 'auto_worker' },
];
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
@@ -327,6 +322,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
export function PlanSchedulePage() {
const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
@@ -536,6 +532,7 @@ export function PlanSchedulePage() {
emptyDetailTitle="스케줄 상세"
detailContent={
<PlanScheduleDetail
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
draft={draft}
hasAccess={hasAccess}
selectedItem={selectedItem}
@@ -605,6 +602,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
});
function PlanScheduleDetail({
automationTypeOptions,
draft,
hasAccess,
selectedItem,
@@ -612,6 +610,7 @@ function PlanScheduleDetail({
onChangeDraft,
onCopyText,
}: {
automationTypeOptions: Array<{ label: string; value: string }>;
draft: PlanScheduledTaskDraft;
hasAccess: boolean;
selectedItem: PlanScheduledTask | null;
@@ -701,7 +700,7 @@ function PlanScheduleDetail({
<Select
className="plan-schedule-page__select plan-schedule-page__select--automation"
value={draft.automationType}
options={PLAN_AUTOMATION_TYPE_OPTIONS}
options={automationTypeOptions}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}

View File

@@ -1,4 +1,5 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type {
PlanActionType,
@@ -25,16 +26,7 @@ function resolvePlanApiBaseUrl() {
}
function normalizePlanAutomationType(value: unknown): PlanAutomationType {
return value === 'plan' ||
value === 'command_execution' ||
value === 'non_source_work' ||
value === 'auto_worker'
? value
: value === 'plan_registration'
? 'plan'
: value === 'general_development'
? 'auto_worker'
: 'none';
return normalizeAutomationTypeId(value);
}
function resolveWorkServerFallbackBaseUrl() {
@@ -389,6 +381,7 @@ function normalizePlanItem(item: PlanItem): PlanItem {
return {
...item,
automationType: normalizePlanAutomationType(item.automationType),
automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType),
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
};

View File

@@ -351,6 +351,53 @@
width: 100%;
}
.plan-board-page__hidden-file-input {
display: none;
}
.plan-board-page__note-resources {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.plan-board-page__note-resource-list {
display: grid;
gap: 12px;
}
.plan-board-page__note-resource-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.96));
}
.plan-board-page__note-resource-path {
word-break: break-all;
}
.plan-board-page__note-resource-image,
.plan-board-page__note-resource-frame {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 12px;
background: #fff;
}
.plan-board-page__note-resource-image {
max-height: 320px;
object-fit: contain;
}
.plan-board-page__note-resource-frame {
min-height: 320px;
}
.plan-board-page__notepad-expand-button.ant-btn {
color: rgba(71, 98, 130, 0.92);
background: rgba(255, 255, 255, 0.9);

View File

@@ -1,10 +1,9 @@
export const PLAN_STATUSES = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'] as const;
export const PLAN_FILTER_STATUSES = ['all', 'in-progress', 'done', 'error'] as const;
export const PLAN_AUTOMATION_TYPES = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const;
export type PlanStatus = (typeof PLAN_STATUSES)[number];
export type PlanFilterStatus = (typeof PLAN_FILTER_STATUSES)[number];
export type PlanAutomationType = (typeof PLAN_AUTOMATION_TYPES)[number];
export type PlanAutomationType = string;
export type PlanActionType =
| 'start-work'
| 'complete-development'
@@ -111,6 +110,7 @@ export type PlanItem = {
workId: string;
note: string;
automationType: PlanAutomationType;
automationBehaviorType?: string;
releaseReviewNote: string;
noteMasked?: boolean;
status: PlanStatus;