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

View File

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

View File

@@ -1,63 +1,4 @@
services: 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: prod-app:
image: node:${NODE_VERSION:-22.22.2}-bookworm image: node:${NODE_VERSION:-22.22.2}-bookworm
container_name: ai-code-app-prod container_name: ai-code-app-prod
@@ -125,12 +66,12 @@ services:
user: "0:0" user: "0:0"
cpus: 1.0 cpus: 1.0
mem_limit: 1536m mem_limit: 1536m
working_dir: /release-app working_dir: /workspace/auto_codex/repo
ports: ports:
- '127.0.0.1:5175:5173' - '127.0.0.1:5175:5173'
volumes: volumes:
- ${RELEASE_APP_SOURCE:-.}:/release-app - ./.auto_codex/repo:/workspace/auto_codex/repo
- ./.docker/release-app/node_modules:/release-app/node_modules - ./.docker/release-app/node_modules:/workspace/auto_codex/repo/node_modules
- ./.docker/release-app/home:/home/how2ice - ./.docker/release-app/home:/home/how2ice
networks: networks:
- default - default
@@ -138,6 +79,9 @@ services:
environment: environment:
HOME: /home/how2ice HOME: /home/how2ice
NPM_CONFIG_CACHE: /home/how2ice/.npm 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: > command: >
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173" sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173"
@@ -148,5 +92,3 @@ networks:
volumes: volumes:
app-node-modules: app-node-modules:
app-home: app-home:
photoprism-storage:
photoprism-db:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js'; 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) { export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => { 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) => { app.put('/api/chat-types', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; 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) => { app.put('/api/app-config', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};

View File

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

View File

