From f2d6310efab971ab432581b15bf56462d9a631d2 Mon Sep 17 00:00:00 2001 From: how2ice Date: Fri, 24 Apr 2026 08:06:36 +0900 Subject: [PATCH] feat: update codex live automation and plan flows --- AGENTS.md | 16 +- README.md | 2 +- docker-compose.yml | 70 +-- docs/README.md | 2 +- docs/features/plan-automation.md | 6 +- docs/features/plan-board-review.md | 4 +- etc/servers/work-server/README.md | 20 +- etc/servers/work-server/docker-compose.yml | 1 - .../work-server/src/routes/app-config.ts | 39 ++ etc/servers/work-server/src/server.ts | 34 +- .../src/services/app-config-service.ts | 179 ++++++- .../automation-type-config-service.ts | 389 +++++++++++++++ .../src/services/board-service.test.ts | 31 +- .../work-server/src/services/board-service.ts | 37 +- .../src/services/chat-service.test.ts | 15 +- .../work-server/src/services/chat-service.ts | 78 +-- .../src/services/plan-schedule-service.ts | 26 +- .../work-server/src/services/plan-service.ts | 82 +-- src/app/main/AutomationTypeManagementPage.tsx | 465 ++++++++++++++++++ src/app/main/ChatRuntimeBridgeV2.tsx | 1 - src/app/main/ChatTypeManagementPage.css | 299 ++++++++++- src/app/main/ChatTypeManagementPage.tsx | 292 ++++++++--- src/app/main/MainChatPanel.tsx | 31 +- src/app/main/MainContent.tsx | 5 + src/app/main/MainHeader.tsx | 67 +-- src/app/main/MainLayout.css | 11 +- src/app/main/automationTypeAccess.ts | 401 +++++++++++++++ src/app/main/chatTypeAccess.ts | 14 +- .../useConversationComposerController.ts | 42 +- .../useConversationRoomActionsController.ts | 1 - .../hooks/useConversationViewController.ts | 2 +- src/app/main/layout/MainLayout.tsx | 40 +- src/app/main/layout/buildSearchOptions.ts | 12 + src/app/main/mainChatPanel/types.ts | 1 - .../main/mainChatPanel/useChatConnection.ts | 1 - src/app/main/mainView/constants.tsx | 2 + src/app/main/mainView/searchOptions.ts | 12 + src/app/main/pages/PlansPage.tsx | 9 + src/app/main/routes.tsx | 17 +- src/features/board/BoardPage.tsx | 43 +- src/features/board/api.ts | 12 +- src/features/board/types.ts | 10 +- src/features/planBoard/PlanBoardPage.tsx | 370 +++++++++++++- src/features/planBoard/PlanSchedulePage.tsx | 19 +- src/features/planBoard/api.ts | 13 +- src/features/planBoard/planBoard.css | 47 ++ src/features/planBoard/types.ts | 4 +- 47 files changed, 2767 insertions(+), 507 deletions(-) create mode 100644 etc/servers/work-server/src/services/automation-type-config-service.ts create mode 100644 src/app/main/AutomationTypeManagementPage.tsx create mode 100644 src/app/main/automationTypeAccess.ts diff --git a/AGENTS.md b/AGENTS.md index d64d66d..7414db3 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,6 @@ * `test.sm-home.cloud`의 `/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다 * 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다 * `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다 -* `자동화 작업메모`, `자동화 메모`, 자동화 접수된 작업메모는 **항상 신규 `feature/*` 브랜치를 생성해 작업**하고, 이후 `release` 반영과 `main` 일괄반영까지 진행한다 -* 자동화 메모 작업을 실제 진행할 때 **현재 작업중인 자동화가 없다면**, 자동화 작업공간의 `release` 브랜치를 먼저 `main` 기준 최신 상태로 맞춘 뒤 다음 자동화 작업을 시작한다 -* 자동화 작업메모의 `main` 일괄반영이 끝나면 **프로젝트 루트에서 최신 `main`을 `pull --ff-only`로 동기화**한다 * `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다 * 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다 * 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다 @@ -31,7 +28,7 @@ * 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다 * 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다 * 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다 -* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석하고, 신규 `feature/*` 브랜치 생성부터 `release` 반영, `main` 일괄반영, 프로젝트 루트 `pull`까지 포함한 흐름을 적용한다 +* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석한다 * 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다 * 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다 * 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다 @@ -50,13 +47,10 @@ ## Codex Live / 채팅 / 작업 메모 규칙 * `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다 -* 자동화 작업메모 반영 요청은 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull`** 순서를 기본으로 처리한다 -* 자동화 작업메모를 시작하기 직전에 **진행 중인 자동화가 없으면**, 자동화 작업공간의 `release`를 `main`과 먼저 맞춘 뒤 `feature/*` 작업을 시작한다 * 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다 * `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다 * 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다 * 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다 -* 자동화 작업메모는 Git flow를 기본으로 적용하며, 일반 채팅/수동 작업은 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다 * 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat//resource/` 아래 세션 전용 경로를 기준으로 사용한다 * 채팅 첨부 파일은 `public/.codex_chat//resource/uploads/` 아래 경로를 기준으로 사용한다 * 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다 @@ -67,9 +61,8 @@ ## Plan / 자동화 메모 * 일반 수동 작업에는 여전히 로컬 `main` 직접 수정 원칙을 유지한다 -* 다만 자동화 작업메모와 Plan 자동화에는 기존 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 기본 규칙으로 다시 적용한다 -* 추가로 자동화 큐가 비어 있는 시점에 새 자동화 메모 작업을 시작하면, 자동화 작업공간의 `release`를 `main`과 동기화한 뒤 같은 흐름을 이어간다 -* `hotfix/*` 흐름은 기존 예외 규칙을 유지하고, 자동화 대상이 아닌 일반 요청에는 자동 적용하지 않는다 +* 자동화 작업메모와 Plan 자동화도 별도 Git 흐름을 기본 전제로 문서화하지 않는다 +* 자동화 처리 세부 절차가 필요하면 해당 기능 문서나 서버 설정이 아니라, 실제 요청 문맥과 현재 운영 설정을 기준으로 다시 확인한다 --- @@ -77,8 +70,7 @@ 👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다 👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다 -👉 자동화가 비어 있으면 다음 자동화 시작 전 작업공간 `release`를 `main`과 먼저 맞춘다 -👉 자동화 작업메모는 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 탄다 +👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다 👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다 --- diff --git a/README.md b/README.md index 1847640..db3f9ee 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입 - Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다. - Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다. - `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다. -- 단, 자동화 접수된 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름을 사용합니다. +- 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다. - 채팅 리소스와 첨부 파일은 `public/.codex_chat//resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat//resource/uploads/...` 아래를 사용합니다. ## 시작하기 diff --git a/docker-compose.yml b/docker-compose.yml index 2d34c8e..788aa9f 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,63 +1,4 @@ services: - photoprism: - image: photoprism/photoprism:latest - container_name: photoprism - logging: - driver: json-file - options: - max-size: "200m" - max-file: "2" - depends_on: - - photoprism-db - ports: - - '127.0.0.1:${PHOTOPRISM_PORT:-2342}:2342' - volumes: - - type: bind - source: ${PHOTOPRISM_ORIGINALS_SOURCE:-/mnt/usb/photos} - target: /photoprism/originals - read_only: true - bind: - create_host_path: false - - photoprism-storage:/photoprism/storage - environment: - PHOTOPRISM_ADMIN_USER: ${PHOTOPRISM_ADMIN_USER:-admin} - PHOTOPRISM_ADMIN_PASSWORD: ${PHOTOPRISM_ADMIN_PASSWORD:-ChangeMe1234} - PHOTOPRISM_SITE_URL: ${PHOTOPRISM_SITE_URL:-https://photo.sm-home.cloud/} - PHOTOPRISM_ORIGINALS_PATH: /photoprism/originals - PHOTOPRISM_STORAGE_PATH: /photoprism/storage - PHOTOPRISM_READONLY: ${PHOTOPRISM_READONLY:-true} - PHOTOPRISM_DATABASE_DRIVER: mysql - PHOTOPRISM_DATABASE_SERVER: photoprism-db:3306 - PHOTOPRISM_DATABASE_NAME: ${PHOTOPRISM_DATABASE_NAME:-photoprism} - PHOTOPRISM_DATABASE_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism} - PHOTOPRISM_DATABASE_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism} - restart: unless-stopped - networks: - - default - - work-backend - - photoprism-db: - image: mariadb:11 - container_name: photoprism-db - logging: - driver: json-file - options: - max-size: "200m" - max-file: "2" - command: --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - environment: - MARIADB_AUTO_UPGRADE: "1" - MARIADB_DATABASE: ${PHOTOPRISM_DATABASE_NAME:-photoprism} - MARIADB_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism} - MARIADB_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism} - MARIADB_ROOT_PASSWORD: ${PHOTOPRISM_DATABASE_ROOT_PASSWORD:-photoprism-root} - volumes: - - photoprism-db:/var/lib/mysql - restart: unless-stopped - networks: - - default - - work-backend - prod-app: image: node:${NODE_VERSION:-22.22.2}-bookworm container_name: ai-code-app-prod @@ -125,12 +66,12 @@ services: user: "0:0" cpus: 1.0 mem_limit: 1536m - working_dir: /release-app + working_dir: /workspace/auto_codex/repo ports: - '127.0.0.1:5175:5173' volumes: - - ${RELEASE_APP_SOURCE:-.}:/release-app - - ./.docker/release-app/node_modules:/release-app/node_modules + - ./.auto_codex/repo:/workspace/auto_codex/repo + - ./.docker/release-app/node_modules:/workspace/auto_codex/repo/node_modules - ./.docker/release-app/home:/home/how2ice networks: - default @@ -138,6 +79,9 @@ services: environment: HOME: /home/how2ice NPM_CONFIG_CACHE: /home/how2ice/.npm + VITE_PUBLIC_HMR_HOST: rel.sm-home.cloud + VITE_PUBLIC_HMR_PROTOCOL: wss + VITE_PUBLIC_HMR_CLIENT_PORT: 443 command: > sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173" @@ -148,5 +92,3 @@ networks: volumes: app-node-modules: app-home: - photoprism-storage: - photoprism-db: diff --git a/docs/README.md b/docs/README.md index 8f9a3c2..fd3b819 100755 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ - 현재 저장소는 당분간 로컬 전용으로 운영합니다. - 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다. - `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다. -- 자동화 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름으로 처리합니다. +- 자동화 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다. - Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다. ## 1. 작업일지 diff --git a/docs/features/plan-automation.md b/docs/features/plan-automation.md index df46007..c21c6b5 100755 --- a/docs/features/plan-automation.md +++ b/docs/features/plan-automation.md @@ -4,7 +4,7 @@ Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다. -현재 운영 규칙에서 자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치에서 시작하며, `release` 반영과 `main` 일괄반영, 프로젝트 루트 `pull --ff-only`까지 이어집니다. 또한 새 자동화 메모 작업을 시작할 때 현재 작업중인 자동화가 없다면, 자동화 작업공간의 `release`를 먼저 `main` 기준 최신 상태로 맞춘 뒤 다음 작업을 진행합니다. 반면 `Codex Live`나 일반 수동 요청은 여전히 로컬 `main` 직접 수정 기준을 유지합니다. +현재 문서는 자동화 브랜치 전략 자체를 고정 규칙으로 설명하지 않습니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다. ## 구현 위치 @@ -116,9 +116,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수 Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다. -자동화 메모 작업을 시작하는 시점에 진행 중인 다른 자동화가 없다면, worker는 먼저 자동화 작업공간의 `release`를 `main`과 맞춰 기준 브랜치를 정리한 뒤 새 `feature/*` 작업을 시작해야 합니다. 이 단계는 유휴 상태에서만 수행하고, 이미 다른 자동화가 돌고 있는 동안에는 중간 기준 브랜치를 임의로 재정렬하지 않습니다. - -자동화 작업메모가 `main` 반영 단계까지 끝나면 worker는 `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`를 수행해 실제 작업본을 최신 `main`으로 맞춥니다. +자동화 작업의 Git 절차나 동기화 순서는 이 문서에 고정 규칙으로 적지 않습니다. 필요 시 실제 worker 구현과 현재 설정값을 함께 확인합니다. ## 차트 집계 방식 diff --git a/docs/features/plan-board-review.md b/docs/features/plan-board-review.md index b9ce7b7..0c1eba8 100755 --- a/docs/features/plan-board-review.md +++ b/docs/features/plan-board-review.md @@ -49,7 +49,7 @@ 2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다. 3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다. 4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다. -5. main 반영이 끝나면 프로젝트 루트 `pull --ff-only`까지 수행한 뒤 최종 완료 흐름으로 정리됩니다. +5. 최종 완료 처리는 현재 worker 상태와 운영 설정을 기준으로 정리됩니다. ## 목록 기능 @@ -88,7 +88,7 @@ - `작업취소` - `main 일괄 반영 요청` -자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치로 시작하며, `release` 반영과 `main` 일괄 반영 뒤 프로젝트 루트 동기화까지 포함합니다. +자동화 작업메모(`auto_worker`)의 Git 절차는 이 문서에서 고정 규칙으로 설명하지 않습니다. 세부 흐름은 실제 worker 구현과 현재 운영 설정을 확인합니다. 세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다. diff --git a/etc/servers/work-server/README.md b/etc/servers/work-server/README.md index 52bf80a..9501d5b 100644 --- a/etc/servers/work-server/README.md +++ b/etc/servers/work-server/README.md @@ -57,15 +57,7 @@ npm run server-command:runner 소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다. -현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. - -단, 자동화 작업메모(`auto_worker`)는 예외적으로 아래 Git 흐름을 기본 동작으로 사용합니다. - -- 신규 `feature/*` 브랜치 생성 -- 자동 작업 수행 -- `release` 브랜치 반영 -- `main` 일괄반영 -- `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only` +현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다. 브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다. @@ -77,15 +69,7 @@ npm run server-command:runner `Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다. -현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다. - -- `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성 -- 성공 시: `작업중`, `브랜치준비` -- 실패 시: `이슈`, 최근 오류 기록 -- `개발완료` 상태: worker가 `release` 브랜치 병합 시도 -- 병합 성공 시: `완료` -- 병합 실패 시: `이슈` -- `main` 반영 성공 시: `PLAN_MAIN_PROJECT_REPO_PATH`에서 `main` 브랜치 `pull --ff-only`까지 수행 +현재 운영 기준에서 자동화 작업메모의 세부 Git 절차는 이 문서에 고정하지 않습니다. 상태 전이와 실제 처리 흐름은 worker 구현, 환경 변수, 현재 운영 정책을 함께 확인해야 합니다. 안전 조건: diff --git a/etc/servers/work-server/docker-compose.yml b/etc/servers/work-server/docker-compose.yml index b54f494..424f1c7 100644 --- a/etc/servers/work-server/docker-compose.yml +++ b/etc/servers/work-server/docker-compose.yml @@ -12,7 +12,6 @@ services: user: "0:0" group_add: - "${SERVER_COMMAND_DOCKER_GID:-984}" - cpus: 1.5 mem_limit: 2048m working_dir: /app env_file: diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index 0a302d0..609a8d4 100755 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js'; +import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js'; export async function registerAppConfigRoutes(app: FastifyInstance) { app.get('/api/app-config', async () => { @@ -21,6 +22,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { }; }); + app.get('/api/automation-types', async () => { + const automationTypes = await getAutomationTypesConfig(); + + return { + ok: true, + automationTypes, + }; + }); + app.put('/api/chat-types', async (request, reply) => { try { let payload: unknown = request.body ?? {}; @@ -50,6 +60,35 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { } }); + app.put('/api/automation-types', async (request, reply) => { + try { + let payload: unknown = request.body ?? {}; + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + + const parsed = z.object({ + automationTypes: z.array(z.unknown()), + }).parse(payload ?? {}); + + const savedAutomationTypes = await upsertAutomationTypesConfig(parsed.automationTypes); + + return { + ok: true, + automationTypes: savedAutomationTypes, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.', + }); + } + }); + app.put('/api/app-config', async (request, reply) => { try { let payload: unknown = request.body ?? {}; diff --git a/etc/servers/work-server/src/server.ts b/etc/servers/work-server/src/server.ts index 51b8ebe..949de0e 100755 --- a/etc/servers/work-server/src/server.ts +++ b/etc/servers/work-server/src/server.ts @@ -9,6 +9,8 @@ import { PlanWorker } from './workers/plan-worker.js'; const app = createApp(); const planWorker = new PlanWorker(app.log); const chatService = new ChatService(app.log); +const startedAt = Date.now(); +let shutdownPromise: Promise | null = null; app.server.on('upgrade', chatService.attachUpgradeHandler()); async function start() { @@ -27,14 +29,32 @@ async function start() { } async function shutdown(signal: string) { - app.log.info(`Received ${signal}, closing server`); + if (shutdownPromise) { + return shutdownPromise; + } - await planWorker.stop(); - chatService.close(); - await app.close(); - await shutdownNotificationProvider(); - await db.destroy(); - process.exit(0); + shutdownPromise = (async () => { + app.log.warn({ + signal, + pid: process.pid, + uptimeSeconds: Math.round((Date.now() - startedAt) / 1000), + rssBytes: process.memoryUsage().rss, + }, 'Received shutdown signal'); + + try { + await planWorker.stop(); + chatService.close(); + await app.close(); + await shutdownNotificationProvider(); + await db.destroy(); + process.exitCode = 0; + } catch (error) { + app.log.error({ error, signal }, 'Failed to shut down cleanly'); + process.exitCode = 1; + } + })(); + + return shutdownPromise; } process.on('SIGINT', () => { diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index 28cd6bf..81b4057 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -3,6 +3,37 @@ import { db } from '../db/client.js'; export const APP_CONFIG_TABLE = 'app_configs'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; +type ChatPermissionRole = 'guest' | 'token-user'; + +type ChatTypeRecord = { + id: string; + name: string; + description: string; + permissions: ChatPermissionRole[]; + enabled: boolean; + updatedAt: string; +}; + +const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ + { + id: 'general-request', + name: '일반 요청', + description: + '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, + { + id: 'api-request-template', + name: 'API요청', + description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, +]; + async function ensureAppConfigTable() { const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE); @@ -58,6 +89,134 @@ function normalizeConfigRecord(value: unknown) { return value as Record; } +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; + 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) { + 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(); + const bySemanticKey = new Map(); + + for (const item of normalized) { + const current = byId.get(item.id); + + if (!current || compareUpdatedAt(current, item) <= 0) { + byId.set(item.id, item); + } + } + + for (const item of byId.values()) { + const semanticKey = buildChatTypeSemanticKey(item); + const current = bySemanticKey.get(semanticKey); + + if (!current || compareUpdatedAt(current, item) <= 0) { + bySemanticKey.set(semanticKey, item); + } + } + + return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); +} + +function mergeDefaultChatTypes(items: unknown[]) { + const savedItems = sanitizeChatTypes(items); + const byId = new Map(savedItems.map((item) => [item.id, item] as const)); + + for (const defaultItem of DEFAULT_CHAT_TYPES) { + const existingItem = byId.get(defaultItem.id); + + if (!existingItem) { + byId.set(defaultItem.id, defaultItem); + continue; + } + + byId.set(defaultItem.id, { + ...existingItem, + name: defaultItem.name, + description: defaultItem.description, + permissions: defaultItem.permissions, + }); + } + + return sanitizeChatTypes(Array.from(byId.values())); +} + +function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) { + if (left.length !== right.length) { + return false; + } + + return left.every((item, index) => { + const target = right[index]; + return ( + target && + item.id === target.id && + item.name === target.name && + item.description === target.description && + item.enabled === target.enabled && + item.updatedAt === target.updatedAt && + item.permissions.length === target.permissions.length && + item.permissions.every((permission, permissionIndex) => permission === target.permissions[permissionIndex]) + ); + }); +} + export type AppConfigSnapshot = { chat?: { maxContextMessages?: number; @@ -149,16 +308,30 @@ export async function getChatTypesConfig() { const config = await getAppConfig(); const normalized = normalizeConfigRecord(config); const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY]; - return Array.isArray(chatTypes) ? chatTypes : null; + if (chatTypes == null) { + return null; + } + + const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : []; + const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes); + + if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) { + await upsertAppConfig({ + [CHAT_TYPES_CONFIG_KEY]: mergedChatTypes, + }); + } + + return mergedChatTypes; } export async function upsertChatTypesConfig(chatTypes: unknown[]) { const current = normalizeConfigRecord(await getAppConfig()); + const resolvedChatTypes = mergeDefaultChatTypes(chatTypes); const nextConfig = { ...current, - [CHAT_TYPES_CONFIG_KEY]: Array.isArray(chatTypes) ? chatTypes : [], + [CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes, }; await upsertAppConfig(nextConfig); - return nextConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]; + return resolvedChatTypes; } diff --git a/etc/servers/work-server/src/services/automation-type-config-service.ts b/etc/servers/work-server/src/services/automation-type-config-service.ts new file mode 100644 index 0000000..95d5603 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-type-config-service.ts @@ -0,0 +1,389 @@ +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: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.', + 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 | 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(); + const bySemanticKey = new Map(); + + 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[] | 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; + } + + return value as Record; +} + +function toAutomationTypeRecord(row: Record) { + 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[]), + ); + 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)) + .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[]) : []), + ); + 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) { + const automationTypeId = normalizeText(row.automation_type_id); + + if (automationTypeId) { + return normalizeLegacyAutomationBehaviorType(automationTypeId); + } + + return normalizeLegacyAutomationBehaviorType(row.automation_type) || 'none'; +} diff --git a/etc/servers/work-server/src/services/board-service.test.ts b/etc/servers/work-server/src/services/board-service.test.ts index 1f35d0e..827d502 100644 --- a/etc/servers/work-server/src/services/board-service.test.ts +++ b/etc/servers/work-server/src/services/board-service.test.ts @@ -4,12 +4,19 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board- test('buildBoardPostPlanNote formats automation work memo with clear sections', () => { assert.equal( - buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n'), + buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', { + name: '자동화 메모', + description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.', + }), [ '# 자동화 작업메모', '', '- 게시판 제목: 알림 개선', '- 메모 출처: board_posts 자동화 접수', + '- 선택 자동화 유형: 자동화 메모', + '', + '## 선택된 유형 context', + '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.', '', '## 요청 본문', '본문 첫 줄\n본문 둘째 줄', @@ -17,6 +24,28 @@ 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', + '선택된 자동화 유형 context 없음', + '', + '## 요청 본문', + '본문', + ].join('\n'), + ); +}); + test('BoardPostAutomationLockedError keeps user-facing message by action', () => { assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.'); diff --git a/etc/servers/work-server/src/services/board-service.ts b/etc/servers/work-server/src/services/board-service.ts index 07d58be..aef8fd6 100755 --- a/etc/servers/work-server/src/services/board-service.ts +++ b/etc/servers/work-server/src/services/board-service.ts @@ -6,6 +6,11 @@ import { PLAN_TABLE, planAutomationTypeSchema, } from './plan-service.js'; +import { + resolveAutomationType, + resolveStoredAutomationTypeId, + type AutomationTypeRecord, +} from './automation-type-config-service.js'; export const BOARD_POSTS_TABLE = 'board_posts'; @@ -58,7 +63,7 @@ function mapBoardPostRow(row: Record): BoardPostItem { title: String(row.title ?? ''), content, preview: createPreview(content), - automationType: normalizePlanAutomationType(row.automation_type), + automationType: resolveStoredAutomationTypeId(row), automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined ? null : Number(row.automation_plan_item_id), @@ -74,19 +79,27 @@ function isBoardPostAutomationLocked(row: Record) { 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 | null) { const normalizedTitle = title.trim(); const normalizedContent = content.trim(); + const normalizedAutomationTypeName = String(automationType?.name ?? '').trim(); + const normalizedAutomationContext = String(automationType?.description ?? '').trim(); return [ '# 자동화 작업메모', '', `- 게시판 제목: ${normalizedTitle}`, '- 메모 출처: board_posts 자동화 접수', + normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null, + '', + '## 선택된 유형 context', + normalizedAutomationContext || '선택된 자동화 유형 context 없음', '', '## 요청 본문', normalizedContent, - ].join('\n'); + ] + .filter((line): line is string => line !== null) + .join('\n'); } function resolveInsertedId(result: unknown): number | null { @@ -159,6 +172,7 @@ export async function ensureBoardPostsTable() { ['title', (table) => table.string('title', 200).notNullable().defaultTo('제목 없음')], ['content', (table) => table.text('content').notNullable().defaultTo('')], ['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')], + ['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()], ['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()], ['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], @@ -187,6 +201,11 @@ export async function ensureBoardPostsTable() { await db(BOARD_POSTS_TABLE) .where({ automation_type: 'general_development' }) .update({ automation_type: 'auto_worker' }); + await db(BOARD_POSTS_TABLE) + .whereNull('automation_type_id') + .update({ + automation_type_id: db.raw('automation_type'), + }); } export async function listBoardPosts() { @@ -206,10 +225,12 @@ export async function getBoardPost(id: number) { export async function createBoardPost(payload: z.infer) { await ensureBoardPostsTable(); const parsedPayload = boardPostPayloadSchema.parse(payload); + const automationType = await resolveAutomationType(parsedPayload.automationType); const insertQuery = db(BOARD_POSTS_TABLE).insert({ title: parsedPayload.title, content: parsedPayload.content, - automation_type: parsedPayload.automationType, + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, created_at: db.fn.now(), updated_at: db.fn.now(), }); @@ -255,10 +276,12 @@ export async function receiveBoardPostAutomation(id: number) { const title = String(currentRow.title ?? '').trim(); const content = String(currentRow.content ?? '').trim(); const workId = `board-post-${id}`; + const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type); const insertQuery = trx(PLAN_TABLE).insert({ work_id: workId, - note: buildBoardPostPlanNote(title, content), + note: buildBoardPostPlanNote(title, content, automationType), automation_type: normalizePlanAutomationType(currentRow.automation_type), + automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type, status: '등록', release_target: 'release', jangsing_processing_required: true, @@ -303,6 +326,7 @@ export async function receiveBoardPostAutomation(id: number) { export async function updateBoardPost(id: number, payload: z.infer) { await ensureBoardPostsTable(); const parsedPayload = boardPostPayloadSchema.parse(payload); + const automationType = await resolveAutomationType(parsedPayload.automationType); return db.transaction(async (trx) => { const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); @@ -319,7 +343,8 @@ export async function updateBoardPost(id: number, payload: z.infer { +test('shouldUseTemplateMacroReply is disabled for chat types', () => { assert.equal( shouldUseTemplateMacroReply( { @@ -49,13 +49,12 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope topMenu: 'chat', focusedComponentId: null, pageUrl: 'https://test.sm-home.cloud/chat/live', - chatTypeLabel: 'API 요청 템플릿', - chatTypeDescription: 'API 요청 본문을 정리하는 템플릿', - chatTypeIsTemplate: true, + chatTypeLabel: 'API요청', + chatTypeDescription: 'API 요청 본문을 정리합니다.', }, '이 템플릿 예시 보여줘', ), - true, + false, ); assert.equal( @@ -66,9 +65,8 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope topMenu: 'chat', focusedComponentId: null, pageUrl: 'https://test.sm-home.cloud/chat/live', - chatTypeLabel: 'API 요청 템플릿', - chatTypeDescription: 'API 요청 본문을 정리하는 템플릿', - chatTypeIsTemplate: true, + chatTypeLabel: 'API요청', + chatTypeDescription: 'API 요청 본문을 정리합니다.', }, '아이패드 말풍선 폰트 조금 줄여줘', ), @@ -85,7 +83,6 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope pageUrl: 'https://test.sm-home.cloud/chat/live', chatTypeLabel: '일반 요청', chatTypeDescription: '일반 요청', - chatTypeIsTemplate: false, }, '템플릿 예시 보여줘', ), diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 5db5a79..4371617 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -60,7 +60,6 @@ type ChatContext = { chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; - chatTypeIsTemplate?: boolean; }; type ChatInboundMessage = @@ -89,7 +88,6 @@ type ChatInboundMessage = chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; - chatTypeIsTemplate?: boolean; }; } | { @@ -875,45 +873,10 @@ export function shouldUseAgenticCodexReply(input: string) { ); } -function extractTemplateKeywords(context: ChatContext | null) { - const raw = `${context?.chatTypeLabel ?? ''} ${context?.chatTypeDescription ?? ''}`; - - return Array.from( - new Set( - raw - .split(/[^0-9A-Za-z가-힣]+/) - .map((keyword) => keyword.trim()) - .filter((keyword) => keyword.length >= 2) - .filter( - (keyword) => - !['템플릿', 'template', 'chat', 'codex', 'live', '요청', '일반', '기본', '유형'].includes(keyword.toLowerCase()), - ), - ), - ); -} - export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) { - if (context?.chatTypeIsTemplate !== true) { - return false; - } - - const normalized = input.toLowerCase(); - - if ( - input.includes('템플릿') || - input.includes('양식') || - input.includes('포맷') || - input.includes('형식') || - input.includes('예시') || - input.includes('샘플') || - normalized.includes('template') || - normalized.includes('format') || - normalized.includes('sample') - ) { - return true; - } - - return extractTemplateKeywords(context).some((keyword) => normalized.includes(keyword.toLowerCase())); + void context; + void input; + return false; } function summarizeCodexOutput(output: string) { @@ -1466,7 +1429,6 @@ function buildAgenticCodexPrompt( const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`; const recentHistoryLines = promptContext?.recentHistoryLines ?? []; const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0); - const isTemplateRequest = context?.chatTypeIsTemplate === true; return [ '당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.', @@ -1477,7 +1439,7 @@ function buildAgenticCodexPrompt( '- 필요 시 DB 직접 조회', '- 필요 시 로컬 API 응답 확인', '- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정', - '- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md의 브랜치 전략을 먼저 확인하고 그 규칙 안에서만 작업하세요.', + '- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.', `- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`, `- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`, '- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.', @@ -1494,7 +1456,6 @@ function buildAgenticCodexPrompt( '채팅 유형 문맥(우선 적용):', `- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`, `- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`, - `- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`, '- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.', '', '참고 화면 정보:', @@ -1503,20 +1464,15 @@ function buildAgenticCodexPrompt( `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`, `- pageUrl: ${context?.pageUrl ?? '없음'}`, '', - isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:', - ...(isTemplateRequest + '최근 대화 문맥:', + ...(recentHistoryLines.length > 0 ? [ - '- 이 요청은 템플릿 유형입니다.', - '- 이전 채팅방 내용은 참조하지 말고, 현재 화면 문맥/유형 설명/사용자 요청만 기준으로 처리하세요.', + ...recentHistoryLines.map((line) => `- ${line}`), + ...(omittedHistoryCount > 0 + ? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`] + : []), ] - : recentHistoryLines.length > 0 - ? [ - ...recentHistoryLines.map((line) => `- ${line}`), - ...(omittedHistoryCount > 0 - ? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`] - : []), - ] - : ['- 참조할 최근 대화가 없습니다.']), + : ['- 참조할 최근 대화가 없습니다.']), '', '사용자 요청:', input, @@ -1669,13 +1625,10 @@ async function runAgenticCodexReply( const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); const appConfig = await getAppConfigSnapshot(); - const recentHistory = - context?.chatTypeIsTemplate === true - ? { items: [] as string[], omittedCount: 0 } - : await buildRecentChatPromptHistory(sessionId, requestId, { - maxMessages: appConfig.chat?.maxContextMessages, - maxChars: appConfig.chat?.maxContextChars, - }); + const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, { + maxMessages: appConfig.chat?.maxContextMessages, + maxChars: appConfig.chat?.maxContextChars, + }); const prompt = buildAgenticCodexPrompt(context, input, sessionId, { recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, @@ -2980,7 +2933,6 @@ export class ChatService { chatTypeId: message.payload.chatTypeId ?? null, chatTypeLabel: message.payload.chatTypeLabel, chatTypeDescription: message.payload.chatTypeDescription, - chatTypeIsTemplate: message.payload.chatTypeIsTemplate, }, ).catch((error: unknown) => { this.logger.error(error, 'chat reply build failed'); diff --git a/etc/servers/work-server/src/services/plan-schedule-service.ts b/etc/servers/work-server/src/services/plan-schedule-service.ts index c90e591..6139063 100755 --- a/etc/servers/work-server/src/services/plan-schedule-service.ts +++ b/etc/servers/work-server/src/services/plan-schedule-service.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { db } from '../db/client.js'; +import { resolveAutomationType, resolveStoredAutomationTypeId } from './automation-type-config-service.js'; import { createCompletedPlanExecutionLogItem, createPlanActionHistory, @@ -186,7 +187,7 @@ export function mapPlanScheduledTaskRow(row: Record) { id: row.id, workId: row.work_id, note: row.note, - automationType: normalizePlanAutomationType(row.automation_type), + automationType: resolveStoredAutomationTypeId(row), releaseTarget: row.release_target, jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), @@ -229,6 +230,7 @@ export async function ensurePlanScheduledTaskTable() { table.string('work_id', 120).notNullable().defaultTo('반복작업'); table.text('note').notNullable().defaultTo(''); table.string('automation_type', 40).notNullable().defaultTo('none'); + table.string('automation_type_id', 120).nullable(); table.string('release_target', 120).notNullable().defaultTo('release'); table.boolean('jangsing_processing_required').notNullable().defaultTo(true); table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); @@ -252,6 +254,9 @@ export async function ensurePlanScheduledTaskTable() { await ensurePlanScheduledTaskColumn('automation_type', (table) => { table.string('automation_type', 40).notNullable().defaultTo('none'); }); + await ensurePlanScheduledTaskColumn('automation_type_id', (table) => { + table.string('automation_type_id', 120).nullable(); + }); await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => { table.boolean('jangsing_processing_required').notNullable().defaultTo(true); }); @@ -295,6 +300,11 @@ export async function ensurePlanScheduledTaskTable() { await db(PLAN_SCHEDULED_TASK_TABLE) .where({ automation_type: 'general_development' }) .update({ automation_type: 'auto_worker' }); + await db(PLAN_SCHEDULED_TASK_TABLE) + .whereNull('automation_type_id') + .update({ + automation_type_id: db.raw('automation_type'), + }); await db(PLAN_SCHEDULED_TASK_TABLE) .where({ repeat_interval_unit: 'minute' }) @@ -320,12 +330,14 @@ export async function createPlanScheduledTask(payload: z.infer, now: D const executionLog = await createCompletedPlanExecutionLogItem({ workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now), note: executionLogNoteLines.join('\n'), - automationType: 'plan', + automationType: String(row.automation_type_id ?? row.automation_type ?? 'plan'), releaseTarget: String(row.release_target ?? 'release'), jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), @@ -500,7 +516,7 @@ async function registerPlanScheduledTaskRow(row: Record, now: D const createdPlan = await createPlanItem({ workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now), note: String(row.note ?? ''), - automationType: normalizePlanAutomationType(row.automation_type), + automationType: String(row.automation_type_id ?? row.automation_type ?? 'none'), releaseTarget: String(row.release_target ?? 'release'), jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts index 2f7df2c..b7ec80b 100755 --- a/etc/servers/work-server/src/services/plan-service.ts +++ b/etc/servers/work-server/src/services/plan-service.ts @@ -1,6 +1,12 @@ import { z } from 'zod'; import { getEnv } from '../config/env.js'; import { db } from '../db/client.js'; +import { + normalizeLegacyAutomationBehaviorType, + resolveAutomationType, + resolveStoredAutomationTypeId, + type AutomationBehaviorType, +} from './automation-type-config-service.js'; import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js'; export const PLAN_TABLE = 'plan_items'; @@ -53,6 +59,7 @@ type PlanRowOptions = { maskNote?: boolean; noteMasked?: boolean; releaseReviewNote?: string; + exposeConfiguredAutomationType?: boolean; }; export const statusSchema = z.enum(planStatuses); @@ -62,32 +69,15 @@ export const setupSchema = z.object({ }); function resolvePlanAutomationTypeAlias(value: unknown) { - if (typeof value !== 'string') { - return value; - } - - const normalizedValue = value.trim(); - - if (normalizedValue === 'plan_registration') { - return 'plan'; - } - - if (normalizedValue === 'general_development') { - return 'auto_worker'; - } - - return normalizedValue; + return normalizeLegacyAutomationBehaviorType(value); } -export const planAutomationTypeSchema = z.preprocess( - resolvePlanAutomationTypeAlias, - z.enum(planAutomationTypes), -); +export const planAutomationTypeSchema = z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120)); export const createPlanSchema = z.object({ workId: z.string().trim().optional().default('작업ID'), note: z.string().default(''), - automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).default('none')), + automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')), releaseTarget: z.string().trim().min(1).default('release'), jangsingProcessingRequired: z.boolean().default(true), autoDeployToMain: z.boolean().default(true), @@ -98,7 +88,7 @@ export const createPlanSchema = z.object({ export const updatePlanSchema = z.object({ workId: z.string().trim().optional(), note: z.string().optional(), - automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).optional()), + automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()), releaseTarget: z.string().trim().min(1).optional(), jangsingProcessingRequired: z.boolean().optional(), autoDeployToMain: z.boolean().optional(), @@ -135,7 +125,7 @@ export const listPlanQuerySchema = z.object({ }); export type PlanStatus = (typeof planStatuses)[number]; -export type PlanAutomationType = (typeof planAutomationTypes)[number]; +export type PlanAutomationType = string; export type PlanWorkerStatus = (typeof planWorkerStatuses)[number]; export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number]; @@ -191,8 +181,8 @@ function normalizePlanWorkId(value?: string | null) { export function normalizePlanAutomationType(value: unknown): PlanAutomationType { const normalizedValue = resolvePlanAutomationTypeAlias(value); - return planAutomationTypes.includes(normalizedValue as PlanAutomationType) - ? (normalizedValue as PlanAutomationType) + return planAutomationTypes.includes(normalizedValue as AutomationBehaviorType) + ? (normalizedValue as AutomationBehaviorType) : 'none'; } @@ -281,7 +271,8 @@ export function mapPlanRow( id: row.id, workId: row.work_id, note: options?.maskNote ? maskPlanNote(row.note) : row.note, - automationType: normalizePlanAutomationType(row.automation_type), + automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type), + automationBehaviorType: normalizePlanAutomationType(row.automation_type), releaseReviewNote: options?.releaseReviewNote ?? '', noteMasked: Boolean(options?.noteMasked), status: row.status, @@ -825,6 +816,9 @@ async function syncPlanColumns() { await ensureColumn('automation_type', (table) => { table.string('automation_type', 40).notNullable().defaultTo('none'); }); + await ensureColumn('automation_type_id', (table) => { + table.string('automation_type_id', 120).nullable(); + }); await ensureColumn('auto_deploy_to_main', (table) => { table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); }); @@ -871,6 +865,11 @@ async function syncPlanColumns() { await db(PLAN_TABLE) .where({ automation_type: 'general_development' }) .update({ automation_type: 'auto_worker' }); + await db(PLAN_TABLE) + .whereNull('automation_type_id') + .update({ + automation_type_id: db.raw('automation_type'), + }); } async function dropPlanWorkIdUniqueConstraint() { @@ -1090,13 +1089,15 @@ export async function ensurePlanTable() { export async function createPlanItem(payload: z.infer) { await ensurePlanTable(); const workId = normalizePlanWorkId(payload.workId); + const automationType = await resolveAutomationType(payload.automationType); const rows = await db(PLAN_TABLE) .insert({ work_id: workId, note: payload.note, status: '등록', - automation_type: normalizePlanAutomationType(payload.automationType), + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, release_target: payload.releaseTarget, jangsing_processing_required: payload.jangsingProcessingRequired, auto_deploy_to_main: payload.autoDeployToMain, @@ -1114,13 +1115,15 @@ export async function createPlanItem(payload: z.infer) export async function createCompletedPlanExecutionLogItem(payload: z.infer) { await ensurePlanTable(); const workId = normalizePlanWorkId(payload.workId); + const automationType = await resolveAutomationType(payload.automationType); const rows = await db(PLAN_TABLE) .insert({ work_id: workId, note: payload.note, status: '완료', - automation_type: normalizePlanAutomationType(payload.automationType), + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, release_target: payload.releaseTarget, jangsing_processing_required: payload.jangsingProcessingRequired, auto_deploy_to_main: payload.autoDeployToMain, @@ -1159,11 +1162,12 @@ export async function upsertAutoPlanItem(args: { .first(); if (!existingRow) { + const automationType = await resolveAutomationType(args.automationType); return { row: await createPlanItem({ workId, note: args.note, - automationType: normalizePlanAutomationType(args.automationType), + automationType: automationType.id, releaseTarget: args.releaseTarget, jangsingProcessingRequired: args.jangsingProcessingRequired, autoDeployToMain: args.autoDeployToMain, @@ -1175,7 +1179,7 @@ export async function upsertAutoPlanItem(args: { } const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release'; - const nextAutomationType = normalizePlanAutomationType(args.automationType ?? existingRow.automation_type); + const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type); const nextJangsingProcessingRequired = args.jangsingProcessingRequired; const nextAutoDeployToMain = args.autoDeployToMain; const nextNote = args.note; @@ -1185,7 +1189,7 @@ export async function upsertAutoPlanItem(args: { : existingRow.normal_processing_level === '상'; const hasPayloadChange = existingRow.note !== nextNote || - normalizePlanAutomationType(existingRow.automation_type) !== nextAutomationType || + String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id || (existingRow.release_target ?? 'release') !== nextReleaseTarget || Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain || Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired; @@ -1202,7 +1206,8 @@ export async function upsertAutoPlanItem(args: { .where({ id: existingRow.id }) .update({ note: nextNote, - automation_type: nextAutomationType, + automation_type: nextAutomationType.behaviorType, + automation_type_id: nextAutomationType.id, release_target: nextReleaseTarget, jangsing_processing_required: nextJangsingProcessingRequired, auto_deploy_to_main: nextAutoDeployToMain, @@ -1223,7 +1228,8 @@ export async function upsertAutoPlanItem(args: { note: nextNote, status: '등록', assigned_branch: null, - automation_type: nextAutomationType, + automation_type: nextAutomationType.behaviorType, + automation_type_id: nextAutomationType.id, release_target: nextReleaseTarget, jangsing_processing_required: nextJangsingProcessingRequired, auto_deploy_to_main: nextAutoDeployToMain, @@ -1262,7 +1268,9 @@ export async function updatePlanItem(id: number, payload: z.infer(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(); + 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 ( + + + + ); + } + + return ( +
+ {detailMode === 'list' ? ( + } onClick={openCreateForm}> + 신규 유형 + + } + > +
+ {errorMessage ? : null} + {saveErrorMessage ? : null} +
+ 등록 자동화 유형 + {isLoading ? '불러오는 중' : `${automationTypes.length}건`} +
+ + {automationTypes.length > 0 ? ( + { + const itemClassName = + item.id === selectedAutomationTypeId + ? 'chat-type-management-page__item chat-type-management-page__item--active' + : 'chat-type-management-page__item'; + + return ( + { + openDetail(item.id); + }} + actions={[ +
+
+ ) : ( + +
+ {errorMessage ? : null} + {saveErrorMessage ? : null} +
+ {isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'} +
+ +
{ + 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); + } + }} + > + +
+ + + + + + +
+
+ + 설명 + +
+
+ {isMobileViewport ? ( + { + setMobileView(value as 'edit' | 'preview'); + setMaximizedPane('none'); + }} + /> + ) : ( + + + + + )} +
+
+
+
+ 입력 + {isMobileViewport ? ( + + ) : null} +
+ + + +
+
+
+
+ 미리보기 + {isMobileViewport ? ( + + ) : null} +
+
+ prev.description !== next.description}> + {({ getFieldValue }) => { + const description = String(getFieldValue('description') ?? '').trim(); + + return description ? ( + + ) : ( + + ); + }} + +
+
+
+
+
+
+
+ + + + + + {!isCreating && selectedAutomationType ? ( + + ) : null} + + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/main/ChatRuntimeBridgeV2.tsx b/src/app/main/ChatRuntimeBridgeV2.tsx index 5dd2823..603dfbe 100644 --- a/src/app/main/ChatRuntimeBridgeV2.tsx +++ b/src/app/main/ChatRuntimeBridgeV2.tsx @@ -33,7 +33,6 @@ export function ChatRuntimeBridgeV2() { chatTypeId: null, chatTypeLabel: '', chatTypeDescription: '', - chatTypeIsTemplate: false, }), [currentPage, focusedComponentId], ); diff --git a/src/app/main/ChatTypeManagementPage.css b/src/app/main/ChatTypeManagementPage.css index 28c2085..83ae67d 100755 --- a/src/app/main/ChatTypeManagementPage.css +++ b/src/app/main/ChatTypeManagementPage.css @@ -1,27 +1,79 @@ .chat-type-management-page { + width: 100%; height: 100%; + min-height: 0; + overflow: hidden; +} + +.chat-type-management-page--detail { + container-type: inline-size; } .chat-type-management-page .ant-card, .chat-type-management-page .ant-card-body, .chat-type-management-page__card { + width: 100%; height: 100%; + min-height: 0; +} + +.chat-type-management-page .ant-card-head { + min-height: 52px; + padding: 0 14px; +} + +.chat-type-management-page .ant-card-head-title, +.chat-type-management-page .ant-card-extra { + padding: 10px 0; +} + +.chat-type-management-page .ant-card-body { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 12px 14px; } .chat-type-management-page__list, .chat-type-management-page__editor { + width: 100%; min-height: 0; display: flex; flex-direction: column; - gap: 12px; + gap: 8px; height: 100%; + overflow: hidden; +} + +.chat-type-management-page__list .ant-list { + flex: 1; + min-height: 0; + overflow: auto; +} + +.chat-type-management-page__editor-form { + width: 100%; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; +} + +.chat-type-management-page__editor-form .ant-form-item { + margin-bottom: 8px; } .chat-type-management-page__list-header { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 8px; +} + +.chat-type-management-page__list-header .ant-typography { + margin-bottom: 0; } .chat-type-management-page__item { @@ -44,3 +96,246 @@ .chat-type-management-page__item-description.ant-typography { margin: 8px 0 10px; } + +.chat-type-management-page__item-description { + margin: 8px 0 10px; +} + +.chat-type-management-page__item-description .markdown-preview > :last-child { + margin-bottom: 0; +} + +.chat-type-management-page__markdown-field { + width: 100%; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; +} + +.chat-type-management-page__field-label { + flex: 0 0 auto; + line-height: 1.2; +} + +.chat-type-management-page__markdown-editor { + width: 100%; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; +} + +.chat-type-management-page__mobile-toggle { + display: none; +} + +.chat-type-management-page__editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.chat-type-management-page__markdown-grid { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 6px; + align-items: stretch; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.chat-type-management-page__markdown-grid--maximized { + grid-template-columns: minmax(0, 1fr); +} + +.chat-type-management-page__markdown-pane { + width: 100%; + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; +} + +.chat-type-management-page__markdown-pane .ant-form-item { + flex: 1; + min-height: 0; + margin-bottom: 0; +} + +.chat-type-management-page__markdown-pane--desktop-hidden { + display: none; +} + +.chat-type-management-page__markdown-pane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.chat-type-management-page__markdown-pane .ant-input-textarea, +.chat-type-management-page__markdown-pane .ant-input { + height: 100%; +} + +.chat-type-management-page__markdown-textarea { + height: 100% !important; + min-height: 180px; + resize: none; +} + +.chat-type-management-page__markdown-textarea textarea { + height: 100% !important; + min-height: 180px; + overflow: auto !important; + resize: none; +} + +.chat-type-management-page__markdown-preview { + width: 100%; + height: 100%; + min-height: 0; + border: 1px solid #f0f0f0; + border-radius: 12px; + background: #fafafa; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; +} + +.chat-type-management-page__markdown-preview-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child { + margin-bottom: 0; +} + +.chat-type-management-page__form-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 2px; +} + +.chat-type-management-page__form-actions--compact { + padding-top: 0; +} + +.chat-type-management-page__meta-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto; + gap: 8px 12px; + align-items: start; +} + +.chat-type-management-page__meta-grid--hidden { + display: none; +} + +.chat-type-management-page__meta-item { + min-width: 0; +} + +.chat-type-management-page__meta-item .ant-form-item-label { + padding-bottom: 4px; +} + +.chat-type-management-page__meta-item--enabled .ant-form-item-control-input { + min-height: 40px; +} + +.chat-type-management-page--pane-maximized .chat-type-management-page__editor, +.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form, +.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field, +.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor, +.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid { + flex: 1; +} + +.chat-type-management-page--pane-maximized .chat-type-management-page__field-label { + display: none; +} + +.chat-type-management-page__card--pane-maximized .ant-card-body { + padding-bottom: 10px; +} + +@media (max-width: 960px) { + .chat-type-management-page, + .chat-type-management-page .ant-card, + .chat-type-management-page .ant-card-body, + .chat-type-management-page__card, + .chat-type-management-page__list, + .chat-type-management-page__editor, + .chat-type-management-page__editor-form, + .chat-type-management-page__markdown-field, + .chat-type-management-page__markdown-editor, + .chat-type-management-page__markdown-grid, + .chat-type-management-page__markdown-pane, + .chat-type-management-page__markdown-preview { + min-height: 0; + } + + .chat-type-management-page__list-header { + align-items: flex-start; + } + + .chat-type-management-page .ant-card-head { + min-height: 48px; + padding: 0 10px; + } + + .chat-type-management-page .ant-card-head-title, + .chat-type-management-page .ant-card-extra, + .chat-type-management-page .ant-card-body { + padding: 10px; + } + + .chat-type-management-page__mobile-toggle { + display: inline-flex; + } + + .chat-type-management-page__editor-toolbar { + flex-wrap: wrap; + } + + .chat-type-management-page__meta-grid { + grid-template-columns: minmax(0, 1fr); + gap: 6px; + } + + .chat-type-management-page__markdown-grid { + grid-template-columns: minmax(0, 1fr); + } + + .chat-type-management-page__markdown-pane--mobile-hidden { + display: none; + } + + .chat-type-management-page__markdown-textarea, + .chat-type-management-page__markdown-textarea textarea, + .chat-type-management-page__markdown-preview-body { + min-height: 0; + } + + .chat-type-management-page__form-actions { + flex-wrap: wrap; + } +} diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index 574fbb0..8037fdb 100755 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -1,6 +1,14 @@ -import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd'; +import { + ArrowsAltOutlined, + ArrowLeftOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + ShrinkOutlined, +} from '@ant-design/icons'; +import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd'; import { useEffect, useMemo, useState } from 'react'; +import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { canUseChatType, CHAT_PERMISSION_ROLE_LABELS, @@ -14,13 +22,12 @@ import { import { useTokenAccess } from './tokenAccess'; import './ChatTypeManagementPage.css'; -const { Paragraph, Text, Title } = Typography; +const { Text, Title } = Typography; type ChatTypeFormValue = { id?: string; name: string; description: string; - isTemplate: boolean; permissions: ChatPermissionRole[]; enabled: boolean; }; @@ -28,7 +35,6 @@ type ChatTypeFormValue = { const EMPTY_FORM_VALUE: ChatTypeFormValue = { name: '', description: '', - isTemplate: false, permissions: ['token-user'], enabled: true, }; @@ -42,7 +48,6 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { id: chatType.id, name: chatType.name, description: chatType.description, - isTemplate: chatType.isTemplate, permissions: chatType.permissions, enabled: chatType.enabled, }; @@ -52,12 +57,16 @@ export function ChatTypeManagementPage() { const { hasAccess } = useTokenAccess(); const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry(); const [selectedChatTypeId, setSelectedChatTypeId] = useState(chatTypes[0]?.id ?? null); + const [isMobileViewport, setIsMobileViewport] = useState(false); + const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); + const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none'); const [isCreating, setIsCreating] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [form] = Form.useForm(); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); + const isPaneMaximized = maximizedPane !== 'none'; const selectedChatType = useMemo( () => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null, @@ -88,10 +97,32 @@ export function ChatTypeManagementPage() { setDetailMode('list'); }, [detailMode, isCreating, selectedChatType]); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const mediaQuery = window.matchMedia('(max-width: 960px)'); + const update = () => { + setIsMobileViewport(mediaQuery.matches); + if (!mediaQuery.matches) { + setMobileView('edit'); + } + }; + + update(); + mediaQuery.addEventListener('change', update); + + return () => { + mediaQuery.removeEventListener('change', update); + }; + }, []); + const openCreateForm = () => { setIsCreating(true); setSelectedChatTypeId(null); setDetailMode('detail'); + setMaximizedPane('none'); form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); }; @@ -100,11 +131,13 @@ export function ChatTypeManagementPage() { setIsCreating(false); setSelectedChatTypeId(chatTypeId); setDetailMode('detail'); + setMaximizedPane('none'); }; const closeDetail = () => { setIsCreating(false); setDetailMode('list'); + setMaximizedPane('none'); }; const handleDelete = async () => { @@ -148,7 +181,11 @@ export function ChatTypeManagementPage() { } return ( -
+
{detailMode === 'list' ? ( {item.name} {item.enabled ? '사용' : '중지'} - {item.isTemplate ? 템플릿 : null} {isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'} - - {item.description || '기본 문맥 설명 없음'} - +
+ {item.description ? ( + + ) : ( + '기본 문맥 설명 없음' + )} +
{item.permissions.map((permission) => ( {CHAT_PERMISSION_ROLE_LABELS[permission]} @@ -226,29 +266,17 @@ export function ChatTypeManagementPage() { ) : ( - {!isCreating && selectedChatType ? ( - - ) : null} - - - } + className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`} >
{errorMessage ? : null} {saveErrorMessage ? : null}
{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'} - 이름과 기본 문맥 설명, 권한 대상만 관리하면 채팅에 그대로 반영됩니다.
+
+ + 기본 문맥 설명 + +
+
+ {isMobileViewport ? ( + { + setMobileView(value as 'edit' | 'preview'); + setMaximizedPane('none'); + }} + /> + ) : ( + + + + + )} +
+
+
+
+ 입력 + {isMobileViewport ? ( + + ) : null} +
+ + + +
+
+
+
+ 미리보기 + {isMobileViewport ? ( + + ) : null} +
+
+ prev.description !== next.description}> + {({ getFieldValue }) => { + const description = String(getFieldValue('description') ?? '').trim(); + + return description ? ( + + ) : ( + + ); + }} + +
+
+
+
+
+
+
- - - - - - - - - - - - - - - - - - + + + + + + {!isCreating && selectedChatType ? ( + + ) : null} + + +
diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 6ecfac2..a63ba02 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -76,11 +76,10 @@ type ChatTypeOption = { value: string; label: string; description: string; - isTemplate: boolean; disabled?: boolean; }; -type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; +type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file'; type PreviewItem = { id: string; @@ -98,7 +97,6 @@ type PendingChatRequest = { chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; - chatTypeIsTemplate: boolean; retryCount: number; failed: boolean; }; @@ -109,7 +107,6 @@ type PendingContextConfirm = { chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; - chatTypeIsTemplate: boolean; includedContextCount: number; omittedContextCount: number; }; @@ -660,6 +657,21 @@ function normalizePreviewUrl(value: string) { return normalizeChatResourceUrl(value); } +function isPreviewRouteUrl(url: string) { + if (typeof window === 'undefined') { + return false; + } + + try { + const parsed = new URL(url, window.location.origin); + const pathname = parsed.pathname.toLowerCase(); + const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname); + return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol); + } catch { + return false; + } +} + function classifyPreviewKind(url: string): PreviewKind { const pathname = url.toLowerCase().split('?')[0] ?? ''; @@ -675,6 +687,10 @@ function classifyPreviewKind(url: string): PreviewKind { return 'markdown'; } + if (/\.(diff|patch)$/i.test(pathname)) { + return 'diff'; + } + if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { return 'code'; } @@ -687,6 +703,10 @@ function classifyPreviewKind(url: string): PreviewKind { return 'pdf'; } + if (isPreviewRouteUrl(url)) { + return 'document'; + } + return 'file'; } @@ -869,7 +889,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = value: item.id, label: item.name, description: item.description, - isTemplate: item.isTemplate, disabled: !isAllowed, }; }), @@ -959,7 +978,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = chatTypeId: selectedChatType?.id ?? null, chatTypeLabel: selectedChatType?.name ?? '', chatTypeDescription: selectedChatType?.description ?? '', - chatTypeIsTemplate: selectedChatType?.isTemplate ?? false, }; const { conversationItems, @@ -2124,7 +2142,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = chatTypeId: request.chatTypeId, chatTypeLabel: request.chatTypeLabel, chatTypeDescription: request.chatTypeDescription, - chatTypeIsTemplate: request.chatTypeIsTemplate, requestId: request.requestId, mode: request.mode, }, diff --git a/src/app/main/MainContent.tsx b/src/app/main/MainContent.tsx index b98ef79..c9cde9f 100755 --- a/src/app/main/MainContent.tsx +++ b/src/app/main/MainContent.tsx @@ -10,6 +10,7 @@ import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage import { useSearchLayer } from '../../layer'; import { useAppStore } from '../../store'; import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView'; +import { AutomationTypeManagementPage } from './AutomationTypeManagementPage'; import { ChatTypeManagementPage } from './ChatTypeManagementPage'; import { ChatSourceChangesPage } from './ChatSourceChangesPage'; import { MainChatPanel } from './MainChatPanel'; @@ -169,6 +170,10 @@ export function MainContent({ return ; } + if (selectionId === 'page:plans:automation-type') { + return ; + } + const planStatus = getPlanStatusFromWindowSelection(selectionId); if (planStatus) { diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index d33a46a..b38764d 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -15,7 +15,6 @@ import { Alert, Button, Checkbox, - Divider, Drawer, Dropdown, Grid, @@ -926,9 +925,7 @@ export function MainHeader({ const [prodServerStatus, setProdServerStatus] = useState(null); const [workServerStatus, setWorkServerStatus] = useState(null); const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); - const [serverRestartingKey, setServerRestartingKey] = useState< - 'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null - >(null); + const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null); const [serverRestartFeedback, setServerRestartFeedback] = useState(null); const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState(null); const { registeredToken, hasAccess } = useTokenAccess(); @@ -1698,50 +1695,6 @@ export function MainHeader({ }); }; - const handleRestartCommandRunner = async () => { - if (!hasAccess || serverRestartingKey) { - return; - } - - setServerRestartCopyFeedback(null); - setServerRestartFeedback(null); - setServerRestartingKey('command-runner'); - - try { - const result = await restartServerCommand('command-runner'); - setServerRestartFeedback({ - tone: 'success', - message: - result.restartState === 'accepted' - ? 'Command runner 배포 및 재기동 요청을 접수했습니다.' - : 'Command runner 배포 및 재기동을 완료했습니다.', - }); - } catch (error) { - setServerRestartFeedback({ - tone: 'error', - message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.', - }); - } finally { - setServerRestartingKey(null); - } - }; - - const handleConfirmRestartCommandRunner = () => { - if (!hasAccess || serverRestartingKey) { - return; - } - - modalApi.confirm({ - title: 'Command runner 배포 및 재기동', - content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?', - okText: '배포 및 재기동', - cancelText: '취소', - onOk: async () => { - await handleRestartCommandRunner(); - }, - }); - }; - const handleRestartBothServers = async () => { if (!hasAccess || serverRestartingKey) { return; @@ -3120,24 +3073,6 @@ export function MainHeader({ {activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null} {activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null} {activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null} - - - Command runner - - 별도 명시적 요청이 있을 때만 command runner 배포 및 재기동을 실행합니다. - - {renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)} - - ) : null} {activeSettingsModal === 'notification' ? ( diff --git a/src/app/main/MainLayout.css b/src/app/main/MainLayout.css index aa59a30..6e39c12 100755 --- a/src/app/main/MainLayout.css +++ b/src/app/main/MainLayout.css @@ -363,11 +363,15 @@ position: fixed; inset: 72px 0 0; z-index: 40; - width: 100% !important; - max-width: 100%; + width: 100vw !important; + min-width: 100vw !important; + max-width: 100vw; + flex: 0 0 100vw !important; height: calc(100vh - 72px); border-right: 0; background: rgba(255, 255, 255, 0.98); + transition: none !important; + overflow: hidden; } .app-sider--mobile-inline.ant-layout-sider { @@ -385,6 +389,9 @@ gap: 12px; height: 100%; padding: 12px 10px; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; } .app-sider__intro { diff --git a/src/app/main/automationTypeAccess.ts b/src/app/main/automationTypeAccess.ts new file mode 100644 index 0000000..bfa3cac --- /dev/null +++ b/src/app/main/automationTypeAccess.ts @@ -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 = { + 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) { + 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 | 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[]) { + const byId = new Map(); + const bySemanticKey = new Map(); + + 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(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; + } finally { + window.clearTimeout(timeoutId); + } +} + +async function requestAutomationTypes(init?: RequestInit) { + try { + return await requestOnce(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(FALLBACK_BASE_URL, init); + } +} + +async function loadAutomationTypesFromServer() { + const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial[] | 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[] }>({ + 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(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, + }; +} diff --git a/src/app/main/chatTypeAccess.ts b/src/app/main/chatTypeAccess.ts index 8a0aabb..401c17f 100755 --- a/src/app/main/chatTypeAccess.ts +++ b/src/app/main/chatTypeAccess.ts @@ -7,7 +7,6 @@ export type ChatTypeRecord = { id: string; name: string; description: string; - isTemplate: boolean; permissions: ChatPermissionRole[]; enabled: boolean; updatedAt: string; @@ -17,7 +16,6 @@ export type ChatTypeInput = { id?: string; name: string; description?: string; - isTemplate?: boolean; permissions: ChatPermissionRole[]; enabled?: boolean; }; @@ -39,8 +37,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ id: 'general-request', name: '일반 요청', description: - '현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정된 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.', - isTemplate: false, + '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.', permissions: ['token-user'], enabled: true, updatedAt: '2026-04-21T00:00:00.000Z', @@ -48,8 +45,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ { id: 'api-request-template', name: 'API요청', - description: 'API요청만 진행 (자동화, 작업요청, 스케줄 등 호출 가능한 API)', - isTemplate: true, + description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', permissions: ['token-user'], enabled: true, updatedAt: '2026-04-16T00:00:00.000Z', @@ -90,15 +86,14 @@ function normalizeChatType(record: Partial): ChatTypeRecord | nu id, name, description: normalizeText(record.description), - isTemplate: record.isTemplate === true, permissions: normalizePermissions(record.permissions), enabled: record.enabled !== false, updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(), }; } -function getChatTypeSemanticKey(record: Pick) { - return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`; +function getChatTypeSemanticKey(record: Pick) { + return buildChatTypeNameKey(record.name); } function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) { @@ -314,7 +309,6 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput id: input.id, name: input.name, description: input.description, - isTemplate: input.isTemplate, permissions: input.permissions, enabled: input.enabled, updatedAt: new Date().toISOString(), diff --git a/src/app/main/chatV2/hooks/useConversationComposerController.ts b/src/app/main/chatV2/hooks/useConversationComposerController.ts index 08306b3..a39cfc4 100644 --- a/src/app/main/chatV2/hooks/useConversationComposerController.ts +++ b/src/app/main/chatV2/hooks/useConversationComposerController.ts @@ -10,7 +10,6 @@ type PendingChatRequest = { chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; - chatTypeIsTemplate: boolean; retryCount: number; failed: boolean; }; @@ -21,7 +20,6 @@ type PendingContextConfirm = { chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; - chatTypeIsTemplate: boolean; includedContextCount: number; omittedContextCount: number; }; @@ -30,7 +28,6 @@ type SelectedChatType = { id: string; name: string; description: string; - isTemplate: boolean; } | null; type RecentContextSummary = { @@ -170,7 +167,7 @@ export function useConversationComposerController({ const executeSendMessage = useCallback( (request: PendingContextConfirm) => { - const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request; + const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription } = request; const requestId = `client-${Date.now().toString(36)}`; const outgoingRequest: PendingChatRequest = { sessionId: activeSessionId, @@ -180,7 +177,6 @@ export function useConversationComposerController({ chatTypeId, chatTypeLabel, chatTypeDescription, - chatTypeIsTemplate, retryCount: 0, failed: false, }; @@ -335,26 +331,23 @@ export function useConversationComposerController({ return; } - if (!selectedChatType.isTemplate) { - const recentContext = summarizeRecentContext( - messagesRef.current, - appConfigChat.maxContextMessages, - appConfigChat.maxContextChars, - ); + const recentContext = summarizeRecentContext( + messagesRef.current, + appConfigChat.maxContextMessages, + appConfigChat.maxContextChars, + ); - if (recentContext.omittedCount > 0) { - setPendingContextConfirm({ - mode, - text: trimmed, - chatTypeId: selectedChatType.id, - chatTypeLabel: selectedChatType.name, - chatTypeDescription: selectedChatType.description, - chatTypeIsTemplate: false, - includedContextCount: recentContext.includedCount, - omittedContextCount: recentContext.omittedCount, - }); - return; - } + if (recentContext.omittedCount > 0) { + setPendingContextConfirm({ + mode, + text: trimmed, + chatTypeId: selectedChatType.id, + chatTypeLabel: selectedChatType.name, + chatTypeDescription: selectedChatType.description, + includedContextCount: recentContext.includedCount, + omittedContextCount: recentContext.omittedCount, + }); + return; } executeSendMessage({ @@ -363,7 +356,6 @@ export function useConversationComposerController({ chatTypeId: selectedChatType.id, chatTypeLabel: selectedChatType.name, chatTypeDescription: selectedChatType.description, - chatTypeIsTemplate: selectedChatType.isTemplate, includedContextCount: 0, omittedContextCount: 0, }); diff --git a/src/app/main/chatV2/hooks/useConversationRoomActionsController.ts b/src/app/main/chatV2/hooks/useConversationRoomActionsController.ts index 29445ff..74f7983 100644 --- a/src/app/main/chatV2/hooks/useConversationRoomActionsController.ts +++ b/src/app/main/chatV2/hooks/useConversationRoomActionsController.ts @@ -17,7 +17,6 @@ type PendingChatRequest = { chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; - chatTypeIsTemplate: boolean; retryCount: number; failed: boolean; }; diff --git a/src/app/main/chatV2/hooks/useConversationViewController.ts b/src/app/main/chatV2/hooks/useConversationViewController.ts index c778113..d1b0d7b 100644 --- a/src/app/main/chatV2/hooks/useConversationViewController.ts +++ b/src/app/main/chatV2/hooks/useConversationViewController.ts @@ -5,7 +5,7 @@ type PreviewItem = { id: string; label: string; url: string; - kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; + kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file'; source: 'message' | 'context'; }; diff --git a/src/app/main/layout/MainLayout.tsx b/src/app/main/layout/MainLayout.tsx index 674085b..14c3b2c 100755 --- a/src/app/main/layout/MainLayout.tsx +++ b/src/app/main/layout/MainLayout.tsx @@ -87,6 +87,7 @@ function parseRoute(pathname: string): { first === 'charts' || first === 'schedule' || first === 'history' || + first === 'automation-type' || first === 'server-command') ) { return { @@ -146,6 +147,22 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) { return !hasAccess && topMenu !== 'docs'; } +function getIsMobileViewport() { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + + return window.matchMedia('(max-width: 768px)').matches; +} + +function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) { + if (!isMobileViewport) { + return false; + } + + return topMenu !== 'docs'; +} + function resolveSidebarOpenKeys( topMenu: TopMenuKey, hasAccess: boolean, @@ -189,9 +206,11 @@ export function MainLayout() { const { openSearch, setOptions: setSearchOptions } = useSearchLayer(); const layoutData = useMainLayoutData(); const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport()); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => + resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu), + ); const [contentExpanded, setContentExpanded] = useState(false); - const [isMobileViewport, setIsMobileViewport] = useState(false); const [sidebarOpenKeys, setSidebarOpenKeys] = useState( resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu), ); @@ -221,12 +240,7 @@ export function MainLayout() { }, []); useEffect(() => { - if (!isMobileViewport) { - setSidebarCollapsed(false); - return; - } - - setSidebarCollapsed(routeState.topMenu !== 'docs'); + setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu)); }, [isMobileViewport, routeState.topMenu]); useEffect(() => { @@ -370,7 +384,6 @@ export function MainLayout() { const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]); const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]); const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]); - const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs'; const initialSelectedPlanId = Number(searchParams.get('planId')); const initialSelectedWorkId = searchParams.get('workId'); @@ -414,7 +427,7 @@ export function MainLayout() { }} onChangeTopMenu={(menu) => { navigate(resolveTopMenuPath(menu, currentDocsFolder)); - setSidebarCollapsed(false); + setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu)); }} onOpenPlanQuickFilter={(filter) => { const targetPlanMenu = resolvePlanQuickFilterMenu(filter); @@ -428,13 +441,12 @@ export function MainLayout() { )} - {contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : ( + {contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : ( { navigate(buildDocsPath(key)); - if (isMobileViewport && !showInlineMobileDocsSidebar) { + if (isMobileViewport) { setSidebarCollapsed(true); } }} @@ -486,7 +498,7 @@ export function MainLayout() { /> )} - {isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : ( + {isMobileViewport && !sidebarCollapsed ? null : ( setContentExpanded((previous) => !previous)}> diff --git a/src/app/main/layout/buildSearchOptions.ts b/src/app/main/layout/buildSearchOptions.ts index 073baea..7b2aac4 100755 --- a/src/app/main/layout/buildSearchOptions.ts +++ b/src/app/main/layout/buildSearchOptions.ts @@ -145,6 +145,18 @@ export function buildSearchOptions({ }, ...(hasAccess ? [ + { + id: 'page:plans:automation-type', + label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`, + group: 'Page', + keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'], + onSelect: () => { + requestPlanQuickFilter(null); + navigateTo(buildPlansPath('automation-type')); + setFocusedComponentId(null); + }, + onSelectWindow, + } satisfies SearchKeywordOption, { id: 'page:plans:history', label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`, diff --git a/src/app/main/mainChatPanel/types.ts b/src/app/main/mainChatPanel/types.ts index 468f0cb..b12c5b9 100755 --- a/src/app/main/mainChatPanel/types.ts +++ b/src/app/main/mainChatPanel/types.ts @@ -30,7 +30,6 @@ export type ChatViewContext = { chatTypeId: string | null; chatTypeLabel: string; chatTypeDescription: string; - chatTypeIsTemplate: boolean; }; export type ChatConversationSummary = { diff --git a/src/app/main/mainChatPanel/useChatConnection.ts b/src/app/main/mainChatPanel/useChatConnection.ts index 13926e4..af476f9 100755 --- a/src/app/main/mainChatPanel/useChatConnection.ts +++ b/src/app/main/mainChatPanel/useChatConnection.ts @@ -202,7 +202,6 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio chatTypeId: context.chatTypeId, chatTypeLabel: context.chatTypeLabel, chatTypeDescription: context.chatTypeDescription, - chatTypeIsTemplate: context.chatTypeIsTemplate, }, }), ); diff --git a/src/app/main/mainView/constants.tsx b/src/app/main/mainView/constants.tsx index af84966..0c16e0d 100755 --- a/src/app/main/mainView/constants.tsx +++ b/src/app/main/mainView/constants.tsx @@ -27,6 +27,7 @@ export const PLAN_SIDEBAR_LABELS: Record = { charts: '차트', schedule: '스케줄', history: '이력', + 'automation-type': '자동화 유형', 'server-command': 'Command', }; @@ -50,6 +51,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial> = { charts: 'plan-menu-charts', schedule: 'plan-menu-schedule', history: 'plan-menu-history', + 'automation-type': 'plan-menu-automation-type', 'server-command': 'plan-menu-server-command', }; diff --git a/src/app/main/mainView/searchOptions.ts b/src/app/main/mainView/searchOptions.ts index 6209581..4fa2626 100755 --- a/src/app/main/mainView/searchOptions.ts +++ b/src/app/main/mainView/searchOptions.ts @@ -101,6 +101,18 @@ export function buildMainViewSearchOptions({ }, onSelectWindow, }, + { + id: 'page:plans:automation-type', + label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`, + group: 'Page', + keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'], + onSelect: () => { + setActiveTopMenu('plans'); + setSelectedPlanMenu('automation-type'); + setFocusedComponentId(null); + }, + onSelectWindow, + }, ...(hasAccess ? [ { diff --git a/src/app/main/pages/PlansPage.tsx b/src/app/main/pages/PlansPage.tsx index aac44a3..ee21637 100755 --- a/src/app/main/pages/PlansPage.tsx +++ b/src/app/main/pages/PlansPage.tsx @@ -1,3 +1,4 @@ +import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage'; import { BoardPage } from '../../../features/board'; import { HistoryPage } from '../../../features/history'; import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard'; @@ -53,6 +54,14 @@ export function PlansPage() { ); } + if (selectedPlanMenu === 'automation-type') { + return ( +
+ +
+ ); + } + if (selectedPlanMenu === 'server-command') { return (
diff --git a/src/app/main/routes.tsx b/src/app/main/routes.tsx index 93bbdba..9acaa82 100755 --- a/src/app/main/routes.tsx +++ b/src/app/main/routes.tsx @@ -7,7 +7,16 @@ import type { PlanFilterStatus } from '../../features/planBoard'; export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play'; export type HeaderTopMenuKey = 'docs' | 'plans'; export type ApiSectionKey = 'components' | 'widgets'; -export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command'; +export type PlanSectionKey = + | PlanFilterStatus + | 'release' + | 'release-review' + | 'board' + | 'charts' + | 'schedule' + | 'history' + | 'automation-type' + | 'server-command'; export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage'; export type PlaySectionKey = 'layout'; export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`; @@ -39,6 +48,7 @@ export const PLAN_SIDEBAR_LABELS: Record = { charts: '차트', schedule: '스케줄', history: '이력', + 'automation-type': '자동화 유형', 'server-command': 'Command', }; @@ -57,6 +67,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial> = { charts: 'plan-menu-charts', schedule: 'plan-menu-schedule', history: 'plan-menu-history', + 'automation-type': 'plan-menu-automation-type', 'server-command': 'plan-menu-server-command', }; @@ -188,6 +199,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] { key: 'history', label: renderPlanMenuLabel('history', PLAN_SIDEBAR_LABELS.history), }, + { + key: 'automation-type', + label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']), + }, ], }, { diff --git a/src/features/board/BoardPage.tsx b/src/features/board/BoardPage.tsx index 3df800e..68ac34b 100755 --- a/src/features/board/BoardPage.tsx +++ b/src/features/board/BoardPage.tsx @@ -13,6 +13,11 @@ import { } from '@ant-design/icons'; import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd'; import { useEffect, useMemo, useState } from 'react'; +import { + buildAutomationTypeOptions, + resolveAutomationTypeLabel, + useAutomationTypeRegistry, +} from '../../app/main/automationTypeAccess'; import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { createBoardPost, @@ -22,7 +27,7 @@ import { setupBoard, updateBoardPost, } from './api'; -import type { BoardAutomationType, BoardDraft, BoardPost } from './types'; +import type { BoardDraft, BoardPost } from './types'; const { Paragraph, Text, Title } = Typography; const { TextArea } = Input; @@ -34,18 +39,6 @@ const EMPTY_DRAFT: BoardDraft = { automationType: 'none', }; -const BOARD_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: BoardAutomationType }> = [ - { label: '선택 안함', value: 'none' }, - { label: 'Plan', value: 'plan' }, - { label: 'Command 실행', value: 'command_execution' }, - { label: '비 소스작업', value: 'non_source_work' }, - { label: 'autoWorker', value: 'auto_worker' }, -]; - -const BOARD_AUTOMATION_TYPE_LABELS = new Map( - BOARD_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const), -); - function formatDateTime(value: string) { return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', @@ -128,6 +121,7 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe export function BoardPage() { const [messageApi, contextHolder] = message.useMessage(); + const { automationTypes } = useAutomationTypeRegistry(); const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [checkedIds, setCheckedIds] = useState([]); @@ -216,7 +210,14 @@ export function BoardPage() { ); const dirtyDraftId = draftDirty && draft.id ? draft.id : null; const automationStatus = resolveBoardAutomationStatus(draft.id, automationReceived, draftDirty, automationReceiveError); - const automationTypeLabel = BOARD_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType; + const automationTypeOptions = useMemo( + () => buildAutomationTypeOptions(automationTypes, draft.automationType), + [automationTypes, draft.automationType], + ); + const automationTypeLabel = useMemo( + () => resolveAutomationTypeLabel(automationTypes, draft.automationType), + [automationTypes, draft.automationType], + ); const receivableIds = useMemo( () => items @@ -671,17 +672,17 @@ export function BoardPage() { triggerNode.parentElement ?? document.body} disabled={!hasAccess} @@ -1757,14 +2062,27 @@ export function PlanBoardPage({
메모 - + + + +