@@ -3,6 +3,37 @@ import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs'; export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
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() { async function ensureAppConfigTable() {
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE); const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
@@ -58,6 +89,134 @@ function normalizeConfigRecord(value: unknown) {
return value as Record<string, 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 = { export type AppConfigSnapshot = {
chat?: { chat?: {
maxContextMessages?: number; maxContextMessages?: number;
@@ -149,16 +308,30 @@ export async function getChatTypesConfig() {
const config = await getAppConfig(); const config = await getAppConfig();
const normalized = normalizeConfigRecord(config); const normalized = normalizeConfigRecord(config);
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY]; 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[]) { export async function upsertChatTypesConfig(chatTypes: unknown[]) {
const current = normalizeConfigRecord(await getAppConfig()); const current = normalizeConfigRecord(await getAppConfig());
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
const nextConfig = { const nextConfig = {
...current, ...current,
[CHAT_TYPES_CONFIG_KEY]: Array.isArray(chatTypes) ? chatTypes : [], [CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
}; };
await upsertAppConfig(nextConfig); 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', () => { test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
assert.equal( assert.equal(
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n'), buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
}),
[ [
'# 자동화 작업메모', '# 자동화 작업메모',
'', '',
'- 게시판 제목: 알림 개선', '- 게시판 제목: 알림 개선',
'- 메모 출처: board_posts 자동화 접수', '- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 자동화 메모',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
'## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
'', '',
'## 요청 본문', '## 요청 본문',
'본문 첫 줄\n본문 둘째 줄', '본문 첫 줄\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', () => { test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,79 @@
.chat-type-management-page { .chat-type-management-page {
width: 100%;
height: 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,
.chat-type-management-page .ant-card-body, .chat-type-management-page .ant-card-body,
.chat-type-management-page__card { .chat-type-management-page__card {
width: 100%;
height: 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__list,
.chat-type-management-page__editor { .chat-type-management-page__editor {
width: 100%;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
height: 100%; 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 { .chat-type-management-page__list-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 8px;
}
.chat-type-management-page__list-header .ant-typography {
margin-bottom: 0;
} }
.chat-type-management-page__item { .chat-type-management-page__item {
@@ -44,3 +96,246 @@
.chat-type-management-page__item-description.ant-typography { .chat-type-management-page__item-description.ant-typography {
margin: 8px 0 10px; 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 {
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd'; 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 { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
canUseChatType, canUseChatType,
CHAT_PERMISSION_ROLE_LABELS, CHAT_PERMISSION_ROLE_LABELS,
@@ -14,13 +22,12 @@ import {
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css'; import './ChatTypeManagementPage.css';
const { Paragraph, Text, Title } = Typography; const { Text, Title } = Typography;
type ChatTypeFormValue = { type ChatTypeFormValue = {
id?: string; id?: string;
name: string; name: string;
description: string; description: string;
isTemplate: boolean;
permissions: ChatPermissionRole[]; permissions: ChatPermissionRole[];
enabled: boolean; enabled: boolean;
}; };
@@ -28,7 +35,6 @@ type ChatTypeFormValue = {
const EMPTY_FORM_VALUE: ChatTypeFormValue = { const EMPTY_FORM_VALUE: ChatTypeFormValue = {
name: '', name: '',
description: '', description: '',
isTemplate: false,
permissions: ['token-user'], permissions: ['token-user'],
enabled: true, enabled: true,
}; };
@@ -42,7 +48,6 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
id: chatType.id, id: chatType.id,
name: chatType.name, name: chatType.name,
description: chatType.description, description: chatType.description,
isTemplate: chatType.isTemplate,
permissions: chatType.permissions, permissions: chatType.permissions,
enabled: chatType.enabled, enabled: chatType.enabled,
}; };
@@ -52,12 +57,16 @@ export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry(); const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null); 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 [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<ChatTypeFormValue>(); const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none';
const selectedChatType = useMemo( const selectedChatType = useMemo(
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null, () => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
@@ -88,10 +97,32 @@ export function ChatTypeManagementPage() {
setDetailMode('list'); setDetailMode('list');
}, [detailMode, isCreating, selectedChatType]); }, [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 = () => { const openCreateForm = () => {
setIsCreating(true); setIsCreating(true);
setSelectedChatTypeId(null); setSelectedChatTypeId(null);
setDetailMode('detail'); setDetailMode('detail');
setMaximizedPane('none');
form.resetFields(); form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE); form.setFieldsValue(EMPTY_FORM_VALUE);
}; };
@@ -100,11 +131,13 @@ export function ChatTypeManagementPage() {
setIsCreating(false); setIsCreating(false);
setSelectedChatTypeId(chatTypeId); setSelectedChatTypeId(chatTypeId);
setDetailMode('detail'); setDetailMode('detail');
setMaximizedPane('none');
}; };
const closeDetail = () => { const closeDetail = () => {
setIsCreating(false); setIsCreating(false);
setDetailMode('list'); setDetailMode('list');
setMaximizedPane('none');
}; };
const handleDelete = async () => { const handleDelete = async () => {
@@ -148,7 +181,11 @@ export function ChatTypeManagementPage() {
} }
return ( 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' ? ( {detailMode === 'list' ? (
<Card <Card
title="컨텍스트 권한 관리" title="컨텍스트 권한 관리"
@@ -200,14 +237,17 @@ export function ChatTypeManagementPage() {
<Space size={[8, 8]} wrap> <Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text> <Text strong>{item.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag> <Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
{item.isTemplate ? <Tag color="gold">릿</Tag> : null}
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}> <Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'} {isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
</Tag> </Tag>
</Space> </Space>
<Paragraph className="chat-type-management-page__item-description"> <div className="chat-type-management-page__item-description">
{item.description || '기본 문맥 설명 없음'} {item.description ? (
</Paragraph> <MarkdownPreviewContent content={item.description} maxBlocks={3} />
) : (
'기본 문맥 설명 없음'
)}
</div>
<Space size={[6, 6]} wrap> <Space size={[6, 6]} wrap>
{item.permissions.map((permission) => ( {item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag> <Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
@@ -226,29 +266,17 @@ export function ChatTypeManagementPage() {
) : ( ) : (
<Card <Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'} title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
className="chat-type-management-page__card" className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
}
> >
<div className="chat-type-management-page__editor"> <div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null} {errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null} {saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header"> <div className="chat-type-management-page__list-header">
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title> <Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
<Text type="secondary"> , .</Text>
</div> </div>
<Form <Form
className="chat-type-management-page__editor-form"
layout="vertical" layout="vertical"
form={form} form={form}
initialValues={EMPTY_FORM_VALUE} initialValues={EMPTY_FORM_VALUE}
@@ -273,23 +301,20 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden> <Form.Item name="id" hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item <Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명" label="컨텍스트명"
name="name" name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]} rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
> >
<Input placeholder="예: 운영 문의" /> <Input placeholder="예: 운영 문의" />
</Form.Item> </Form.Item>
<Form.Item label="기본 문맥 설명" name="description"> <Form.Item
<Input.TextArea className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
autoSize={{ minRows: 4, maxRows: 8 }} label="권한 대상"
placeholder="이 컨텍스트에서 기본으로 참고해야 할 문맥을 입력하세요." name="permissions"
/> >
</Form.Item>
<Form.Item label="템플릿 요청 여부" name="isTemplate" valuePropName="checked">
<Switch checkedChildren="템플릿" unCheckedChildren="일반" />
</Form.Item>
<Form.Item label="권한 대상" name="permissions">
<Checkbox.Group <Checkbox.Group
options={[ options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' }, { label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
@@ -297,9 +322,147 @@ export function ChatTypeManagementPage() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label="사용 여부" name="enabled" valuePropName="checked"> <Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" /> <Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item> </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' : ''
}`}
>
<Space wrap> <Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}> <Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'} {isCreating ? '등록' : '수정 저장'}
@@ -308,6 +471,17 @@ export function ChatTypeManagementPage() {
</Button> </Button>
</Space> </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> </Form>
</div> </div>
</Card> </Card>

View File

@@ -76,11 +76,10 @@ type ChatTypeOption = {
value: string; value: string;
label: string; label: string;
description: string; description: string;
isTemplate: boolean;
disabled?: boolean; disabled?: boolean;
}; };
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
type PreviewItem = { type PreviewItem = {
id: string; id: string;
@@ -98,7 +97,6 @@ type PendingChatRequest = {
chatTypeId: string; chatTypeId: string;
chatTypeLabel: string; chatTypeLabel: string;
chatTypeDescription: string; chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number; retryCount: number;
failed: boolean; failed: boolean;
}; };
@@ -109,7 +107,6 @@ type PendingContextConfirm = {
chatTypeId: string; chatTypeId: string;
chatTypeLabel: string; chatTypeLabel: string;
chatTypeDescription: string; chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number; includedContextCount: number;
omittedContextCount: number; omittedContextCount: number;
}; };
@@ -660,6 +657,21 @@ function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value); 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 { function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? ''; const pathname = url.toLowerCase().split('?')[0] ?? '';
@@ -675,6 +687,10 @@ function classifyPreviewKind(url: string): PreviewKind {
return 'markdown'; 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)) { 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'; return 'code';
} }
@@ -687,6 +703,10 @@ function classifyPreviewKind(url: string): PreviewKind {
return 'pdf'; return 'pdf';
} }
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file'; return 'file';
} }
@@ -869,7 +889,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
value: item.id, value: item.id,
label: item.name, label: item.name,
description: item.description, description: item.description,
isTemplate: item.isTemplate,
disabled: !isAllowed, disabled: !isAllowed,
}; };
}), }),
@@ -959,7 +978,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeId: selectedChatType?.id ?? null, chatTypeId: selectedChatType?.id ?? null,
chatTypeLabel: selectedChatType?.name ?? '', chatTypeLabel: selectedChatType?.name ?? '',
chatTypeDescription: selectedChatType?.description ?? '', chatTypeDescription: selectedChatType?.description ?? '',
chatTypeIsTemplate: selectedChatType?.isTemplate ?? false,
}; };
const { const {
conversationItems, conversationItems,
@@ -2124,7 +2142,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeId: request.chatTypeId, chatTypeId: request.chatTypeId,
chatTypeLabel: request.chatTypeLabel, chatTypeLabel: request.chatTypeLabel,
chatTypeDescription: request.chatTypeDescription, chatTypeDescription: request.chatTypeDescription,
chatTypeIsTemplate: request.chatTypeIsTemplate,
requestId: request.requestId, requestId: request.requestId,
mode: request.mode, mode: request.mode,
}, },

View File

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

View File

@@ -3,19 +3,20 @@ import {
BellOutlined, BellOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CopyOutlined, CopyOutlined,
DownOutlined,
FileMarkdownOutlined, FileMarkdownOutlined,
LoadingOutlined, LoadingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
ProfileOutlined, ProfileOutlined,
ReloadOutlined, ReloadOutlined,
RightOutlined,
SettingOutlined, SettingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Divider,
Drawer, Drawer,
Dropdown, Dropdown,
Grid, Grid,
@@ -884,6 +885,7 @@ export function MainHeader({
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [automationGroupExpanded, setAutomationGroupExpanded] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [activeSettingsModal, setActiveSettingsModal] = useState<SettingsModalKey>('appSettings'); const [activeSettingsModal, setActiveSettingsModal] = useState<SettingsModalKey>('appSettings');
const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation'); const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation');
@@ -926,9 +928,7 @@ export function MainHeader({
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null); const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null); const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [serverRestartingKey, setServerRestartingKey] = useState< const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null);
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
>(null);
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null); const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null); const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess(); const { registeredToken, hasAccess } = useTokenAccess();
@@ -961,6 +961,8 @@ export function MainHeader({
const workServerPendingUpdateCount = const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount; const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
const totalAutomationShortcutCount =
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
const settingsStatusClassName = const settingsStatusClassName =
totalPendingUpdateCount >= 2 totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive' ? '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 () => { const handleRestartBothServers = async () => {
if (!hasAccess || serverRestartingKey) { if (!hasAccess || serverRestartingKey) {
return; return;
@@ -2668,12 +2626,42 @@ export function MainHeader({
const settingsMenu = ( const settingsMenu = (
<div className="app-header__settings-menu"> <div className="app-header__settings-menu">
{hasAccess ? ( {hasAccess ? (
<> <div className="app-header__settings-group">
<button <button
type="button" type="button"
className="app-header__settings-item" className="app-header__settings-item"
aria-expanded={automationGroupExpanded}
onClick={() => {
setAutomationGroupExpanded((current) => !current);
}}
>
<span className="app-header__settings-icon">
<ReloadOutlined />
<span
className={`app-header__status-dot ${
totalAutomationShortcutCount > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
<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={() => { onClick={() => {
setSettingsOpen(false); setSettingsOpen(false);
setAutomationGroupExpanded(false);
onOpenPlanQuickFilter('working'); onOpenPlanQuickFilter('working');
}} }}
> >
@@ -2695,9 +2683,10 @@ export function MainHeader({
</button> </button>
<button <button
type="button" type="button"
className="app-header__settings-item" className="app-header__settings-item app-header__settings-item--nested"
onClick={() => { onClick={() => {
setSettingsOpen(false); setSettingsOpen(false);
setAutomationGroupExpanded(false);
onOpenPlanQuickFilter('release-pending-main'); onOpenPlanQuickFilter('release-pending-main');
}} }}
> >
@@ -2719,9 +2708,10 @@ export function MainHeader({
</button> </button>
<button <button
type="button" type="button"
className="app-header__settings-item" className="app-header__settings-item app-header__settings-item--nested"
onClick={() => { onClick={() => {
setSettingsOpen(false); setSettingsOpen(false);
setAutomationGroupExpanded(false);
onOpenPlanQuickFilter('automation-failed'); onOpenPlanQuickFilter('automation-failed');
}} }}
> >
@@ -2741,7 +2731,9 @@ export function MainHeader({
<Text type="secondary"> {planShortcutCounts.automationFailed}</Text> <Text type="secondary"> {planShortcutCounts.automationFailed}</Text>
</span> </span>
</button> </button>
</> </div>
) : null}
</div>
) : null} ) : null}
<button <button
type="button" type="button"
@@ -3120,24 +3112,6 @@ export function MainHeader({
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null} {activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null} {activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : 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} ) : null}
{activeSettingsModal === 'notification' ? ( {activeSettingsModal === 'notification' ? (

View File

@@ -174,10 +174,23 @@
gap: 8px; gap: 8px;
} }
.app-header__settings-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.app-header__settings-item:hover { .app-header__settings-item:hover {
background: #f3f7ff; background: #f3f7ff;
} }
.app-header__settings-item--nested {
margin-left: 12px;
min-width: 0;
padding-left: 14px;
background: #f8fbff;
}
.app-header__settings-icon { .app-header__settings-icon {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -241,10 +254,23 @@
} }
.app-header__settings-label { .app-header__settings-label {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 1;
font-size: 14px; font-size: 14px;
font-weight: 600; 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 { .app-header__update-progress {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -363,11 +389,15 @@
position: fixed; position: fixed;
inset: 72px 0 0; inset: 72px 0 0;
z-index: 40; z-index: 40;
width: 100% !important; width: 100vw !important;
max-width: 100%; min-width: 100vw !important;
max-width: 100vw;
flex: 0 0 100vw !important;
height: calc(100vh - 72px); height: calc(100vh - 72px);
border-right: 0; border-right: 0;
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
transition: none !important;
overflow: hidden;
} }
.app-sider--mobile-inline.ant-layout-sider { .app-sider--mobile-inline.ant-layout-sider {
@@ -385,6 +415,9 @@
gap: 12px; gap: 12px;
height: 100%; height: 100%;
padding: 12px 10px; padding: 12px 10px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
} }
.app-sider__intro { .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; id: string;
name: string; name: string;
description: string; description: string;
isTemplate: boolean;
permissions: ChatPermissionRole[]; permissions: ChatPermissionRole[];
enabled: boolean; enabled: boolean;
updatedAt: string; updatedAt: string;
@@ -17,7 +16,6 @@ export type ChatTypeInput = {
id?: string; id?: string;
name: string; name: string;
description?: string; description?: string;
isTemplate?: boolean;
permissions: ChatPermissionRole[]; permissions: ChatPermissionRole[];
enabled?: boolean; enabled?: boolean;
}; };
@@ -39,8 +37,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request', id: 'general-request',
name: '일반 요청', name: '일반 요청',
description: description:
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.', '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
isTemplate: false,
permissions: ['token-user'], permissions: ['token-user'],
enabled: true, enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z', updatedAt: '2026-04-21T00:00:00.000Z',
@@ -48,8 +45,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{ {
id: 'api-request-template', id: 'api-request-template',
name: 'API요청', name: 'API요청',
description: 'API요청만 진행 (자동화, 작업요청, 스케줄 등 호출 가능한 API)', description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
isTemplate: true,
permissions: ['token-user'], permissions: ['token-user'],
enabled: true, enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z', updatedAt: '2026-04-16T00:00:00.000Z',
@@ -90,15 +86,14 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
id, id,
name, name,
description: normalizeText(record.description), description: normalizeText(record.description),
isTemplate: record.isTemplate === true,
permissions: normalizePermissions(record.permissions), permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false, enabled: record.enabled !== false,
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(), updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(),
}; };
} }
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) { function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`; return buildChatTypeNameKey(record.name);
} }
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) { function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
@@ -314,7 +309,6 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
id: input.id, id: input.id,
name: input.name, name: input.name,
description: input.description, description: input.description,
isTemplate: input.isTemplate,
permissions: input.permissions, permissions: input.permissions,
enabled: input.enabled, enabled: input.enabled,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,6 +145,18 @@ export function buildSearchOptions({
}, },
...(hasAccess ...(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', id: 'page:plans:history',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`, label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,

View File

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

View File

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

View File

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

View File

@@ -101,6 +101,18 @@ export function buildMainViewSearchOptions({
}, },
onSelectWindow, 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 ...(hasAccess
? [ ? [
{ {

View File

@@ -1,3 +1,4 @@
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
import { BoardPage } from '../../../features/board'; import { BoardPage } from '../../../features/board';
import { HistoryPage } from '../../../features/history'; import { HistoryPage } from '../../../features/history';
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard'; import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
@@ -53,6 +54,14 @@ export function PlansPage() {
); );
} }
if (selectedPlanMenu === 'automation-type') {
return (
<div className="app-main-panel">
<AutomationTypeManagementPage />
</div>
);
}
if (selectedPlanMenu === 'server-command') { if (selectedPlanMenu === 'server-command') {
return ( return (
<div className="app-main-panel"> <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 TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
export type HeaderTopMenuKey = 'docs' | 'plans'; export type HeaderTopMenuKey = 'docs' | 'plans';
export type ApiSectionKey = 'components' | 'widgets'; 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 ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout'; export type PlaySectionKey = 'layout';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`; export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
@@ -39,6 +48,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
charts: '차트', charts: '차트',
schedule: '스케줄', schedule: '스케줄',
history: '이력', history: '이력',
'automation-type': '자동화 유형',
'server-command': 'Command', 'server-command': 'Command',
}; };
@@ -57,6 +67,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
charts: 'plan-menu-charts', charts: 'plan-menu-charts',
schedule: 'plan-menu-schedule', schedule: 'plan-menu-schedule',
history: 'plan-menu-history', history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'server-command': 'plan-menu-server-command', 'server-command': 'plan-menu-server-command',
}; };
@@ -188,6 +199,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
key: 'history', key: 'history',
label: renderPlanMenuLabel('history', PLAN_SIDEBAR_LABELS.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'; } from '@ant-design/icons';
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd'; import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
createBoardPost, createBoardPost,
@@ -22,7 +27,7 @@ import {
setupBoard, setupBoard,
updateBoardPost, updateBoardPost,
} from './api'; } from './api';
import type { BoardAutomationType, BoardDraft, BoardPost } from './types'; import type { BoardDraft, BoardPost } from './types';
const { Paragraph, Text, Title } = Typography; const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -34,18 +39,6 @@ const EMPTY_DRAFT: BoardDraft = {
automationType: 'none', 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) { function formatDateTime(value: string) {
return new Date(value).toLocaleString('ko-KR', { return new Date(value).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul', timeZone: 'Asia/Seoul',
@@ -128,6 +121,7 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
export function BoardPage() { export function BoardPage() {
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const { automationTypes } = useAutomationTypeRegistry();
const [items, setItems] = useState<BoardPost[]>([]); const [items, setItems] = useState<BoardPost[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]); const [checkedIds, setCheckedIds] = useState<number[]>([]);
@@ -216,7 +210,14 @@ export function BoardPage() {
); );
const dirtyDraftId = draftDirty && draft.id ? draft.id : null; const dirtyDraftId = draftDirty && draft.id ? draft.id : null;
const automationStatus = resolveBoardAutomationStatus(draft.id, automationReceived, draftDirty, automationReceiveError); 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( const receivableIds = useMemo(
() => () =>
items items
@@ -671,7 +672,7 @@ export function BoardPage() {
<Select <Select
className="board-page__automation-select" className="board-page__automation-select"
value={draft.automationType} value={draft.automationType}
options={BOARD_AUTOMATION_TYPE_OPTIONS} options={automationTypeOptions}
popupClassName="board-page__automation-select-popup" popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body} getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked} disabled={isDraftLocked}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
DownOutlined, DownOutlined,
FullscreenExitOutlined, FullscreenExitOutlined,
FullscreenOutlined, FullscreenOutlined,
PaperClipOutlined,
UpOutlined, UpOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
@@ -28,8 +29,16 @@ import {
Typography, Typography,
message, message,
} from 'antd'; } 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 { 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 { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css'; import './planBoard.css';
import { import {
@@ -67,7 +76,6 @@ import { maskNotePreviewByWord } from './noteMasking';
import type { import type {
PlanActionHistory, PlanActionHistory,
PlanActionType, PlanActionType,
PlanAutomationType,
PlanAutomationUsageSnapshot, PlanAutomationUsageSnapshot,
PlanDraft, PlanDraft,
PlanFilterStatus, PlanFilterStatus,
@@ -130,16 +138,6 @@ const MAIN_STATE_FILTER_OPTIONS: Array<{ label: string; value: MainStateFilter }
{ label: 'main 실패', value: 'failed' }, { label: 'main 실패', value: 'failed' },
{ label: 'main 미대상', value: 'not-targeted' }, { 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 }> = [ const ISSUE_STATE_FILTER_OPTIONS: Array<{ label: string; value: IssueStateFilter }> = [
{ label: '이슈 전체', value: 'all' }, { label: '이슈 전체', value: 'all' },
{ label: '열린 이슈', value: 'open' }, { label: '열린 이슈', value: 'open' },
@@ -160,6 +158,191 @@ type ReviewListIndicator = {
totalCount: number; 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) { function isPlanItemRequestLocked(item: Pick<PlanItem, 'startedAt'> | null | undefined) {
return Boolean(item?.startedAt); 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 = { type ActionButton = {
key: PlanActionType; key: PlanActionType;
label: string; label: string;
@@ -352,6 +588,7 @@ export function PlanBoardPage({
initialSelectedWorkId = null, initialSelectedWorkId = null,
}: PlanBoardPageProps) { }: PlanBoardPageProps) {
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000; const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
@@ -365,6 +602,7 @@ export function PlanBoardPage({
const [selectedSourceWork, setSelectedSourceWork] = useState<PlanSourceWorkHistory | null>(null); const [selectedSourceWork, setSelectedSourceWork] = useState<PlanSourceWorkHistory | null>(null);
const [draft, setDraft] = useState<PlanDraft>(() => createEmptyDraft(appConfig)); const [draft, setDraft] = useState<PlanDraft>(() => createEmptyDraft(appConfig));
const [noteInputValue, setNoteInputValue] = useState(''); const [noteInputValue, setNoteInputValue] = useState('');
const [noteAttachmentUploading, setNoteAttachmentUploading] = useState(false);
const [actionNote, setActionNote] = useState(''); const [actionNote, setActionNote] = useState('');
const [issueActionNote, setIssueActionNote] = useState(''); const [issueActionNote, setIssueActionNote] = useState('');
const [resolveLatestIssue, setResolveLatestIssue] = useState(false); const [resolveLatestIssue, setResolveLatestIssue] = useState(false);
@@ -393,6 +631,8 @@ export function PlanBoardPage({
workId: initialSelectedWorkId, workId: initialSelectedWorkId,
}); });
const draftRef = useRef(draft); const draftRef = useRef(draft);
const noteAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const noteAttachmentSessionIdRef = useRef(createPlanNoteAttachmentSessionId());
const savingRef = useRef(saving); const savingRef = useRef(saving);
const previousWorkerStatusMapRef = useRef<Map<number, string | null>>(new Map()); const previousWorkerStatusMapRef = useRef<Map<number, string | null>>(new Map());
const notifiedAutomationStartKeysRef = useRef<Set<string>>(new Set()); const notifiedAutomationStartKeysRef = useRef<Set<string>>(new Set());
@@ -404,6 +644,10 @@ export function PlanBoardPage({
); );
const isAutoRefreshRunning = hasPendingAutomation && autoRefreshEnabled; const isAutoRefreshRunning = hasPendingAutomation && autoRefreshEnabled;
const autoRefreshCountdownSeconds = Math.max(1, Math.ceil(autoRefreshRemainingMs / 1000)); const autoRefreshCountdownSeconds = Math.max(1, Math.ceil(autoRefreshRemainingMs / 1000));
const noteResources = useMemo(
() => (hasAccess ? extractPlanNoteResources(noteInputValue) : []),
[hasAccess, noteInputValue],
);
draftRef.current = draft; draftRef.current = draft;
savingRef.current = saving; savingRef.current = saving;
@@ -898,6 +1142,7 @@ export function PlanBoardPage({
return; return;
} }
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
setDraft(createEmptyDraft(appConfig)); setDraft(createEmptyDraft(appConfig));
setResolveLatestIssue(false); setResolveLatestIssue(false);
setRetryLatestIssue(true); setRetryLatestIssue(true);
@@ -907,6 +1152,7 @@ export function PlanBoardPage({
} }
function handleSelectItem(item: PlanItem) { function handleSelectItem(item: PlanItem) {
noteAttachmentSessionIdRef.current = resolvePlanNoteAttachmentSessionId(item.id, noteAttachmentSessionIdRef.current);
setDraft(toDraft(item)); setDraft(toDraft(item));
setResolveLatestIssue(false); setResolveLatestIssue(false);
setRetryLatestIssue(true); 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() { async function handleDelete() {
if (savingRef.current || !draftRef.current.id) { if (savingRef.current || !draftRef.current.id) {
return; return;
@@ -1253,7 +1551,14 @@ export function PlanBoardPage({
hasAccess && selectedItem && !isRequestLocked && isFunctionCheckEditableStatus(selectedItem.status), hasAccess && selectedItem && !isRequestLocked && isFunctionCheckEditableStatus(selectedItem.status),
); );
const canSave = hasAccess && !isRequestLocked; 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 latestActionHistory = actionHistories[0] ?? null;
const latestIssueHistory = issueHistories[0] ?? null; const latestIssueHistory = issueHistories[0] ?? null;
const releaseCompletedTimestamps = useMemo( const releaseCompletedTimestamps = useMemo(
@@ -1740,7 +2045,7 @@ export function PlanBoardPage({
<Select <Select
className="plan-board-page__select plan-board-page__select--automation" className="plan-board-page__select plan-board-page__select--automation"
value={draft.automationType} value={draft.automationType}
options={PLAN_AUTOMATION_TYPE_OPTIONS} options={automationTypeOptions}
popupClassName="plan-board-page__select-popup" popupClassName="plan-board-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body} getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess} disabled={!hasAccess}
@@ -1757,6 +2062,18 @@ export function PlanBoardPage({
<div> <div>
<Flex justify="space-between" align="center" gap={8} wrap> <Flex justify="space-between" align="center" gap={8} wrap>
<Text strong></Text> <Text strong></Text>
<Space size={8} wrap>
<Button
size="small"
icon={<PaperClipOutlined />}
disabled={!hasAccess || isRequestLocked}
loading={noteAttachmentUploading}
onClick={() => {
noteAttachmentInputRef.current?.click();
}}
>
</Button>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined />}
@@ -1765,6 +2082,7 @@ export function PlanBoardPage({
> >
</Button> </Button>
</Space>
</Flex> </Flex>
<div className="plan-board-page__notepad-frame"> <div className="plan-board-page__notepad-frame">
<TextArea <TextArea
@@ -1778,11 +2096,19 @@ export function PlanBoardPage({
}} }}
/> />
</div> </div>
<input
ref={noteAttachmentInputRef}
type="file"
multiple
className="plan-board-page__hidden-file-input"
onChange={handleNoteAttachmentInputChange}
/>
{isRequestLocked ? ( {isRequestLocked ? (
<Text type="secondary"> <Text type="secondary">
{hasAccess ? '자동화 접수된 항목은 본문을 수정할 수 없습니다.' : '조회 화면에서는 작업 메모를 40% 마스킹해 표시합니다.'} {hasAccess ? '자동화 접수된 항목은 본문을 수정할 수 없습니다.' : '조회 화면에서는 작업 메모를 40% 마스킹해 표시합니다.'}
</Text> </Text>
) : null} ) : null}
{noteResources.length ? <PlanNoteResourcePanel resources={noteResources} /> : null}
</div> </div>
{selectedReleaseReviewNote.trim() ? ( {selectedReleaseReviewNote.trim() ? (

View File

@@ -18,12 +18,15 @@ import {
message, message,
} from 'antd'; } from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import {
buildAutomationTypeOptions,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { useTokenAccess } from '../../app/main/tokenAccess'; import { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css'; import './planBoard.css';
import './planSchedule.css'; import './planSchedule.css';
import { maskNotePreviewByWord } from './noteMasking'; import { maskNotePreviewByWord } from './noteMasking';
import { PlanListDetailLayout } from './PlanListDetailLayout'; import { PlanListDetailLayout } from './PlanListDetailLayout';
import type { PlanAutomationType } from './types';
import { import {
createPlanScheduledTask, createPlanScheduledTask,
deletePlanScheduledTask, deletePlanScheduledTask,
@@ -77,14 +80,6 @@ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
const DEFAULT_DAILY_RUN_TIME = '09:00'; const DEFAULT_DAILY_RUN_TIME = '09:00';
const KST_TIME_ZONE = 'Asia/Seoul'; const KST_TIME_ZONE = 'Asia/Seoul';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
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) { function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1)); const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
@@ -327,6 +322,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
export function PlanSchedulePage() { export function PlanSchedulePage() {
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<PlanScheduledTask[]>([]); const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft()); const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
@@ -536,6 +532,7 @@ export function PlanSchedulePage() {
emptyDetailTitle="스케줄 상세" emptyDetailTitle="스케줄 상세"
detailContent={ detailContent={
<PlanScheduleDetail <PlanScheduleDetail
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
draft={draft} draft={draft}
hasAccess={hasAccess} hasAccess={hasAccess}
selectedItem={selectedItem} selectedItem={selectedItem}
@@ -605,6 +602,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
}); });
function PlanScheduleDetail({ function PlanScheduleDetail({
automationTypeOptions,
draft, draft,
hasAccess, hasAccess,
selectedItem, selectedItem,
@@ -612,6 +610,7 @@ function PlanScheduleDetail({
onChangeDraft, onChangeDraft,
onCopyText, onCopyText,
}: { }: {
automationTypeOptions: Array<{ label: string; value: string }>;
draft: PlanScheduledTaskDraft; draft: PlanScheduledTaskDraft;
hasAccess: boolean; hasAccess: boolean;
selectedItem: PlanScheduledTask | null; selectedItem: PlanScheduledTask | null;
@@ -701,7 +700,7 @@ function PlanScheduleDetail({
<Select <Select
className="plan-schedule-page__select plan-schedule-page__select--automation" className="plan-schedule-page__select plan-schedule-page__select--automation"
value={draft.automationType} value={draft.automationType}
options={PLAN_AUTOMATION_TYPE_OPTIONS} options={automationTypeOptions}
popupClassName="plan-schedule-page__select-popup" popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body} getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess} disabled={!hasAccess}

View File

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

View File

@@ -351,6 +351,53 @@
width: 100%; 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 { .plan-board-page__notepad-expand-button.ant-btn {
color: rgba(71, 98, 130, 0.92); color: rgba(71, 98, 130, 0.92);
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);

View File

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