feat: refine codex live chat context flows
This commit is contained in:
211
docs/README.md
211
docs/README.md
@@ -1,184 +1,53 @@
|
|||||||
# Docs Guide
|
# 프로젝트 구조
|
||||||
|
|
||||||
프로젝트 문서는 작업일지, 기능 문서, 컴포넌트 문서를 기본 축으로 운영합니다. 현재 메인 앱 `Docs` 화면은 `docs/**/*.md`를 동적으로 수집해 폴더별로 노출합니다.
|
이 문서는 현재 저장소의 큰 구조만 빠르게 확인하기 위한 기준 문서입니다. `Docs` 화면도 이 문서만 기본으로 읽으며, 채팅/자동화용 세부 context는 각 관리 화면에서 개별 항목으로 관리합니다.
|
||||||
|
|
||||||
## 0. 임시 로컬 모드
|
## 최상위 구조
|
||||||
|
|
||||||
- 현재 저장소는 당분간 로컬 전용으로 운영합니다.
|
|
||||||
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
|
|
||||||
- `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
|
|
||||||
- 자동화 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
|
|
||||||
- 자동화와 `Codex Live`는 별개로 취급하며, 자동화는 선택된 자동화 유형 context만 우선 참조합니다.
|
|
||||||
- Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
|
||||||
|
|
||||||
## 1. 작업일지
|
|
||||||
|
|
||||||
- 위치: `docs/worklogs`
|
|
||||||
- 규칙: 날짜별 1개 Markdown 파일 작성
|
|
||||||
- 파일명 예시: `2026-03-31.md`
|
|
||||||
- 템플릿: `docs/templates/worklog-template.md`
|
|
||||||
- 권장 기록 범위: 구현 내용, 구조 변경, 빌드/배포 이슈, Git 작업 내역
|
|
||||||
- 최근 작업일지는 날짜별로 계속 누적 기록
|
|
||||||
- 화면 캡처는 `docs/assets/worklogs/YYYY-MM-DD/` 아래에 저장하고 작업일지에서 상대 경로로 연결
|
|
||||||
- 캡처는 전체 화면보다 작업한 컴포넌트 영역 단위 이미지를 우선 사용
|
|
||||||
- 메뉴/기능 증적이 필요하면 `capture:menu`, `capture:feature` 스크립트로 화면 단위 캡처를 함께 남김
|
|
||||||
- 화면 캡처를 남기지 못한 날에도 `## 화면 캡처` 섹션은 유지하고, 미첨부 사유를 한 줄로 기록
|
|
||||||
- 문서 최신화 작업을 수행한 날에는 어떤 문서를 왜 수정했는지 함께 기록
|
|
||||||
|
|
||||||
권장 항목:
|
|
||||||
|
|
||||||
- 오늘 작업한 내용
|
|
||||||
- 이슈 및 해결 과정
|
|
||||||
- 결정 사항
|
|
||||||
- 상세 작업 내역
|
|
||||||
|
|
||||||
## 2. 기능 문서
|
|
||||||
|
|
||||||
- 위치: `docs/features`
|
|
||||||
- 규칙: 기능 단위로 Markdown 파일 작성
|
|
||||||
- 파일명 예시: `auth.md`, `dashboard.md`
|
|
||||||
- 템플릿: `docs/templates/feature-template.md`
|
|
||||||
- 권장 기록 범위: 기능 목적, 화면 흐름, API/상태, 테스트 포인트
|
|
||||||
- `docs/features/*.md`를 추가하거나 수정하면 앱 `Docs / 기능문서` 메뉴에 반영됨
|
|
||||||
- `src/features/**/*.md`는 프로젝트 내부 전용 설명 문서용이며 메인 `Docs` 메뉴의 기본 수집 대상은 아님
|
|
||||||
|
|
||||||
권장 항목:
|
|
||||||
|
|
||||||
- 기능 목적
|
|
||||||
- 주요 화면/흐름
|
|
||||||
- 데이터 구조 및 API
|
|
||||||
- 예외 처리
|
|
||||||
- 테스트 포인트
|
|
||||||
|
|
||||||
현재 주요 기능 문서:
|
|
||||||
|
|
||||||
- `docs/features/work-request-board.md`: 작업 요청 게시글, 하위 요청, 순차 자동화 접수
|
|
||||||
- `docs/features/plan-board-review.md`: Plan 게시판과 상세 처리
|
|
||||||
- `docs/features/plan-automation.md`: 자동화 처리 흐름과 worker 기준
|
|
||||||
- `docs/features/plan-schedule.md`: 반복 등록과 스케줄 관리
|
|
||||||
- `docs/features/plan-usage.md`: 운영자/검수자 활용 순서
|
|
||||||
|
|
||||||
## 3. 컴포넌트 문서
|
|
||||||
|
|
||||||
- 위치: `docs/components`
|
|
||||||
- 규칙: 컴포넌트별 1개 Markdown 파일 작성
|
|
||||||
- 파일명 예시: `status-badge.md`, `user-card.md`
|
|
||||||
- 대표 샘플: 각 컴포넌트의 `samples/Sample.tsx`
|
|
||||||
- 확장 샘플: `samples/*.tsx`
|
|
||||||
|
|
||||||
권장 항목:
|
|
||||||
|
|
||||||
- 목적
|
|
||||||
- 폴더 구조
|
|
||||||
- UI props
|
|
||||||
- plugin input/output 규칙
|
|
||||||
- plugin 합성 규칙
|
|
||||||
- Sample 활용 예시
|
|
||||||
|
|
||||||
현재 기준 주요 컴포넌트 구조:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/components
|
src/
|
||||||
├─ markdownPreview
|
docs/
|
||||||
├─ navigation
|
etc/
|
||||||
├─ previewer
|
public/
|
||||||
├─ search
|
scripts/
|
||||||
├─ status-badge
|
|
||||||
└─ window
|
|
||||||
```
|
```
|
||||||
|
|
||||||
공통 플러그인 타입과 유틸은 `src/types/component-plugin.ts` 에서 관리합니다.
|
- `src`: 메인 프런트엔드 소스
|
||||||
|
- `docs`: 작업 템플릿과 작업일지 같은 보조 문서
|
||||||
|
- `etc`: work-server, DB, 운영 보조 리소스
|
||||||
|
- `public`: 정적 파일과 채팅 세션 리소스
|
||||||
|
- `scripts`: 개발/운영 스크립트
|
||||||
|
|
||||||
패키지 기준 안내 문서:
|
## 프런트엔드 구조
|
||||||
|
|
||||||
- `src/components/README.md`: 공통 컴포넌트 패키지 목적, 구조, export 규약
|
```text
|
||||||
- `src/widgets/README.md`: 공통 위젯 패키지 목적, registry, feature 규약
|
src
|
||||||
|
├─ app
|
||||||
|
│ └─ main
|
||||||
|
├─ components
|
||||||
|
├─ widgets
|
||||||
|
├─ features
|
||||||
|
├─ views
|
||||||
|
├─ layer
|
||||||
|
└─ store
|
||||||
|
```
|
||||||
|
|
||||||
샘플 운영 규칙:
|
- `src/app/main`: 메인 앱 셸, 라우팅, 상단/사이드바, 채팅/문서 진입점
|
||||||
|
- `src/components`: 공통 UI 조각
|
||||||
|
- `src/widgets`: 공통 카드형 블록
|
||||||
|
- `src/features`: 프로젝트 전용 기능
|
||||||
|
- `src/views`: 플레이/샘플 성격의 화면
|
||||||
|
- `src/layer`: 전역 레이어와 검색 같은 횡단 기능
|
||||||
|
- `src/store`: 앱 전역 상태
|
||||||
|
|
||||||
- `samples/Sample.tsx`는 해당 컴포넌트의 가장 기본형만 표현
|
## 기능 배치 기준
|
||||||
- plugin/feature 예시는 `samples/*.tsx`로 분리
|
|
||||||
- 샘플 목록에서는 같은 컴포넌트 기준으로 묶고 `base -> plugin -> feature` 순서로 정렬
|
|
||||||
|
|
||||||
## 4. 샘플/위젯 레이아웃
|
- 화면 전용 로직은 `src/features`에 둡니다.
|
||||||
|
- 여러 화면에서 재사용되는 UI는 `src/components` 또는 `src/widgets`에 둡니다.
|
||||||
|
- 문서 렌더링과 샘플 수집 같은 앱 메타 기능은 `src/app/main`과 매니페스트에서 관리합니다.
|
||||||
|
|
||||||
- 컴포넌트 샘플 레이아웃: 좌측 컴포넌트 목록 + 우측 상세 카드
|
## 문서 노출 기준
|
||||||
- 상세 카드는 컴포넌트 하나당 1개
|
|
||||||
- 카드 내부는 `Base Sample` 아래에 `Plugin Samples`, `Feature Samples`를 순차적으로 배치
|
|
||||||
- 위젯 샘플은 `widgets/**/samples/*.tsx` 기준으로 별도 수집
|
|
||||||
- 실제 샘플 엔트리 로딩은 `src/app/manifests/samples.manifest.ts`, `src/samples/registry.ts`를 기준으로 동작
|
|
||||||
- 위젯 공통 계약과 메타데이터 규약은 `src/widgets/README.md`, `src/widgets/registry.ts`, `src/widgets/core`를 함께 기준으로 봅니다
|
|
||||||
|
|
||||||
## 5. 프로젝트 종속 레이아웃
|
- 앱 `Docs` 메뉴는 구조 확인용 문서만 노출합니다.
|
||||||
|
- 작업일지, 템플릿, 과거 설계 메모는 저장소에 남길 수 있어도 기본 문서 목록에서는 제외합니다.
|
||||||
- 위치: `src/features/layout`
|
- 채팅 유형 context와 자동화 유형 context는 공용 문서가 아니라 각 관리 데이터에서 직접 관리합니다.
|
||||||
- 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃
|
|
||||||
- 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, Docs markdown preview, Plan 게시판
|
|
||||||
- `Layout Editor`의 기능 명세는 위젯 기본 스펙 정의가 아니라, 현재 레이아웃에 배치된 컴포넌트의 화면 역할과 상호작용을 기술하는 데이터로 취급
|
|
||||||
- 따라서 컴포넌트 간 이벤트/상태 연결과 API 연결 흐름도 `Layout Editor` 기능 명세에서 구현 가능한 범위로 본다
|
|
||||||
- 따라서 샘플 메타나 위젯 기본 기능을 `Layout Editor` 기능 명세에 자동 주입하지 않음
|
|
||||||
- `Layout Editor` 구현은 공통 위젯/컴포넌트 본체 직접 수정보다 `props` 전달과 feature 레이어 조합을 우선한다
|
|
||||||
- 공통 위젯/컴포넌트 변경이 필요하면 기본값 `props`를 기존 동작과 동일하게 유지해 기존 화면 영향이 없도록 설계한다
|
|
||||||
|
|
||||||
프로젝트 종속 기능 규칙:
|
|
||||||
|
|
||||||
- 현재 프로젝트에서만 의미 있는 화면/기능은 `src/features` 아래에 둠
|
|
||||||
- 예: `Plan 게시판`, 대시보드 feature 샘플, 앱 전용 레이아웃
|
|
||||||
- 공통 컴포넌트/위젯으로 재사용 가능한 항목은 `src/components`, `src/widgets`에 유지
|
|
||||||
|
|
||||||
메인 화면 분리 규칙:
|
|
||||||
|
|
||||||
- 위치: `src/app/main`
|
|
||||||
- 구성: `MainView`, `MainHeader`, `MainSidebar`, `MainContent`
|
|
||||||
- 목적: 상단 메뉴, 사이드바, 본문, 검색/문서/Plan 흐름을 앱 레벨에서 분리
|
|
||||||
|
|
||||||
## 6. Markdown Preview
|
|
||||||
|
|
||||||
- 공통 markdown preview는 `src/components/markdownPreview` 아래에서 관리
|
|
||||||
- `basePath`를 받아 특정 폴더 아래 markdown 문서를 재사용 가능하게 렌더링
|
|
||||||
- `docs` 문서 영역은 좌측 폴더/문서 트리 + 우측 markdown 카드 목록 구조 사용
|
|
||||||
- 문서 수집 매니페스트는 `src/app/manifests/docs.manifest.ts`에서 관리
|
|
||||||
- `docs/features`, `docs/components`, `docs/worklogs`, `docs/templates`는 폴더 단위로 자동 분류됨
|
|
||||||
- `docs/worklogs`는 최신 날짜가 먼저 보이도록 역순 정렬
|
|
||||||
|
|
||||||
## 7. 대시보드 위젯/데이터
|
|
||||||
|
|
||||||
- 대시보드 카드 위젯은 `src/widgets/dashboard-report-card`
|
|
||||||
- 위젯 샘플과 프로젝트 종속 샘플은 분리
|
|
||||||
- 재사용 가능한 샘플 데이터는 `src/data` 아래에서 관리
|
|
||||||
- 프로젝트 전용 대시보드 샘플은 `src/features/dashboard`에 둠
|
|
||||||
|
|
||||||
## 8. 배포 메모
|
|
||||||
|
|
||||||
- Nexus publish 대상 registry는 `package.json`의 `publishConfig.registry`
|
|
||||||
- alpha 버전 배포는 `npm publish --tag alpha`
|
|
||||||
- Nexus 인증은 `~/.npmrc`의 `username / _password(base64) / email` 방식으로 확인
|
|
||||||
|
|
||||||
## 8-1. 웹푸쉬 작업 메모
|
|
||||||
|
|
||||||
- 동일한 웹푸쉬를 새 알림으로 교체하려면 DB에서 이전 알림을 지우지 말고 `POST /api/notifications/send` 호출 시 `data.notificationKey` 또는 `threadId`를 고정값으로 보냅니다.
|
|
||||||
- 서비스워커는 같은 `notificationKey`를 `tag`로 사용하므로 같은 브라우저의 이전 알림이 자동으로 대체됩니다.
|
|
||||||
- 특정 브라우저 클라이언트에만 보내려면 같은 API payload에 `targetClientIds: ['클라이언트ID']`를 넣습니다.
|
|
||||||
- 대상 클라이언트 ID가 필요하면 `web_push_subscriptions.device_id`를 조회하고, raw SQL 대신 `/api/crud/web_push_subscriptions/select` 같은 기존 CRUD API를 우선 사용합니다.
|
|
||||||
|
|
||||||
## 9. etc 운영 기준
|
|
||||||
|
|
||||||
- 부가 서버/DB/타언어 프로젝트는 `etc` 아래에서 분리 관리
|
|
||||||
- 서버 예시: `etc/servers/work-server`
|
|
||||||
- DB 예시: `etc/db/work-db`
|
|
||||||
- `etc` 내부 비밀값과 생성물은 커밋 제외
|
|
||||||
- `.env`
|
|
||||||
- `node_modules`
|
|
||||||
- `dist`
|
|
||||||
- `*.log`
|
|
||||||
|
|
||||||
## 10. Plan 기능 문서 메모
|
|
||||||
|
|
||||||
- `Plan` 기능은 `src/features/planBoard`에서 관리
|
|
||||||
- `작업 요청` 기능은 `src/features/board`에서 관리하며 게시글 1건에 N개 하위 요청을 둘 수 있습니다.
|
|
||||||
- 현재 앱에는 목록/상세 보드, release 검수, 차트, 스케줄 화면이 함께 포함됨
|
|
||||||
- 기본 상태는 `등록`, `작업중`, `작업완료`, `릴리즈완료`, `완료`
|
|
||||||
- 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영, 프로젝트 루트 pull 완료 상태를 포함한 흐름을 표현
|
|
||||||
- `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록
|
|
||||||
- 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹
|
|
||||||
- 관련 기능 문서는 `docs/features/work-request-board.md`, `plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
# 신규 컴포넌트 후보 2차 정리
|
|
||||||
|
|
||||||
## 신규 컴포넌트 후보 7차 제안
|
|
||||||
|
|
||||||
### 목적
|
|
||||||
|
|
||||||
현재 `release` 브랜치 기준으로 기존 컴포넌트와 겹치지 않는 신규 공통 컴포넌트 후보를 제안합니다.
|
|
||||||
|
|
||||||
이 글은 검토용 plan 게시판 작성만 수행하며, 자동화 접수는 하지 않고 미접수 상태로 유지합니다.
|
|
||||||
|
|
||||||
### 공통 설계 원칙
|
|
||||||
|
|
||||||
- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
|
||||||
- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행해야 합니다.
|
|
||||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당해야 합니다.
|
|
||||||
- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완해야 합니다.
|
|
||||||
|
|
||||||
### release 기준 확인
|
|
||||||
|
|
||||||
- 이미 존재: Dashboard Report Card, Progress/MultiProgress, Search Command, Popup/Select/CheckCombo 입력, Markdown Preview, Previewer/Codex Diff, Status Badge, Window, DataListTable, EmbeddedMap, TextMemo/GPS/API 샘플 위젯
|
|
||||||
- 제안 방향: Plan/Board/History 화면에서 반복될 가능성이 높지만 아직 공통 컴포넌트로 분리되지 않은 조합형 UI
|
|
||||||
|
|
||||||
### 신규 후보
|
|
||||||
|
|
||||||
#### 1. Query Filter Builder UI
|
|
||||||
|
|
||||||
복수 조건 필터를 행 단위로 추가하고 저장할 수 있는 필터 빌더 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Plan Board 고급 필터, History 검색, Board 검색
|
|
||||||
- 주요 props: `fields`, `operators`, `value`, `onChange`, `presets`, `compact`
|
|
||||||
- 기대 효과: 화면마다 흩어질 수 있는 필터 조건 UI를 일관된 패턴으로 정리
|
|
||||||
|
|
||||||
#### 2. Timeline Activity Feed UI
|
|
||||||
|
|
||||||
작업 상태 변경, 접수, release/main 반영, 오류 이벤트를 시간순으로 보여주는 활동 피드 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Plan 상세, History 상세, 자동화 실행 이력
|
|
||||||
- 주요 props: `items`, `groupByDate`, `statusResolver`, `dense`, `renderMeta`
|
|
||||||
- 기대 효과: 로그성 텍스트를 추적 가능한 UI로 전환하고 최근 변경 맥락을 빠르게 파악
|
|
||||||
|
|
||||||
#### 3. Evidence Attachment Strip UI
|
|
||||||
|
|
||||||
스크린샷, diff, 로그, 링크 같은 증빙 자료를 한 줄 카드 목록으로 노출하는 첨부 스트립 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Plan 검증 증빙, Preview 결과, History 상세
|
|
||||||
- 주요 props: `attachments`, `onPreview`, `onDownload`, `maxVisible`, `variant`
|
|
||||||
- 기대 효과: 증빙 자료 표시와 미리보기 진입점을 공통화
|
|
||||||
|
|
||||||
### 우선순위 제안
|
|
||||||
|
|
||||||
1. Query Filter Builder UI
|
|
||||||
2. Timeline Activity Feed UI
|
|
||||||
3. Evidence Attachment Strip UI
|
|
||||||
|
|
||||||
우선 1번을 먼저 검토하는 것이 좋습니다. Plan Board와 History에서 필터 조건이 계속 늘어날 가능성이 높아 재사용 효과가 가장 큽니다.
|
|
||||||
|
|
||||||
## 목적
|
|
||||||
|
|
||||||
기존에 개발 완료된 `FormField`, `StateKit`, `DataListTable`과 이미 개발 접수된 `Action Toolbar UI`, `Detail Inspector Panel`, `Timeline / Activity Log UI`, `Confirm Dialog UI`, `Notification Toast / Action Feedback UI`, `Date Range Input`, `File Attachment List`, `Component Usage Doc Card`, `Split Pane Layout`은 이번 후보에서 제외합니다.
|
|
||||||
|
|
||||||
이번 문서는 현재 코드베이스와 기존 Board/Plan 접수 이력에 없는 신규 공통 컴포넌트만 다시 추려 이후 Plan 후속 작업 후보를 만드는 목적입니다.
|
|
||||||
|
|
||||||
## 제외 기준
|
|
||||||
|
|
||||||
- 이미 구현 완료된 공통 컴포넌트는 중복 후보로 다시 올리지 않음
|
|
||||||
- 이미 Board/Plan에서 개발 접수된 컴포넌트는 신규 후보에서 제외
|
|
||||||
- 앱 전용 화면 조합보다 여러 기능에서 재사용 가능한 공통 UI를 우선 선정
|
|
||||||
|
|
||||||
## 신규 후보
|
|
||||||
|
|
||||||
### 1. Drawer / Side Sheet UI
|
|
||||||
|
|
||||||
본문 흐름을 끊지 않고 우측 또는 하단에서 보조 편집 화면을 여는 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Plan 상세 보조 편집, 설정 화면, 모바일 상세 패널
|
|
||||||
- 주요 props: `open`, `placement`, `width`, `title`, `footer`, `onClose`
|
|
||||||
- 기대 효과: 전체 화면 전환 없이 보조 작업을 열고 닫는 패턴을 공통화
|
|
||||||
|
|
||||||
### 2. Description List / Key Value Summary UI
|
|
||||||
|
|
||||||
상세 정보 화면에서 라벨과 값을 읽기 전용으로 정리하는 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Plan 메타 정보, 방문 이력 상세, 앱 설정 요약
|
|
||||||
- 주요 props: `items`, `columns`, `size`, `labelWidth`, `copyable`
|
|
||||||
- 기대 효과: 상세 화면마다 반복되는 메타 정보 레이아웃을 줄임
|
|
||||||
|
|
||||||
### 3. Stepper / Process Flow UI
|
|
||||||
|
|
||||||
등록, 작업중, `release` 반영, `main` 반영 같은 단계를 순서형으로 보여주는 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Plan 상태 흐름, 배포 진행 표시, 자동화 단계 요약
|
|
||||||
- 주요 props: `steps`, `current`, `status`, `direction`, `compact`
|
|
||||||
- 기대 효과: 텍스트 상태 나열보다 현재 단계와 다음 단계를 직관적으로 전달
|
|
||||||
|
|
||||||
### 4. Tag Input UI
|
|
||||||
|
|
||||||
여러 키워드나 라벨을 직접 추가하고 삭제하는 입력 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Board 태그, 검색 조건 저장, 증적 분류, 빠른 필터 조합
|
|
||||||
- 주요 props: `value`, `suggestions`, `maxTags`, `allowCustom`, `onChange`
|
|
||||||
- 기대 효과: 다중 조건 입력을 `select`와 별도로 다뤄 반복 필터 구성이 쉬워짐
|
|
||||||
|
|
||||||
### 5. Breadcrumb / Context Path UI
|
|
||||||
|
|
||||||
현재 위치와 상위 경로를 짧게 보여주는 탐색 보조 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: Docs 상세, Components 샘플 상세, History 상세 진입 경로
|
|
||||||
- 주요 props: `items`, `separator`, `compact`, `onNavigate`
|
|
||||||
- 기대 효과: 깊은 메뉴 구조에서 현재 위치 파악과 상위 이동 비용을 낮춤
|
|
||||||
|
|
||||||
### 6. Property Grid UI
|
|
||||||
|
|
||||||
설정값이나 옵션 목록을 2열 또는 다열로 배치해 빠르게 편집하는 설정형 컴포넌트입니다.
|
|
||||||
|
|
||||||
- 적용 위치: 앱 설정, 자동화 설정, 위젯 옵션 편집
|
|
||||||
- 주요 props: `sections`, `fields`, `columns`, `readonly`, `onChange`
|
|
||||||
- 기대 효과: 설정 폼을 긴 세로 나열 대신 밀도 있게 구성 가능
|
|
||||||
|
|
||||||
## 권장 진행 순서
|
|
||||||
|
|
||||||
1. `Description List / Key Value Summary UI`
|
|
||||||
2. `Stepper / Process Flow UI`
|
|
||||||
3. `Drawer / Side Sheet UI`
|
|
||||||
4. `Tag Input UI`
|
|
||||||
5. `Property Grid UI`
|
|
||||||
6. `Breadcrumb / Context Path UI`
|
|
||||||
|
|
||||||
## 검증 기준
|
|
||||||
|
|
||||||
- 모바일 폭에서 `drawer`, `stepper`, `property grid`가 가로 넘침 없이 동작하는지 확인
|
|
||||||
- Plan 상세와 설정 화면에 붙였을 때 기존 `antd` 기본 컴포넌트 조합보다 반복 코드가 줄어드는지 확인
|
|
||||||
- 읽기 전용 화면과 편집 화면에서 같은 컴포넌트를 무리 없이 재사용할 수 있는지 확인
|
|
||||||
|
|
||||||
## 메모
|
|
||||||
|
|
||||||
- 다음 후보 구현 시에는 `samples/BaseSample.tsx`, `samples/Sample.tsx`, 필요 시 `plugins/*.plugin.ts`를 같은 묶음으로 준비
|
|
||||||
- Docs 문서에는 목적, 주요 props, 적용 위치, 확장 포인트를 함께 기록
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
테스트MD자동 생성 입니다.
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
getAppConfig,
|
|
||||||
getChatContextSettingsConfig,
|
getChatContextSettingsConfig,
|
||||||
|
getAppConfigSnapshot,
|
||||||
getChatTypesConfig,
|
getChatTypesConfig,
|
||||||
normalizeAppConfigSnapshot,
|
normalizeAppConfigSnapshot,
|
||||||
upsertAppConfig,
|
upsertAppConfig,
|
||||||
@@ -52,20 +52,20 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
|
|||||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/app-config', async (request) => {
|
app.get('/api/app-config', async (request) => {
|
||||||
const appOrigin = getRequestAppOrigin(request);
|
const appOrigin = getRequestAppOrigin(request);
|
||||||
const config = await getAppConfig(appOrigin);
|
const config = await getAppConfigSnapshot(appOrigin);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
config: normalizeAppConfigSnapshot(config),
|
config,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/chat-types', async (request) => {
|
app.get('/api/chat-types', async (request) => {
|
||||||
const chatTypes = await getChatTypesConfig(getRequestAppOrigin(request));
|
const chatTypeConfig = await getChatTypesConfig(getRequestAppOrigin(request));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
chatTypes,
|
...chatTypeConfig,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,17 +108,21 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = z.object({
|
const parsed = z
|
||||||
chatTypes: z.array(z.unknown()),
|
.object({
|
||||||
}).parse(payload ?? {});
|
chatTypes: z.array(z.unknown()).optional(),
|
||||||
|
customChatTypes: z.array(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
.parse(payload ?? {});
|
||||||
|
|
||||||
const appOrigin = getRequestAppOrigin(request);
|
const appOrigin = getRequestAppOrigin(request);
|
||||||
const appDomain = getRequestAppDomain(request);
|
const appDomain = getRequestAppDomain(request);
|
||||||
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes, appOrigin, appDomain);
|
const targetChatTypes = parsed.customChatTypes ?? parsed.chatTypes ?? [];
|
||||||
|
const savedChatTypeConfig = await upsertChatTypesConfig(targetChatTypes, appOrigin, appDomain);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
chatTypes: savedChatTypes,
|
...savedChatTypeConfig,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
|
|||||||
13
etc/servers/work-server/src/routes/chat.test.ts
Normal file
13
etc/servers/work-server/src/routes/chat.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveStaticContentType } from './chat.js';
|
||||||
|
|
||||||
|
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveStaticContentType keeps plain text content type for code resources', () => {
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRunt
|
|||||||
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||||
import {
|
import {
|
||||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||||
|
clearChatConversationData,
|
||||||
createChatConversation,
|
createChatConversation,
|
||||||
deleteUnansweredChatConversationRequest,
|
deleteUnansweredChatConversationRequest,
|
||||||
deleteChatConversation,
|
deleteChatConversation,
|
||||||
@@ -22,13 +23,14 @@ import {
|
|||||||
updateChatConversationContext,
|
updateChatConversationContext,
|
||||||
} from '../services/chat-room-service.js';
|
} from '../services/chat-room-service.js';
|
||||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||||
|
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
|
||||||
|
|
||||||
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
||||||
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||||
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
||||||
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
||||||
|
|
||||||
function resolveStaticContentType(filePath: string) {
|
export function resolveStaticContentType(filePath: string) {
|
||||||
const extension = path.extname(filePath).toLowerCase();
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
switch (extension) {
|
switch (extension) {
|
||||||
@@ -40,10 +42,12 @@ function resolveStaticContentType(filePath: string) {
|
|||||||
case '.cjs':
|
case '.cjs':
|
||||||
case '.json':
|
case '.json':
|
||||||
case '.css':
|
case '.css':
|
||||||
case '.html':
|
|
||||||
case '.txt':
|
case '.txt':
|
||||||
case '.diff':
|
case '.diff':
|
||||||
return 'text/plain; charset=utf-8';
|
return 'text/plain; charset=utf-8';
|
||||||
|
case '.html':
|
||||||
|
case '.htm':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
case '.md':
|
case '.md':
|
||||||
case '.markdown':
|
case '.markdown':
|
||||||
return 'text/markdown; charset=utf-8';
|
return 'text/markdown; charset=utf-8';
|
||||||
@@ -139,7 +143,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveChatAttachmentRepoPath() {
|
function resolveChatAttachmentRepoPath() {
|
||||||
return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH);
|
return resolveMainProjectRoot();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
||||||
@@ -421,7 +425,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageLimit = query.limit ?? 6;
|
const messageLimit = query.limit ?? 8;
|
||||||
const detailPage = await listChatConversationDetailPage(params.sessionId, {
|
const detailPage = await listChatConversationDetailPage(params.sessionId, {
|
||||||
limit: messageLimit,
|
limit: messageLimit,
|
||||||
beforeMessageId: query.beforeMessageId ?? null,
|
beforeMessageId: query.beforeMessageId ?? null,
|
||||||
@@ -562,4 +566,34 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/chat/conversations/:sessionId/clear', async (request, reply) => {
|
||||||
|
const params = z.object({
|
||||||
|
sessionId: z.string().trim().min(1).max(120),
|
||||||
|
}).parse(request.params ?? {});
|
||||||
|
|
||||||
|
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||||
|
const current = await getChatConversation(params.sessionId, clientId || null);
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '초기화할 채팅방을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveChatService()?.resetSessionData(params.sessionId);
|
||||||
|
chatRuntimeService.clearSession(params.sessionId);
|
||||||
|
const item = await clearChatConversationData(params.sessionId, clientId || null);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '채팅방 데이터 초기화 후 다시 불러오지 못했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
cancelServerRestartReservation,
|
cancelServerRestartReservation,
|
||||||
confirmServerRestartReservation,
|
confirmServerRestartReservation,
|
||||||
getRestartReservationWorkloadSummary,
|
getRestartReservationWorkloadSummary,
|
||||||
|
requestImmediateRestartRecovery,
|
||||||
getServerRestartReservation,
|
getServerRestartReservation,
|
||||||
scheduleServerRestartReservation,
|
scheduleServerRestartReservation,
|
||||||
} from '../services/server-restart-reservation-service.js';
|
} from '../services/server-restart-reservation-service.js';
|
||||||
@@ -90,14 +91,40 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await restartServerCommand(key);
|
try {
|
||||||
|
const result = await restartServerCommand(key);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
item: result.server,
|
item: result.server,
|
||||||
commandOutput: result.commandOutput,
|
commandOutput: result.commandOutput,
|
||||||
restartState: result.restartState,
|
restartState: result.restartState,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||||
|
|
||||||
|
if (key !== 'test' && key !== 'work-server') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestImmediateRestartRecovery(app.log, key, message);
|
||||||
|
const server = (await listServerCommands()).find((item) => item.key === key);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`${key} 서버 상태를 다시 읽지 못했습니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item: server,
|
||||||
|
commandOutput: `${message}\n\n빌드 실패를 감지해 Codex 자동 개선과 재기동 재시도를 시작했습니다.`,
|
||||||
|
restartState: 'accepted' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mergeDefaultChatTypes, resolveAppConfigByOrigin } from './app-config-service.js';
|
import {
|
||||||
|
mergeDefaultChatTypes,
|
||||||
|
migrateLegacyChatTypeContexts,
|
||||||
|
stripBuiltInChatTypes,
|
||||||
|
resolveAppConfigByOrigin,
|
||||||
|
resolveCanonicalChatTypesFromConfig,
|
||||||
|
resolveCanonicalChatContextSettingsFromConfig,
|
||||||
|
stripChatContextSettingsFromScopedAppConfigs,
|
||||||
|
} from './app-config-service.js';
|
||||||
|
|
||||||
test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => {
|
test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => {
|
||||||
const merged = mergeDefaultChatTypes([
|
const merged = mergeDefaultChatTypes([
|
||||||
@@ -64,9 +72,74 @@ test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
|
|||||||
assert.ok(merged.some((item) => item.id === 'layout-editor-execution'));
|
assert.ok(merged.some((item) => item.id === 'layout-editor-execution'));
|
||||||
assert.ok(merged.some((item) => item.id === 'api-request-template'));
|
assert.ok(merged.some((item) => item.id === 'api-request-template'));
|
||||||
assert.ok(merged.some((item) => item.id === 'general-inquiry'));
|
assert.ok(merged.some((item) => item.id === 'general-inquiry'));
|
||||||
|
assert.ok(!merged.some((item) => item.id === 'plan-checklist-execution'));
|
||||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
|
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () => {
|
||||||
|
const stripped = stripBuiltInChatTypes([
|
||||||
|
{
|
||||||
|
id: 'general-request',
|
||||||
|
name: '일반 요청',
|
||||||
|
description: 'builtin',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-checklist-execution',
|
||||||
|
name: 'Plan 체크리스트 실행',
|
||||||
|
description: 'custom-seeded',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-support-flow',
|
||||||
|
name: '운영 문의 전용',
|
||||||
|
description: 'custom',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'plan-checklist-execution']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
|
||||||
|
const migrated = migrateLegacyChatTypeContexts(
|
||||||
|
{
|
||||||
|
defaultContexts: [],
|
||||||
|
chatTypeDefaults: [
|
||||||
|
{
|
||||||
|
chatTypeId: 'plan-checklist-execution',
|
||||||
|
defaultContextIds: ['legacy-linked-context'],
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
roomContexts: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'plan-checklist-execution',
|
||||||
|
name: 'Plan 체크리스트 실행',
|
||||||
|
description: 'legacy plan context',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(migrated.defaultContexts.some((item) => item.id === 'chat-default-plan-checklist-execution'), true);
|
||||||
|
assert.equal(
|
||||||
|
migrated.defaultContexts.find((item) => item.id === 'chat-default-plan-checklist-execution')?.content,
|
||||||
|
'legacy plan context',
|
||||||
|
);
|
||||||
|
assert.equal(migrated.chatTypeDefaults.some((item) => item.chatTypeId === 'plan-checklist-execution'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveAppConfigByOrigin prefers scoped app config over legacy global config', () => {
|
test('resolveAppConfigByOrigin prefers scoped app config over legacy global config', () => {
|
||||||
const resolved = resolveAppConfigByOrigin(
|
const resolved = resolveAppConfigByOrigin(
|
||||||
{
|
{
|
||||||
@@ -112,3 +185,149 @@ test('resolveAppConfigByOrigin falls back to legacy global config when scoped co
|
|||||||
|
|
||||||
assert.equal(resolved.chat?.receiveRoomNotifications, true);
|
assert.equal(resolved.chat?.receiveRoomNotifications, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context settings over stale scoped entries', () => {
|
||||||
|
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||||
|
{
|
||||||
|
chatContextSettings: {
|
||||||
|
defaultContexts: [
|
||||||
|
{
|
||||||
|
id: 'global-a',
|
||||||
|
title: '전역 A',
|
||||||
|
content: 'global',
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'global-b',
|
||||||
|
title: '전역 B',
|
||||||
|
content: 'global',
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scopedAppConfigs: {
|
||||||
|
'https://test.sm-home.cloud': {
|
||||||
|
config: {
|
||||||
|
chatContextSettings: {
|
||||||
|
defaultContexts: [
|
||||||
|
{
|
||||||
|
id: 'scoped-a',
|
||||||
|
title: '스코프 A',
|
||||||
|
content: 'scoped',
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'https://test.sm-home.cloud',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
resolved.defaultContexts.map((item) => item.id),
|
||||||
|
['global-a', 'global-b'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped settings only when global settings are empty', () => {
|
||||||
|
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||||
|
{
|
||||||
|
scopedAppConfigs: {
|
||||||
|
'https://test.sm-home.cloud': {
|
||||||
|
config: {
|
||||||
|
chatContextSettings: {
|
||||||
|
defaultContexts: [
|
||||||
|
{
|
||||||
|
id: 'scoped-a',
|
||||||
|
title: '스코프 A',
|
||||||
|
content: 'scoped',
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'https://test.sm-home.cloud',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
resolved.defaultContexts.map((item) => item.id),
|
||||||
|
['scoped-a'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCanonicalChatTypesFromConfig merges global chat types with stale scoped entries', () => {
|
||||||
|
const resolved = resolveCanonicalChatTypesFromConfig(
|
||||||
|
{
|
||||||
|
chatTypes: [
|
||||||
|
{
|
||||||
|
id: 'verification-test-generation',
|
||||||
|
name: '검증 밑 테스트 생성',
|
||||||
|
description: 'global',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T08:15:18.440Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scopedAppConfigs: {
|
||||||
|
'https://test.sm-home.cloud': {
|
||||||
|
config: {
|
||||||
|
chatTypes: [
|
||||||
|
{
|
||||||
|
id: 'general-request',
|
||||||
|
name: '일반 요청',
|
||||||
|
description: 'scoped',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'https://test.sm-home.cloud',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(resolved);
|
||||||
|
assert.equal(resolved.some((item) => item.id === 'verification-test-generation'), true);
|
||||||
|
assert.equal(resolved.some((item) => item.id === 'general-request'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat context settings only', () => {
|
||||||
|
const stripped = stripChatContextSettingsFromScopedAppConfigs({
|
||||||
|
scopedAppConfigs: {
|
||||||
|
'https://test.sm-home.cloud': {
|
||||||
|
config: {
|
||||||
|
chatContextSettings: {
|
||||||
|
defaultContexts: [{ id: 'legacy', title: 'legacy', content: 'legacy', enabled: true }],
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
receiveRoomNotifications: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(stripped.changed, true);
|
||||||
|
assert.deepEqual(stripped.scopedConfigs, {
|
||||||
|
'https://test.sm-home.cloud': {
|
||||||
|
config: {
|
||||||
|
chat: {
|
||||||
|
receiveRoomNotifications: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { DEFAULT_CHAT_TYPES } from './chat-type-defaults.js';
|
import {
|
||||||
|
DEFAULT_CHAT_TYPES,
|
||||||
|
PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT,
|
||||||
|
PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
|
||||||
|
PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
|
||||||
|
} from './chat-type-defaults.js';
|
||||||
|
|
||||||
export const APP_CONFIG_TABLE = 'app_configs';
|
export const APP_CONFIG_TABLE = 'app_configs';
|
||||||
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
||||||
@@ -25,6 +30,14 @@ type ChatTypeRecord = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID = 'plan-checklist-execution';
|
||||||
|
|
||||||
|
export type ChatTypesConfigSnapshot = {
|
||||||
|
builtInChatTypes: ChatTypeRecord[];
|
||||||
|
customChatTypes: ChatTypeRecord[];
|
||||||
|
chatTypes: ChatTypeRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
type ChatDefaultContextRecord = {
|
type ChatDefaultContextRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -53,25 +66,6 @@ export type ChatContextSettingsSnapshot = {
|
|||||||
roomContexts: ChatRoomContextSettings[];
|
roomContexts: ChatRoomContextSettings[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
|
|
||||||
{
|
|
||||||
id: 'chat-default-mobile-verification',
|
|
||||||
title: '모바일 검증',
|
|
||||||
content:
|
|
||||||
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
|
|
||||||
enabled: true,
|
|
||||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chat-default-resource-output',
|
|
||||||
title: '리소스 출력',
|
|
||||||
content:
|
|
||||||
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
|
|
||||||
enabled: true,
|
|
||||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function ensureAppConfigTable() {
|
async function ensureAppConfigTable() {
|
||||||
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
||||||
|
|
||||||
@@ -154,6 +148,82 @@ function getScopedAppConfigsRecord(value: unknown) {
|
|||||||
return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]);
|
return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScopedAppConfigEntryRecord(value: unknown) {
|
||||||
|
return normalizeConfigRecord(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChatContextSettingsSnapshot(value: ChatContextSettingsSnapshot) {
|
||||||
|
return (
|
||||||
|
value.defaultContexts.length > 0 ||
|
||||||
|
value.chatTypeDefaults.length > 0 ||
|
||||||
|
value.roomContexts.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripChatContextSettingsFromScopedAppConfigs(value: unknown) {
|
||||||
|
const scopedConfigs = getScopedAppConfigsRecord(value);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const sanitizedScopedConfigs = Object.fromEntries(
|
||||||
|
Object.entries(scopedConfigs).map(([origin, entry]) => {
|
||||||
|
const normalizedEntry = getScopedAppConfigEntryRecord(entry);
|
||||||
|
const normalizedConfig = normalizeConfigRecord(normalizedEntry.config);
|
||||||
|
|
||||||
|
if (!(CHAT_CONTEXT_SETTINGS_CONFIG_KEY in normalizedConfig)) {
|
||||||
|
return [origin, normalizedEntry];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: _removed, ...nextConfig } = normalizedConfig;
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
return [
|
||||||
|
origin,
|
||||||
|
{
|
||||||
|
...normalizedEntry,
|
||||||
|
config: nextConfig,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed,
|
||||||
|
scopedConfigs: sanitizedScopedConfigs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCanonicalChatContextSettingsFromConfig(value: unknown, appOrigin?: string | null) {
|
||||||
|
const normalized = normalizeConfigRecord(value);
|
||||||
|
const globalSettings = sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||||
|
|
||||||
|
if (hasChatContextSettingsSnapshot(globalSettings)) {
|
||||||
|
return globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedSettings = sanitizeChatContextSettings(
|
||||||
|
normalizeConfigRecord(resolveAppConfigByOrigin(normalized, appOrigin))[CHAT_CONTEXT_SETTINGS_CONFIG_KEY],
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasChatContextSettingsSnapshot(scopedSettings) ? scopedSettings : globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCanonicalChatTypesFromConfig(value: unknown, appOrigin?: string | null) {
|
||||||
|
const normalized = normalizeConfigRecord(value);
|
||||||
|
const globalChatTypes = Array.isArray(normalized[CHAT_TYPES_CONFIG_KEY])
|
||||||
|
? sanitizeChatTypes(normalized[CHAT_TYPES_CONFIG_KEY])
|
||||||
|
: [];
|
||||||
|
const scopedConfig = resolveScopedAppConfig(normalized, appOrigin);
|
||||||
|
const scopedChatTypes = Array.isArray(normalizeConfigRecord(scopedConfig)[CHAT_TYPES_CONFIG_KEY])
|
||||||
|
? sanitizeChatTypes(normalizeConfigRecord(scopedConfig)[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (globalChatTypes.length === 0 && scopedChatTypes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeDefaultChatTypes([...globalChatTypes, ...scopedChatTypes]);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) {
|
function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) {
|
||||||
const normalizedAppOrigin = normalizeAppOrigin(appOrigin);
|
const normalizedAppOrigin = normalizeAppOrigin(appOrigin);
|
||||||
|
|
||||||
@@ -229,6 +299,26 @@ export async function getAppConfig(appOrigin?: string | null) {
|
|||||||
return resolveAppConfigByOrigin(row.config_json ?? {}, appOrigin);
|
return resolveAppConfigByOrigin(row.config_json ?? {}, appOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRawAppConfigRecord() {
|
||||||
|
await ensureAppConfigTable();
|
||||||
|
|
||||||
|
const row = await db(APP_CONFIG_TABLE).first();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return {} as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof row.config_json === 'string') {
|
||||||
|
try {
|
||||||
|
return normalizeConfigRecord(JSON.parse(row.config_json));
|
||||||
|
} catch {
|
||||||
|
return {} as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeConfigRecord(row.config_json);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfigRecord(value: unknown) {
|
function normalizeConfigRecord(value: unknown) {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
return {} as Record<string, unknown>;
|
return {} as Record<string, unknown>;
|
||||||
@@ -299,7 +389,7 @@ function sanitizeDefaultContexts(items: unknown) {
|
|||||||
const byId = new Map<string, ChatDefaultContextRecord>();
|
const byId = new Map<string, ChatDefaultContextRecord>();
|
||||||
const sourceItems = Array.isArray(items) ? items : [];
|
const sourceItems = Array.isArray(items) ? items : [];
|
||||||
|
|
||||||
[...sourceItems, ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
|
sourceItems
|
||||||
.map((item) => normalizeDefaultContextRecord(item))
|
.map((item) => normalizeDefaultContextRecord(item))
|
||||||
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
@@ -420,6 +510,14 @@ function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
|
|||||||
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBuiltInChatTypeId(chatTypeId: string) {
|
||||||
|
return DEFAULT_CHAT_TYPES.some((item) => item.id === chatTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLegacyMigratedChatTypeId(chatTypeId: string) {
|
||||||
|
return chatTypeId === LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) {
|
function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) {
|
||||||
const leftTime = Date.parse(left.updatedAt);
|
const leftTime = Date.parse(left.updatedAt);
|
||||||
const rightTime = Date.parse(right.updatedAt);
|
const rightTime = Date.parse(right.updatedAt);
|
||||||
@@ -473,6 +571,56 @@ export function mergeDefaultChatTypes(items: unknown[]) {
|
|||||||
return sanitizeChatTypes(Array.from(byId.values()));
|
return sanitizeChatTypes(Array.from(byId.values()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripBuiltInChatTypes(items: unknown[]) {
|
||||||
|
return sanitizeChatTypes(items).filter((item) => !isBuiltInChatTypeId(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) {
|
||||||
|
return items.filter((item) => !isLegacyMigratedChatTypeId(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlanChecklistDefaultContext(record?: ChatTypeRecord | null, existing?: ChatDefaultContextRecord | null) {
|
||||||
|
const content = normalizeText(record?.description) || normalizeText(existing?.content) || PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT;
|
||||||
|
|
||||||
|
return normalizeDefaultContextRecord({
|
||||||
|
id: PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
|
||||||
|
title: normalizeText(record?.name) || normalizeText(existing?.title) || PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
|
||||||
|
content,
|
||||||
|
enabled: existing?.enabled ?? record?.enabled ?? true,
|
||||||
|
updatedAt: normalizeText(record?.updatedAt) || normalizeText(existing?.updatedAt) || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateLegacyChatTypeContexts(
|
||||||
|
settings: ChatContextSettingsSnapshot,
|
||||||
|
chatTypes: ChatTypeRecord[],
|
||||||
|
): ChatContextSettingsSnapshot {
|
||||||
|
const legacyPlanChecklistChatType = chatTypes.find((item) => item.id === LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID);
|
||||||
|
|
||||||
|
if (!legacyPlanChecklistChatType) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingContext =
|
||||||
|
settings.defaultContexts.find((item) => item.id === PLAN_CHECKLIST_DEFAULT_CONTEXT_ID) ?? null;
|
||||||
|
const migratedContext = buildPlanChecklistDefaultContext(legacyPlanChecklistChatType, existingContext);
|
||||||
|
const nextDefaultContexts = migratedContext
|
||||||
|
? sanitizeDefaultContexts([
|
||||||
|
...settings.defaultContexts.filter((item) => item.id !== PLAN_CHECKLIST_DEFAULT_CONTEXT_ID),
|
||||||
|
migratedContext,
|
||||||
|
])
|
||||||
|
: settings.defaultContexts;
|
||||||
|
const nextChatTypeDefaults = sanitizeChatTypeDefaultSelections(
|
||||||
|
settings.chatTypeDefaults.filter((item) => item.chatTypeId !== LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultContexts: nextDefaultContexts,
|
||||||
|
chatTypeDefaults: nextChatTypeDefaults,
|
||||||
|
roomContexts: settings.roomContexts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
|
function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
|
||||||
if (left.length !== right.length) {
|
if (left.length !== right.length) {
|
||||||
return false;
|
return false;
|
||||||
@@ -585,7 +733,18 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppConfigSnapshot(appOrigin?: string | null): Promise<AppConfigSnapshot> {
|
export async function getAppConfigSnapshot(appOrigin?: string | null): Promise<AppConfigSnapshot> {
|
||||||
return normalizeAppConfigSnapshot(await getAppConfig(appOrigin));
|
const config = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||||
|
const rawConfig = await getRawAppConfigRecord();
|
||||||
|
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
|
||||||
|
const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin);
|
||||||
|
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(rawConfig);
|
||||||
|
|
||||||
|
return normalizeAppConfigSnapshot({
|
||||||
|
...config,
|
||||||
|
...(canonicalChatTypes ? { [CHAT_TYPES_CONFIG_KEY]: canonicalChatTypes } : null),
|
||||||
|
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: canonicalChatContextSettings,
|
||||||
|
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertAppConfig(
|
export async function upsertAppConfig(
|
||||||
@@ -626,42 +785,83 @@ export async function upsertAppConfig(
|
|||||||
return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin);
|
return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChatTypesConfig(appOrigin?: string | null) {
|
export async function getChatTypesConfig(appOrigin?: string | null): Promise<ChatTypesConfigSnapshot> {
|
||||||
const config = await getAppConfig(appOrigin);
|
const rawConfig = await getRawAppConfigRecord();
|
||||||
const normalized = normalizeConfigRecord(config);
|
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
|
||||||
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
|
const builtInChatTypes = sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||||
if (chatTypes == null) {
|
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
|
||||||
return null;
|
const customChatTypes = stripBuiltInChatTypes(migratedChatTypeList);
|
||||||
}
|
const mergedChatTypes = mergeDefaultChatTypes(customChatTypes);
|
||||||
|
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||||
|
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
|
||||||
|
canonicalChatTypes ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
|
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||||
const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
|
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||||
|
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||||
|
: [];
|
||||||
|
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||||
|
|
||||||
if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) {
|
if (!isSameChatTypeList(resolvedCustomChatTypes, customChatTypes)) {
|
||||||
await upsertAppConfig({
|
await upsertAppConfig({
|
||||||
[CHAT_TYPES_CONFIG_KEY]: mergedChatTypes,
|
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
|
||||||
}, appOrigin);
|
}, appOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergedChatTypes;
|
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
|
||||||
|
await upsertChatContextSettingsConfig(migratedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
builtInChatTypes,
|
||||||
|
customChatTypes,
|
||||||
|
chatTypes: mergedChatTypes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
|
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
|
||||||
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
|
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||||
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
|
const customChatTypes = stripBuiltInChatTypes(chatTypes);
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
...current,
|
...current,
|
||||||
[CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
|
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await upsertAppConfig(nextConfig, appOrigin, appDomain);
|
await upsertAppConfig(nextConfig, appOrigin, appDomain);
|
||||||
return resolvedChatTypes;
|
return {
|
||||||
|
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||||
|
customChatTypes,
|
||||||
|
chatTypes: mergeDefaultChatTypes(customChatTypes),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChatContextSettingsConfig(appOrigin?: string | null) {
|
export async function getChatContextSettingsConfig(appOrigin?: string | null) {
|
||||||
const config = await getAppConfig(appOrigin);
|
const rawConfig = await getRawAppConfigRecord();
|
||||||
const normalized = normalizeConfigRecord(config);
|
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin) ?? [];
|
||||||
return sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||||
|
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
|
||||||
|
canonicalChatTypes,
|
||||||
|
);
|
||||||
|
const migratedChatTypes = stripLegacyMigratedChatTypes(canonicalChatTypes);
|
||||||
|
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||||
|
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||||
|
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||||
|
: [];
|
||||||
|
const nextCustomChatTypes = stripBuiltInChatTypes(migratedChatTypes);
|
||||||
|
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||||
|
|
||||||
|
if (!isSameChatTypeList(resolvedCustomChatTypes, nextCustomChatTypes)) {
|
||||||
|
await upsertAppConfig({
|
||||||
|
[CHAT_TYPES_CONFIG_KEY]: nextCustomChatTypes,
|
||||||
|
}, appOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
|
||||||
|
await upsertChatContextSettingsConfig(migratedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return migratedSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertChatContextSettingsConfig(
|
export async function upsertChatContextSettingsConfig(
|
||||||
@@ -669,13 +869,17 @@ export async function upsertChatContextSettingsConfig(
|
|||||||
appOrigin?: string | null,
|
appOrigin?: string | null,
|
||||||
appDomain?: string | null,
|
appDomain?: string | null,
|
||||||
) {
|
) {
|
||||||
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
|
const current = await getRawAppConfigRecord();
|
||||||
const nextSettings = sanitizeChatContextSettings(settings);
|
const nextSettings = sanitizeChatContextSettings(settings);
|
||||||
|
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(current);
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
...current,
|
...current,
|
||||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: nextSettings,
|
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: nextSettings,
|
||||||
|
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||||
};
|
};
|
||||||
|
|
||||||
await upsertAppConfig(nextConfig, appOrigin, appDomain);
|
void appOrigin;
|
||||||
|
void appDomain;
|
||||||
|
await upsertAppConfig(nextConfig);
|
||||||
return nextSettings;
|
return nextSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,79 @@ export type ChatMessagePart =
|
|||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
actionLabel?: string | null;
|
actionLabel?: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'prompt';
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
submitLabel?: string | null;
|
||||||
|
mode?: 'queue' | 'direct' | null;
|
||||||
|
multiple?: boolean;
|
||||||
|
responseTemplate?: string | null;
|
||||||
|
freeTextLabel?: string | null;
|
||||||
|
freeTextPlaceholder?: string | null;
|
||||||
|
currentStepKey?: string | null;
|
||||||
|
steps?: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
submitLabel?: string | null;
|
||||||
|
mode?: 'queue' | 'direct' | null;
|
||||||
|
multiple?: boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
responseTemplate?: string | null;
|
||||||
|
freeTextLabel?: string | null;
|
||||||
|
freeTextPlaceholder?: string | null;
|
||||||
|
selectedValues?: string[];
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
preview?:
|
||||||
|
| {
|
||||||
|
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||||
|
url?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
alt?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
selectedValues?: string[];
|
||||||
|
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||||
|
resolvedAt?: string | null;
|
||||||
|
resultText?: string | null;
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
preview?:
|
||||||
|
| {
|
||||||
|
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||||
|
url?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
alt?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||||
|
type PromptOption = PromptPart['options'][number];
|
||||||
|
type PromptPreview = NonNullable<PromptOption['preview']>;
|
||||||
|
type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||||
|
|
||||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||||
|
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
|
||||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||||
|
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||||
|
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||||
|
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||||
|
|
||||||
function normalizeText(value: unknown) {
|
function normalizeText(value: unknown) {
|
||||||
return String(value ?? '').trim();
|
return String(value ?? '').trim();
|
||||||
@@ -27,6 +94,25 @@ function normalizeUrl(value: string) {
|
|||||||
return `/${malformedResourceMatch[1]}`;
|
return `/${malformedResourceMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||||
|
if (apiMarkerIndex >= 0) {
|
||||||
|
const apiPath = normalized.slice(apiMarkerIndex);
|
||||||
|
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||||
|
return dotCodexIndex >= 0
|
||||||
|
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||||
|
: apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||||
|
if (publicDotCodexIndex >= 0) {
|
||||||
|
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||||
|
if (dotCodexIndex >= 0) {
|
||||||
|
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -34,6 +120,114 @@ function normalizeUrl(value: string) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePromptPreview(value: unknown): PromptPreview | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const type: 'image' | 'markdown' | 'html' | 'resource' | null =
|
||||||
|
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
||||||
|
? record.type
|
||||||
|
: null;
|
||||||
|
const url = normalizeUrl(normalizeText(record.url));
|
||||||
|
const content = String(record.content ?? '').trim() || null;
|
||||||
|
const alt = normalizeText(record.alt) || null;
|
||||||
|
const title = normalizeText(record.title) || null;
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'image' || type === 'resource') {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (!content && !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
url: url || null,
|
||||||
|
content,
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptOption(value: unknown): PromptOption | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const optionValue = normalizeText(record.value);
|
||||||
|
const label = normalizeText(record.label);
|
||||||
|
|
||||||
|
if (!optionValue || !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: optionValue,
|
||||||
|
label,
|
||||||
|
description: normalizeText(record.description) || null,
|
||||||
|
preview: normalizePromptPreview(record.preview),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptSelectedValues(value: unknown) {
|
||||||
|
return [
|
||||||
|
...(Array.isArray(value) ? value : []),
|
||||||
|
]
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((item, index, array) => array.indexOf(item) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.flatMap((item, index) => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const key = normalizeText(record.key) || `step-${index + 1}`;
|
||||||
|
const title = normalizeText(record.title);
|
||||||
|
const options = Array.isArray(record.options)
|
||||||
|
? record.options
|
||||||
|
.map((option) => normalizePromptOption(option))
|
||||||
|
.filter((option): option is PromptOption => Boolean(option))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!title || options.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
description: normalizeText(record.description) || null,
|
||||||
|
submitLabel: normalizeText(record.submitLabel) || null,
|
||||||
|
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
|
||||||
|
multiple: record.multiple === true,
|
||||||
|
optional: record.optional === true,
|
||||||
|
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||||
|
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||||
|
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||||
|
selectedValues: normalizePromptSelectedValues(record.selectedValues),
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function decodeUrlComponentSafely(value: string) {
|
function decodeUrlComponentSafely(value: string) {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
@@ -141,6 +335,66 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const title = normalizeText(record.title);
|
||||||
|
const options = Array.isArray(record.options)
|
||||||
|
? record.options
|
||||||
|
.map((item) => normalizePromptOption(item))
|
||||||
|
.filter((option): option is PromptOption => Boolean(option))
|
||||||
|
: [];
|
||||||
|
const steps = normalizePromptSteps(record.steps);
|
||||||
|
|
||||||
|
if (!title || (options.length === 0 && steps.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
|
||||||
|
const selectedValues = [
|
||||||
|
...normalizePromptSelectedValues(record.selectedValues),
|
||||||
|
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||||
|
]
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((value, index, values) => values.indexOf(value) === index);
|
||||||
|
const resolvedBy =
|
||||||
|
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
|
||||||
|
? record.resolvedBy
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'prompt',
|
||||||
|
title,
|
||||||
|
description: normalizeText(record.description) || null,
|
||||||
|
submitLabel: normalizeText(record.submitLabel) || null,
|
||||||
|
mode,
|
||||||
|
multiple: record.multiple === true,
|
||||||
|
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||||
|
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||||
|
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||||
|
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||||
|
steps: steps.length > 0 ? steps : undefined,
|
||||||
|
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||||
|
selectedValues,
|
||||||
|
resolvedBy,
|
||||||
|
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||||
|
resultText: normalizeText(record.resultText) || null,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function extractChatMessageParts(text: string) {
|
export function extractChatMessageParts(text: string) {
|
||||||
const lines = String(text ?? '').split('\n');
|
const lines = String(text ?? '').split('\n');
|
||||||
const keptLines: string[] = [];
|
const keptLines: string[] = [];
|
||||||
@@ -151,7 +405,38 @@ export function extractChatMessageParts(text: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
const dedupeKey =
|
||||||
|
nextPart.type === 'link_card'
|
||||||
|
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
|
||||||
|
: [
|
||||||
|
nextPart.type,
|
||||||
|
nextPart.title,
|
||||||
|
nextPart.options
|
||||||
|
.map((option) =>
|
||||||
|
[
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
option.preview?.type ?? '',
|
||||||
|
option.preview?.url ?? '',
|
||||||
|
option.preview?.content ?? '',
|
||||||
|
option.preview?.title ?? '',
|
||||||
|
].join('|'),
|
||||||
|
)
|
||||||
|
.join(','),
|
||||||
|
(nextPart.steps ?? [])
|
||||||
|
.map((step) =>
|
||||||
|
[
|
||||||
|
step.key,
|
||||||
|
step.title,
|
||||||
|
step.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||||
|
].join('|'),
|
||||||
|
)
|
||||||
|
.join(','),
|
||||||
|
nextPart.selectedValues?.join(',') ?? '',
|
||||||
|
nextPart.resolvedBy ?? '',
|
||||||
|
nextPart.resultText ?? '',
|
||||||
|
nextPart.readOnly === true ? 'readonly' : '',
|
||||||
|
].join(':');
|
||||||
|
|
||||||
if (seenLinkKeys.has(dedupeKey)) {
|
if (seenLinkKeys.has(dedupeKey)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -163,6 +448,15 @@ export function extractChatMessageParts(text: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
const promptMatched = line.match(PROMPT_LINE_PATTERN);
|
||||||
|
|
||||||
|
if (promptMatched) {
|
||||||
|
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
|
||||||
|
keptLines.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
@@ -196,7 +490,7 @@ export function extractChatMessageParts(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestPart = parts.at(-1);
|
const latestPart = parts.at(-1);
|
||||||
if (latestPart && isInternalResourceUrl(latestPart.url)) {
|
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
|
||||||
parts.pop();
|
parts.pop();
|
||||||
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
||||||
keptLines.push(latestPart.url);
|
keptLines.push(latestPart.url);
|
||||||
@@ -222,24 +516,29 @@ export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = item as Record<string, unknown>;
|
const record = item as Record<string, unknown>;
|
||||||
if (record.type !== 'link_card') {
|
if (record.type === 'link_card') {
|
||||||
return null;
|
const title = normalizeText(record.title);
|
||||||
|
const url = normalizeUrl(String(record.url ?? ''));
|
||||||
|
const actionLabel = normalizeText(record.actionLabel) || null;
|
||||||
|
|
||||||
|
if (!title || !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'link_card' as const,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
actionLabel,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = normalizeText(record.title);
|
if (record.type === 'prompt') {
|
||||||
const url = normalizeUrl(String(record.url ?? ''));
|
const promptPart = buildPromptPart(JSON.stringify(record));
|
||||||
const actionLabel = normalizeText(record.actionLabel) || null;
|
return promptPart;
|
||||||
|
|
||||||
if (!title || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return null;
|
||||||
type: 'link_card' as const,
|
|
||||||
title,
|
|
||||||
url,
|
|
||||||
actionLabel,
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.filter(Boolean) as ChatMessagePart[];
|
.filter(Boolean) as ChatMessagePart[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type ChatConversationItem = {
|
|||||||
currentJobMessage: string | null;
|
currentJobMessage: string | null;
|
||||||
currentQueueSize: number;
|
currentQueueSize: number;
|
||||||
currentStatusUpdatedAt: string | null;
|
currentStatusUpdatedAt: string | null;
|
||||||
|
isPendingWork: boolean;
|
||||||
|
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
|
||||||
lastRequestPreview: string;
|
lastRequestPreview: string;
|
||||||
lastMessagePreview: string;
|
lastMessagePreview: string;
|
||||||
lastResponsePreview: string;
|
lastResponsePreview: string;
|
||||||
@@ -173,6 +175,160 @@ function createPreview(text: string) {
|
|||||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PENDING_WORK_ANALYSIS_PATTERNS = [
|
||||||
|
/분석/u,
|
||||||
|
/검토/u,
|
||||||
|
/조사/u,
|
||||||
|
/원인/u,
|
||||||
|
/파악/u,
|
||||||
|
/\banalysis\b/i,
|
||||||
|
/\binvestigat(?:e|ion)\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PENDING_WORK_DESIGN_PATTERNS = [
|
||||||
|
/설계/u,
|
||||||
|
/프롬프트/u,
|
||||||
|
/시안/u,
|
||||||
|
/구조/u,
|
||||||
|
/방향/u,
|
||||||
|
/기획/u,
|
||||||
|
/플로우/u,
|
||||||
|
/아키텍처/u,
|
||||||
|
/\bdesign\b/i,
|
||||||
|
/\barchitecture\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PENDING_WORK_IMPLEMENTATION_PATTERNS = [
|
||||||
|
/구현했/u,
|
||||||
|
/수정했/u,
|
||||||
|
/반영했/u,
|
||||||
|
/적용했/u,
|
||||||
|
/완료했/u,
|
||||||
|
/마무리했/u,
|
||||||
|
/배포했/u,
|
||||||
|
/검증했/u,
|
||||||
|
/빌드.*통과/u,
|
||||||
|
/테스트.*통과/u,
|
||||||
|
/캡처/u,
|
||||||
|
/preview/iu,
|
||||||
|
/변경 파일/u,
|
||||||
|
/diff/u,
|
||||||
|
/\bimplement(?:ed|ation)?\b/i,
|
||||||
|
/\bfix(?:ed)?\b/i,
|
||||||
|
/\bverified?\b/i,
|
||||||
|
/\btested?\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
|
||||||
|
/원하시면/u,
|
||||||
|
/진행해드릴/u,
|
||||||
|
/이어(?:서|가)/u,
|
||||||
|
/다음 단계/u,
|
||||||
|
/선택/u,
|
||||||
|
/옵션/u,
|
||||||
|
/후속/u,
|
||||||
|
/\bif you want\b/i,
|
||||||
|
/\bnext step\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizePendingWorkText(text: string | null | undefined) {
|
||||||
|
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
|
||||||
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingWorkReasonFromText(text: string) {
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingWorkPattern(text, PENDING_WORK_DESIGN_PATTERNS)) {
|
||||||
|
return 'design' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingWorkPattern(text, PENDING_WORK_ANALYSIS_PATTERNS)) {
|
||||||
|
return 'analysis' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOpenPromptParts(parts: ChatMessagePart[] | undefined) {
|
||||||
|
return (parts ?? []).some((part) => {
|
||||||
|
if (part.type !== 'prompt' || part.readOnly === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((part.selectedValues?.length ?? 0) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((part.resultText?.trim() ?? '').length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((part.resolvedAt?.trim() ?? '').length > 0 || part.resolvedBy != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingWorkState(args: {
|
||||||
|
requestText?: string | null;
|
||||||
|
responseText?: string | null;
|
||||||
|
latestCodexParts?: ChatMessagePart[] | undefined;
|
||||||
|
}) {
|
||||||
|
if (hasOpenPromptParts(args.latestCodexParts)) {
|
||||||
|
return {
|
||||||
|
isPendingWork: true,
|
||||||
|
pendingWorkReason: 'prompt' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestText = normalizePendingWorkText(args.requestText);
|
||||||
|
const responseText = normalizePendingWorkText(args.responseText);
|
||||||
|
const requestReason = resolvePendingWorkReasonFromText(requestText);
|
||||||
|
|
||||||
|
if (!requestReason) {
|
||||||
|
return {
|
||||||
|
isPendingWork: false,
|
||||||
|
pendingWorkReason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingWorkPattern(responseText, PENDING_WORK_IMPLEMENTATION_PATTERNS)) {
|
||||||
|
return {
|
||||||
|
isPendingWork: false,
|
||||||
|
pendingWorkReason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseText) {
|
||||||
|
return {
|
||||||
|
isPendingWork: true,
|
||||||
|
pendingWorkReason: requestReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseReason = resolvePendingWorkReasonFromText(responseText);
|
||||||
|
|
||||||
|
if (responseReason || hasPendingWorkPattern(responseText, PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
|
||||||
|
return {
|
||||||
|
isPendingWork: true,
|
||||||
|
pendingWorkReason: responseReason ?? requestReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPendingWork: false,
|
||||||
|
pendingWorkReason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
|
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
|
||||||
/이전\s*(채팅|대화|문맥)/u,
|
/이전\s*(채팅|대화|문맥)/u,
|
||||||
/이전\s*요청/u,
|
/이전\s*요청/u,
|
||||||
@@ -279,6 +435,8 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
|
|||||||
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
|
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
|
||||||
currentQueueSize: Number(row.current_queue_size ?? 0),
|
currentQueueSize: Number(row.current_queue_size ?? 0),
|
||||||
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
|
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
|
||||||
|
isPendingWork: false,
|
||||||
|
pendingWorkReason: null,
|
||||||
lastRequestPreview: '',
|
lastRequestPreview: '',
|
||||||
lastMessagePreview: String(row.last_message_preview ?? ''),
|
lastMessagePreview: String(row.last_message_preview ?? ''),
|
||||||
lastResponsePreview: '',
|
lastResponsePreview: '',
|
||||||
@@ -876,6 +1034,40 @@ async function getLatestResponseMessageIdMap(sessionIds: string[]) {
|
|||||||
return responseMap;
|
return responseMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getLatestCodexPromptPartsMap(sessionIds: string[]) {
|
||||||
|
const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));
|
||||||
|
|
||||||
|
if (normalizedSessionIds.length === 0) {
|
||||||
|
return new Map<string, ChatMessagePart[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
|
||||||
|
.select('session_id', 'parts_json', 'created_at', 'message_id')
|
||||||
|
.whereIn('session_id', normalizedSessionIds)
|
||||||
|
.andWhere('author', 'codex')
|
||||||
|
.orderBy('session_id', 'asc')
|
||||||
|
.orderBy('created_at', 'desc')
|
||||||
|
.orderBy('message_id', 'desc');
|
||||||
|
|
||||||
|
const promptPartMap = new Map<string, ChatMessagePart[]>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const sessionId = String(row.session_id ?? '').trim();
|
||||||
|
|
||||||
|
if (!sessionId || promptPartMap.has(sessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = parseChatMessageParts(row.parts_json);
|
||||||
|
|
||||||
|
if ((parts ?? []).some((part) => part.type === 'prompt')) {
|
||||||
|
promptPartMap.set(sessionId, parts ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return promptPartMap;
|
||||||
|
}
|
||||||
|
|
||||||
async function getLatestResponseMessageId(sessionId: string) {
|
async function getLatestResponseMessageId(sessionId: string) {
|
||||||
const responseMap = await getLatestResponseMessageIdMap([sessionId]);
|
const responseMap = await getLatestResponseMessageIdMap([sessionId]);
|
||||||
return responseMap.get(sessionId.trim()) ?? null;
|
return responseMap.get(sessionId.trim()) ?? null;
|
||||||
@@ -1444,17 +1636,26 @@ export async function listChatConversations(
|
|||||||
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
|
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
|
||||||
rows.map((row) => String(row.session_id ?? '')),
|
rows.map((row) => String(row.session_id ?? '')),
|
||||||
);
|
);
|
||||||
|
const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap(
|
||||||
|
rows.map((row) => String(row.session_id ?? '')),
|
||||||
|
);
|
||||||
|
|
||||||
if (!normalizedUnreadStateClientId) {
|
if (!normalizedUnreadStateClientId) {
|
||||||
return rows
|
return rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const mapped = mapConversationRow(row);
|
const mapped = mapConversationRow(row);
|
||||||
|
const pendingWorkState = resolvePendingWorkState({
|
||||||
|
requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||||
|
responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||||
|
latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...resolveConversationPreviewOverride(
|
...resolveConversationPreviewOverride(
|
||||||
mapped,
|
mapped,
|
||||||
latestPreviewMessageMap.get(mapped.sessionId),
|
latestPreviewMessageMap.get(mapped.sessionId),
|
||||||
latestRequestPreviewMap.get(mapped.sessionId),
|
latestRequestPreviewMap.get(mapped.sessionId),
|
||||||
),
|
),
|
||||||
|
...pendingWorkState,
|
||||||
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||||
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||||
hasUnreadResponse: false,
|
hasUnreadResponse: false,
|
||||||
@@ -1489,6 +1690,11 @@ export async function listChatConversations(
|
|||||||
const mapped = mapConversationRow(row);
|
const mapped = mapConversationRow(row);
|
||||||
const preference = preferenceMap.get(mapped.sessionId);
|
const preference = preferenceMap.get(mapped.sessionId);
|
||||||
const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId);
|
const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId);
|
||||||
|
const pendingWorkState = resolvePendingWorkState({
|
||||||
|
requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||||
|
responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
|
||||||
|
latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...resolveConversationPreviewOverride(
|
...resolveConversationPreviewOverride(
|
||||||
@@ -1496,6 +1702,7 @@ export async function listChatConversations(
|
|||||||
latestPreviewMessage,
|
latestPreviewMessage,
|
||||||
latestRequestPreviewMap.get(mapped.sessionId),
|
latestRequestPreviewMap.get(mapped.sessionId),
|
||||||
),
|
),
|
||||||
|
...pendingWorkState,
|
||||||
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||||
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||||
clientId: normalizedUnreadStateClientId,
|
clientId: normalizedUnreadStateClientId,
|
||||||
@@ -1654,7 +1861,7 @@ export async function listChatConversationDetailPage(
|
|||||||
): Promise<ChatConversationDetailPage> {
|
): Promise<ChatConversationDetailPage> {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
|
const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
|
||||||
const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 6)));
|
const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 8)));
|
||||||
const normalizedBeforeMessageId =
|
const normalizedBeforeMessageId =
|
||||||
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
|
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
|
||||||
? Math.trunc(options.beforeMessageId as number)
|
? Math.trunc(options.beforeMessageId as number)
|
||||||
@@ -2435,6 +2642,36 @@ export async function deleteChatConversation(sessionId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearChatConversationData(sessionId: string, clientId?: string | null) {
|
||||||
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del();
|
||||||
|
await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del();
|
||||||
|
await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del();
|
||||||
|
await trx(CHAT_CONVERSATION_CLIENT_TABLE)
|
||||||
|
.where({ session_id: normalizedSessionId })
|
||||||
|
.update({
|
||||||
|
last_read_response_message_id: null,
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
});
|
||||||
|
await trx(CHAT_CONVERSATION_TABLE)
|
||||||
|
.where({ session_id: normalizedSessionId })
|
||||||
|
.update({
|
||||||
|
current_request_id: null,
|
||||||
|
current_job_status: null,
|
||||||
|
current_job_message: null,
|
||||||
|
current_queue_size: 0,
|
||||||
|
current_status_updated_at: null,
|
||||||
|
last_message_preview: '',
|
||||||
|
last_message_at: null,
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return getChatConversation(normalizedSessionId, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
|
export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
|
||||||
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
|
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
|
||||||
.where({
|
.where({
|
||||||
|
|||||||
@@ -149,13 +149,17 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
|||||||
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
||||||
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
|
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
|
||||||
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
|
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
|
||||||
|
assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
|
||||||
|
assert.match(prompt, /`steps` 배열을 추가해/);
|
||||||
|
assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
|
||||||
|
assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
|
||||||
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
|
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
|
||||||
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
|
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
|
||||||
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
|
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
|
||||||
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
|
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureChatSessionReferenceResource creates a persistent per-room markdown resource and preserves manual notes', async () => {
|
test('ensureChatSessionReferenceResource creates a minimal per-room markdown resource without chat memo accumulation', async () => {
|
||||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-'));
|
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-'));
|
||||||
|
|
||||||
const resourcePath = await ensureChatSessionReferenceResource({
|
const resourcePath = await ensureChatSessionReferenceResource({
|
||||||
@@ -182,13 +186,9 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
|
|||||||
const firstContent = await readFile(absolutePath, 'utf8');
|
const firstContent = await readFile(absolutePath, 'utf8');
|
||||||
assert.match(firstContent, /# 채팅방 참고 리소스/);
|
assert.match(firstContent, /# 채팅방 참고 리소스/);
|
||||||
assert.match(firstContent, /## 자동 갱신 문맥/);
|
assert.match(firstContent, /## 자동 갱신 문맥/);
|
||||||
assert.match(firstContent, /## 수동 메모/);
|
assert.doesNotMatch(firstContent, /## 수동 메모/);
|
||||||
|
assert.doesNotMatch(firstContent, /## 최신 사용자 요청/);
|
||||||
const manuallyEditedContent = firstContent.replace(
|
assert.doesNotMatch(firstContent, /## 최근 대화 요약/);
|
||||||
'- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.',
|
|
||||||
'- 유지 메모: 이 줄은 보존되어야 합니다.',
|
|
||||||
);
|
|
||||||
await writeFile(absolutePath, manuallyEditedContent, 'utf8');
|
|
||||||
|
|
||||||
await ensureChatSessionReferenceResource({
|
await ensureChatSessionReferenceResource({
|
||||||
repoPath: tempDir,
|
repoPath: tempDir,
|
||||||
@@ -210,9 +210,8 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
|
|||||||
|
|
||||||
const updatedContent = await readFile(absolutePath, 'utf8');
|
const updatedContent = await readFile(absolutePath, 'utf8');
|
||||||
assert.match(updatedContent, /request-2/);
|
assert.match(updatedContent, /request-2/);
|
||||||
assert.match(updatedContent, /둘째 요청/);
|
assert.doesNotMatch(updatedContent, /둘째 요청/);
|
||||||
assert.match(updatedContent, /이전 1개 메시지는 제외되었습니다\./);
|
assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/);
|
||||||
assert.match(updatedContent, /유지 메모: 이 줄은 보존되어야 합니다\./);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => {
|
test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => {
|
||||||
@@ -249,9 +248,6 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
|
|||||||
"이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {",
|
"이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {",
|
||||||
'<!-- codex-live:auto:end -->',
|
'<!-- codex-live:auto:end -->',
|
||||||
'',
|
'',
|
||||||
'## 수동 메모',
|
|
||||||
'- 유지 메모',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
@@ -277,8 +273,8 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
|
|||||||
const rebuiltContent = await readFile(absolutePath, 'utf8');
|
const rebuiltContent = await readFile(absolutePath, 'utf8');
|
||||||
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
|
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
|
||||||
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/g) ?? []).length, 1);
|
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/g) ?? []).length, 1);
|
||||||
assert.match(rebuiltContent, /셋째 요청/);
|
assert.doesNotMatch(rebuiltContent, /셋째 요청/);
|
||||||
assert.match(rebuiltContent, /## 수동 메모\n- 유지 메모/);
|
assert.doesNotMatch(rebuiltContent, /## 수동 메모/);
|
||||||
assert.doesNotMatch(rebuiltContent, /이전 응답 조각/);
|
assert.doesNotMatch(rebuiltContent, /이전 응답 조각/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,6 +295,282 @@ test('extractChatMessageParts strips link-card markers into structured parts', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('extractChatMessageParts strips prompt markers into structured parts', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractChatMessageParts(
|
||||||
|
[
|
||||||
|
'단계형 선택지를 준비했습니다.',
|
||||||
|
'[[prompt:{"title":"다음 단계 선택","description":"원하는 작업 흐름을 고르세요.","submitLabel":"계속","mode":"queue","options":[{"label":"요약 먼저","value":"summary-first","description":"현황 요약 후 구현합니다."},{"label":"바로 구현","value":"implement-now","description":"확인 없이 바로 수정합니다."}],"responseTemplate":"사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요."}]]',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
strippedText: '단계형 선택지를 준비했습니다.',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
title: '다음 단계 선택',
|
||||||
|
description: '원하는 작업 흐름을 고르세요.',
|
||||||
|
submitLabel: '계속',
|
||||||
|
mode: 'queue',
|
||||||
|
multiple: false,
|
||||||
|
responseTemplate: '사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요.',
|
||||||
|
freeTextLabel: null,
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
currentStepKey: null,
|
||||||
|
readOnly: false,
|
||||||
|
selectedValues: [],
|
||||||
|
resolvedBy: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
resultText: null,
|
||||||
|
steps: undefined,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '요약 먼저',
|
||||||
|
value: 'summary-first',
|
||||||
|
description: '현황 요약 후 구현합니다.',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '바로 구현',
|
||||||
|
value: 'implement-now',
|
||||||
|
description: '확인 없이 바로 수정합니다.',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractChatMessageParts keeps readonly auto-selected prompt state', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractChatMessageParts(
|
||||||
|
[
|
||||||
|
'시안 3개 중 자동 선택 결과입니다.',
|
||||||
|
'[[prompt:{"title":"UI 시안 선택","description":"시간 안에 응답이 없어 자동 선택되었습니다.","readOnly":true,"selectedValues":["option-b"],"resolvedBy":"timeout","resultText":"B안이 기본 시안으로 채택되었습니다.","options":[{"label":"A안","value":"option-a","description":"카드 레이아웃 중심"},{"label":"B안","value":"option-b","description":"탭과 요약 헤더 중심"},{"label":"C안","value":"option-c","description":"하단 플로팅 액션 중심"}]}]]',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
strippedText: '시안 3개 중 자동 선택 결과입니다.',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
title: 'UI 시안 선택',
|
||||||
|
description: '시간 안에 응답이 없어 자동 선택되었습니다.',
|
||||||
|
submitLabel: null,
|
||||||
|
mode: null,
|
||||||
|
multiple: false,
|
||||||
|
responseTemplate: null,
|
||||||
|
freeTextLabel: null,
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
currentStepKey: null,
|
||||||
|
readOnly: true,
|
||||||
|
selectedValues: ['option-b'],
|
||||||
|
resolvedBy: 'timeout',
|
||||||
|
resolvedAt: null,
|
||||||
|
resultText: 'B안이 기본 시안으로 채택되었습니다.',
|
||||||
|
steps: undefined,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'A안',
|
||||||
|
value: 'option-a',
|
||||||
|
description: '카드 레이아웃 중심',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'B안',
|
||||||
|
value: 'option-b',
|
||||||
|
description: '탭과 요약 헤더 중심',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'C안',
|
||||||
|
value: 'option-c',
|
||||||
|
description: '하단 플로팅 액션 중심',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractChatMessageParts(
|
||||||
|
[
|
||||||
|
'시안 미리보기 선택입니다.',
|
||||||
|
'[[prompt:{"title":"시안 선택","options":[{"label":"이미지안","value":"image-a","preview":{"type":"image","url":"https://example.com/a.png","alt":"A"}},{"label":"마크다운안","value":"markdown-b","preview":{"type":"markdown","content":"## B안\\n- 설명"}},{"label":"HTML안","value":"html-c","preview":{"type":"html","content":"<section>C</section>","title":"HTML C"}},{"label":"리소스안","value":"resource-d","preview":{"type":"resource","url":"/api/chat/resources/sample.html","title":"리소스"}}]}]]',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
strippedText: '시안 미리보기 선택입니다.',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
title: '시안 선택',
|
||||||
|
description: null,
|
||||||
|
submitLabel: null,
|
||||||
|
mode: null,
|
||||||
|
multiple: false,
|
||||||
|
responseTemplate: null,
|
||||||
|
freeTextLabel: null,
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
currentStepKey: null,
|
||||||
|
readOnly: false,
|
||||||
|
selectedValues: [],
|
||||||
|
resolvedBy: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
resultText: null,
|
||||||
|
steps: undefined,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '이미지안',
|
||||||
|
value: 'image-a',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'image',
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
content: null,
|
||||||
|
alt: 'A',
|
||||||
|
title: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '마크다운안',
|
||||||
|
value: 'markdown-b',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'markdown',
|
||||||
|
url: null,
|
||||||
|
content: '## B안\n- 설명',
|
||||||
|
alt: null,
|
||||||
|
title: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'HTML안',
|
||||||
|
value: 'html-c',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'html',
|
||||||
|
url: null,
|
||||||
|
content: '<section>C</section>',
|
||||||
|
alt: null,
|
||||||
|
title: 'HTML C',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '리소스안',
|
||||||
|
value: 'resource-d',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'resource',
|
||||||
|
url: '/api/chat/resources/sample.html',
|
||||||
|
content: null,
|
||||||
|
alt: null,
|
||||||
|
title: '리소스',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractChatMessageParts supports stepper prompt steps', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractChatMessageParts(
|
||||||
|
[
|
||||||
|
'단계형 stepper prompt입니다.',
|
||||||
|
'[[prompt:{"title":"구현 흐름 선택","description":"단계별로 실행 범위를 고릅니다.","steps":[{"key":"layout","title":"시안 선택","options":[{"label":"A안","value":"layout-a","description":"기본 레이아웃"},{"label":"B안","value":"layout-b","description":"탭 중심 레이아웃"}]},{"key":"scope","title":"후속 범위","optional":true,"multiple":true,"freeTextLabel":"세부 요청","options":[{"label":"모바일 정리","value":"mobile-cleanup"},{"label":"상태 요약 추가","value":"summary-card"}]}],"responseTemplate":"{{step_summaries}}"}]]',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
strippedText: '단계형 stepper prompt입니다.',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
title: '구현 흐름 선택',
|
||||||
|
description: '단계별로 실행 범위를 고릅니다.',
|
||||||
|
submitLabel: null,
|
||||||
|
mode: null,
|
||||||
|
multiple: false,
|
||||||
|
responseTemplate: '{{step_summaries}}',
|
||||||
|
freeTextLabel: null,
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
currentStepKey: null,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'layout',
|
||||||
|
title: '시안 선택',
|
||||||
|
description: null,
|
||||||
|
submitLabel: null,
|
||||||
|
mode: null,
|
||||||
|
multiple: false,
|
||||||
|
optional: false,
|
||||||
|
responseTemplate: null,
|
||||||
|
freeTextLabel: null,
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
selectedValues: [],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'A안',
|
||||||
|
value: 'layout-a',
|
||||||
|
description: '기본 레이아웃',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'B안',
|
||||||
|
value: 'layout-b',
|
||||||
|
description: '탭 중심 레이아웃',
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scope',
|
||||||
|
title: '후속 범위',
|
||||||
|
description: null,
|
||||||
|
submitLabel: null,
|
||||||
|
mode: null,
|
||||||
|
multiple: true,
|
||||||
|
optional: true,
|
||||||
|
responseTemplate: null,
|
||||||
|
freeTextLabel: '세부 요청',
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
selectedValues: [],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '모바일 정리',
|
||||||
|
value: 'mobile-cleanup',
|
||||||
|
description: null,
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '상태 요약 추가',
|
||||||
|
value: 'summary-card',
|
||||||
|
description: null,
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
readOnly: false,
|
||||||
|
selectedValues: [],
|
||||||
|
resolvedBy: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
resultText: null,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('extractChatMessageParts repairs malformed resource link-card urls and encoded action labels', () => {
|
test('extractChatMessageParts repairs malformed resource link-card urls and encoded action labels', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
extractChatMessageParts(
|
extractChatMessageParts(
|
||||||
@@ -317,6 +589,75 @@ test('extractChatMessageParts repairs malformed resource link-card urls and enco
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('extractChatMessageParts canonicalizes prompt preview resource urls from public paths and absolute filesystem paths', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractChatMessageParts(
|
||||||
|
'[[prompt:{"title":"시안 선택","options":[{"label":"공개경로","value":"public-path","preview":{"type":"resource","url":"public/.codex_chat/chat-room/resource/sample-a.html"}},{"label":"절대경로","value":"absolute-path","preview":{"type":"resource","url":"/home/how2ice/project/ai-code-app/public/.codex_chat/chat-room/resource/sample-b.html"}},{"label":"닷경로","value":"dot-path","preview":{"type":"resource","url":"/.codex_chat/chat-room/resource/sample-c.html"}}]}]]',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
strippedText: '',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
title: '시안 선택',
|
||||||
|
description: null,
|
||||||
|
submitLabel: null,
|
||||||
|
mode: null,
|
||||||
|
multiple: false,
|
||||||
|
responseTemplate: null,
|
||||||
|
freeTextLabel: null,
|
||||||
|
freeTextPlaceholder: null,
|
||||||
|
currentStepKey: null,
|
||||||
|
readOnly: false,
|
||||||
|
selectedValues: [],
|
||||||
|
resolvedBy: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
resultText: null,
|
||||||
|
steps: undefined,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '공개경로',
|
||||||
|
value: 'public-path',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'resource',
|
||||||
|
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-a.html',
|
||||||
|
content: null,
|
||||||
|
alt: null,
|
||||||
|
title: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '절대경로',
|
||||||
|
value: 'absolute-path',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'resource',
|
||||||
|
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-b.html',
|
||||||
|
content: null,
|
||||||
|
alt: null,
|
||||||
|
title: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '닷경로',
|
||||||
|
value: 'dot-path',
|
||||||
|
description: null,
|
||||||
|
preview: {
|
||||||
|
type: 'resource',
|
||||||
|
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-c.html',
|
||||||
|
content: null,
|
||||||
|
alt: null,
|
||||||
|
title: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
|
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
|
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { hasErrorLogViewAccessToken } from './error-log-service.js';
|
|||||||
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
|
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
|
||||||
import { createNotificationMessage } from './notification-message-service.js';
|
import { createNotificationMessage } from './notification-message-service.js';
|
||||||
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
||||||
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||||
import {
|
import {
|
||||||
findLatestPlanItem,
|
findLatestPlanItem,
|
||||||
findPlanItemByPreviewUrl,
|
findPlanItemByPreviewUrl,
|
||||||
@@ -329,6 +330,26 @@ function createChatQuestionAnswerNotificationBody(args: {
|
|||||||
return args.fallback;
|
return args.fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStructuredChatMessage(message: ChatMessage): ChatMessage {
|
||||||
|
if (message.author === 'user') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : [];
|
||||||
|
const extracted = extractChatMessageParts(message.text);
|
||||||
|
const nextParts = existingParts.length > 0 ? existingParts : extracted.parts;
|
||||||
|
|
||||||
|
if (nextParts.length === 0) {
|
||||||
|
return existingParts.length === 0 ? message : { ...message, parts: existingParts };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
text: extracted.strippedText,
|
||||||
|
parts: nextParts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
|
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
|
||||||
const questionPreview = createChatNotificationPreview(questionText ?? '');
|
const questionPreview = createChatNotificationPreview(questionText ?? '');
|
||||||
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
|
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
|
||||||
@@ -1584,9 +1605,6 @@ function buildChatSessionReferenceAutoSection(args: {
|
|||||||
context: ChatContext | null;
|
context: ChatContext | null;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
input: string;
|
|
||||||
recentHistoryLines: string[];
|
|
||||||
omittedHistoryCount: number;
|
|
||||||
}) {
|
}) {
|
||||||
const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청';
|
const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청';
|
||||||
const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음';
|
const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음';
|
||||||
@@ -1594,14 +1612,6 @@ function buildChatSessionReferenceAutoSection(args: {
|
|||||||
const topMenu = args.context?.topMenu?.trim() || '없음';
|
const topMenu = args.context?.topMenu?.trim() || '없음';
|
||||||
const pageUrl = args.context?.pageUrl?.trim() || '없음';
|
const pageUrl = args.context?.pageUrl?.trim() || '없음';
|
||||||
const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
|
const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
|
||||||
const historyLines =
|
|
||||||
args.recentHistoryLines.length > 0
|
|
||||||
? args.recentHistoryLines.map((line) => `- ${line}`)
|
|
||||||
: ['- 최근 대화 없음'];
|
|
||||||
|
|
||||||
if (args.omittedHistoryCount > 0) {
|
|
||||||
historyLines.push(`- 최근 문맥 일부만 포함했습니다. 이전 ${args.omittedHistoryCount}개 메시지는 제외되었습니다.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CHAT_SESSION_REFERENCE_AUTO_START,
|
CHAT_SESSION_REFERENCE_AUTO_START,
|
||||||
@@ -1617,12 +1627,6 @@ function buildChatSessionReferenceAutoSection(args: {
|
|||||||
'',
|
'',
|
||||||
'## 현재 채팅 유형 context',
|
'## 현재 채팅 유형 context',
|
||||||
chatTypeDescription,
|
chatTypeDescription,
|
||||||
'',
|
|
||||||
'## 최신 사용자 요청',
|
|
||||||
args.input.trim() || '없음',
|
|
||||||
'',
|
|
||||||
'## 최근 대화 요약',
|
|
||||||
...historyLines,
|
|
||||||
CHAT_SESSION_REFERENCE_AUTO_END,
|
CHAT_SESSION_REFERENCE_AUTO_END,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -1635,30 +1639,19 @@ function mergeChatSessionReferenceContent(existingContent: string, autoSection:
|
|||||||
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
||||||
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
|
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n');
|
|
||||||
|
|
||||||
if (!trimmedExisting) {
|
if (!trimmedExisting) {
|
||||||
return [
|
return `${defaultHeader}\n\n${autoSection}\n`;
|
||||||
defaultHeader,
|
|
||||||
'',
|
|
||||||
autoSection,
|
|
||||||
'',
|
|
||||||
defaultManualSection,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
|
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
|
||||||
const manualSectionMatch = existingContent.match(/(^|\n)(## 수동 메모[\s\S]*)$/m);
|
|
||||||
const preservedManualSection = manualSectionMatch?.[2]?.trim() || defaultManualSection;
|
|
||||||
|
|
||||||
if (firstAutoStartIndex >= 0) {
|
if (firstAutoStartIndex >= 0) {
|
||||||
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
|
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
|
||||||
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
|
return `${preservedHeader}\n\n${autoSection}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preservedHeader = existingContent.replace(/(^|\n)(## 수동 메모[\s\S]*)$/m, '').trim() || defaultHeader;
|
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
|
||||||
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureChatSessionReferenceResource(args: {
|
export async function ensureChatSessionReferenceResource(args: {
|
||||||
@@ -1677,9 +1670,6 @@ export async function ensureChatSessionReferenceResource(args: {
|
|||||||
context: args.context,
|
context: args.context,
|
||||||
sessionId: args.sessionId,
|
sessionId: args.sessionId,
|
||||||
requestId: args.requestId,
|
requestId: args.requestId,
|
||||||
input: args.input,
|
|
||||||
recentHistoryLines: args.recentHistoryLines,
|
|
||||||
omittedHistoryCount: args.omittedHistoryCount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let existingContent = '';
|
let existingContent = '';
|
||||||
@@ -1765,7 +1755,7 @@ export function buildAgenticCodexPrompt(
|
|||||||
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
|
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
|
||||||
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
|
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
|
||||||
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
|
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
|
||||||
'- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.',
|
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
|
||||||
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
|
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
|
||||||
...buildChatTypeInstructionBlock(context),
|
...buildChatTypeInstructionBlock(context),
|
||||||
'',
|
'',
|
||||||
@@ -1776,6 +1766,7 @@ export function buildAgenticCodexPrompt(
|
|||||||
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
|
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
|
||||||
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
|
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
|
||||||
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
|
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
|
||||||
|
'- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 세션 리소스 아래 실제 `.html` 파일을 만든 뒤 기본값으로 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 형태를 사용하세요. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
|
||||||
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
|
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
|
||||||
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
|
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
|
||||||
'- 한국어로 간결하게 답하세요.',
|
'- 한국어로 간결하게 답하세요.',
|
||||||
@@ -1947,7 +1938,7 @@ async function runAgenticCodexReply(
|
|||||||
onActivity?: (line: string) => void,
|
onActivity?: (line: string) => void,
|
||||||
isCancellationRequested?: () => boolean,
|
isCancellationRequested?: () => boolean,
|
||||||
) {
|
) {
|
||||||
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
|
const repoPath = resolveMainProjectRoot();
|
||||||
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
||||||
const appConfig = await getAppConfigSnapshot();
|
const appConfig = await getAppConfigSnapshot();
|
||||||
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
|
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
|
||||||
@@ -2856,19 +2847,33 @@ export class ChatService {
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (session.isDeleted) {
|
if (session.isDeleted) {
|
||||||
return this.createSessionEnvelope(session, message);
|
const normalizedDeletedMessage =
|
||||||
|
message.type === 'chat:message'
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
payload: normalizeStructuredChatMessage(message.payload),
|
||||||
|
}
|
||||||
|
: message;
|
||||||
|
return this.createSessionEnvelope(session, normalizedDeletedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = this.createSessionEnvelope(session, message);
|
const normalizedMessage =
|
||||||
|
message.type === 'chat:message'
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
payload: normalizeStructuredChatMessage(message.payload),
|
||||||
|
}
|
||||||
|
: message;
|
||||||
|
const envelope = this.createSessionEnvelope(session, normalizedMessage);
|
||||||
this.retainEnvelopeForReplay(session, envelope);
|
this.retainEnvelopeForReplay(session, envelope);
|
||||||
|
|
||||||
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope');
|
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope');
|
||||||
|
|
||||||
if (message.type === 'chat:message') {
|
if (normalizedMessage.type === 'chat:message') {
|
||||||
this.persistConversationMessage(session, message.payload);
|
this.persistConversationMessage(session, normalizedMessage.payload);
|
||||||
|
|
||||||
if (message.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
|
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
|
||||||
void this.sendOfflineNotificationIfNeeded(session, message.payload).catch((error: unknown) => {
|
void this.sendOfflineNotificationIfNeeded(session, normalizedMessage.payload).catch((error: unknown) => {
|
||||||
this.logger.error(error, 'failed to send offline chat notification');
|
this.logger.error(error, 'failed to send offline chat notification');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2878,9 +2883,10 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
|
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
|
||||||
|
const normalizedMessage = normalizeStructuredChatMessage(message);
|
||||||
const envelope = this.createSessionEnvelope(session, {
|
const envelope = this.createSessionEnvelope(session, {
|
||||||
type: 'chat:message:update',
|
type: 'chat:message:update',
|
||||||
payload: message,
|
payload: normalizedMessage,
|
||||||
});
|
});
|
||||||
this.retainEnvelopeForReplay(session, envelope);
|
this.retainEnvelopeForReplay(session, envelope);
|
||||||
|
|
||||||
@@ -2889,8 +2895,8 @@ export class ChatService {
|
|||||||
// Streaming codex deltas and synthesized activity summaries are transient UI state.
|
// Streaming codex deltas and synthesized activity summaries are transient UI state.
|
||||||
// Persist only the final chat message / activity rows to avoid long DB tails that
|
// Persist only the final chat message / activity rows to avoid long DB tails that
|
||||||
// can keep a finished request looking "running" until every intermediate update flushes.
|
// can keep a finished request looking "running" until every intermediate update flushes.
|
||||||
if (shouldPersistMessageUpdate(message)) {
|
if (shouldPersistMessageUpdate(normalizedMessage)) {
|
||||||
this.persistConversationMessage(session, message);
|
this.persistConversationMessage(session, normalizedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return envelope;
|
return envelope;
|
||||||
@@ -3465,6 +3471,26 @@ export class ChatService {
|
|||||||
chatRuntimeService.clearSession(normalizedSessionId);
|
chatRuntimeService.clearSession(normalizedSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSessionData(sessionId: string) {
|
||||||
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
|
if (!normalizedSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.sessions.get(normalizedSessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.queue = [];
|
||||||
|
session.eventHistory = [];
|
||||||
|
session.pendingQueueReleaseEventId = null;
|
||||||
|
session.watchedRuntimeRequestId = null;
|
||||||
|
session.activeRequestCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private handleMessage(socket: WebSocket, raw: RawData) {
|
private handleMessage(socket: WebSocket, raw: RawData) {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(raw.toString()) as ChatInboundMessage;
|
const message = JSON.parse(raw.toString()) as ChatInboundMessage;
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.DEFAULT_CHAT_TYPES = void 0;
|
exports.SEEDED_CUSTOM_CHAT_TYPES = exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = exports.DEFAULT_CHAT_TYPES = void 0;
|
||||||
|
exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = {
|
||||||
|
id: 'plan-checklist-execution',
|
||||||
|
name: 'Plan 체크리스트 실행',
|
||||||
|
description: '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
exports.SEEDED_CUSTOM_CHAT_TYPES = [exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE];
|
||||||
exports.DEFAULT_CHAT_TYPES = [
|
exports.DEFAULT_CHAT_TYPES = [
|
||||||
{
|
{
|
||||||
id: 'general-request',
|
id: 'general-request',
|
||||||
name: '일반 요청',
|
name: '일반 요청',
|
||||||
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||||
permissions: ['token-user'],
|
permissions: ['token-user'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'md-context-managed',
|
||||||
|
name: 'MD 기준 관리',
|
||||||
|
description: '## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'layout-editor-execution',
|
id: 'layout-editor-execution',
|
||||||
|
|||||||
@@ -7,15 +7,38 @@ export type DefaultChatTypeRecord = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
|
||||||
|
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
|
||||||
|
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT =
|
||||||
|
'## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
|
||||||
|
|
||||||
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
||||||
{
|
{
|
||||||
id: 'general-request',
|
id: 'general-request',
|
||||||
name: '일반 요청',
|
name: '일반 요청',
|
||||||
description:
|
description:
|
||||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||||
permissions: ['token-user'],
|
permissions: ['token-user'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'md-context-managed',
|
||||||
|
name: 'MD 기준 관리',
|
||||||
|
description:
|
||||||
|
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chat-maximized-bottom-safe',
|
||||||
|
name: '채팅 최대화 하단 안전영역',
|
||||||
|
description:
|
||||||
|
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'layout-editor-execution',
|
id: 'layout-editor-execution',
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
function normalizeCandidatePath(value: string | null | undefined) {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized ? path.resolve(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidateScore(candidatePath: string) {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (fs.existsSync(path.join(candidatePath, 'AGENTS.md'))) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(path.join(candidatePath, 'package.json'))) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(path.join(candidatePath, 'etc', 'servers', 'work-server'))) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMainProjectRoot() {
|
||||||
|
const candidates = [
|
||||||
|
env.SERVER_COMMAND_MAIN_PROJECT_ROOT,
|
||||||
|
env.PLAN_MAIN_PROJECT_REPO_PATH,
|
||||||
|
env.PLAN_GIT_REPO_PATH,
|
||||||
|
env.SERVER_COMMAND_PROJECT_ROOT,
|
||||||
|
path.resolve(process.cwd(), '../../..'),
|
||||||
|
process.cwd(),
|
||||||
|
'/workspace/main-project',
|
||||||
|
]
|
||||||
|
.map((value) => normalizeCandidatePath(value))
|
||||||
|
.filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
|
||||||
|
|
||||||
|
const existingCandidates = candidates.filter((candidate) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(candidate).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCandidates.length === 0) {
|
||||||
|
return candidates[0] ?? path.resolve(process.cwd(), '../../..');
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingCandidates
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => getCandidateScore(right) - getCandidateScore(left))[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveStaticContentType } from './resource-manager-service.js';
|
||||||
|
|
||||||
|
test('resolveStaticContentType returns html content type for resource manager html files', () => {
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveStaticContentType keeps markdown and text files unchanged', () => {
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.md'), 'text/markdown; charset=utf-8');
|
||||||
|
assert.equal(resolveStaticContentType('/tmp/sample.log'), 'text/plain; charset=utf-8');
|
||||||
|
});
|
||||||
@@ -86,7 +86,7 @@ const TEXT_FILE_EXTENSIONS = new Set([
|
|||||||
'.diff',
|
'.diff',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveStaticContentType(filePath: string) {
|
export function resolveStaticContentType(filePath: string) {
|
||||||
const extension = path.extname(filePath).toLowerCase();
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
switch (extension) {
|
switch (extension) {
|
||||||
@@ -98,7 +98,6 @@ function resolveStaticContentType(filePath: string) {
|
|||||||
case '.cjs':
|
case '.cjs':
|
||||||
case '.json':
|
case '.json':
|
||||||
case '.css':
|
case '.css':
|
||||||
case '.html':
|
|
||||||
case '.txt':
|
case '.txt':
|
||||||
case '.diff':
|
case '.diff':
|
||||||
case '.log':
|
case '.log':
|
||||||
@@ -107,6 +106,9 @@ function resolveStaticContentType(filePath: string) {
|
|||||||
case '.yml':
|
case '.yml':
|
||||||
case '.xml':
|
case '.xml':
|
||||||
return 'text/plain; charset=utf-8';
|
return 'text/plain; charset=utf-8';
|
||||||
|
case '.html':
|
||||||
|
case '.htm':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
case '.md':
|
case '.md':
|
||||||
case '.markdown':
|
case '.markdown':
|
||||||
return 'text/markdown; charset=utf-8';
|
return 'text/markdown; charset=utf-8';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { readFile, rm, stat } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||||
import {
|
import {
|
||||||
getRuntimeWorkServerBuildInfo,
|
getRuntimeWorkServerBuildInfo,
|
||||||
readLatestWorkServerBuildInfo,
|
readLatestWorkServerBuildInfo,
|
||||||
@@ -243,7 +244,7 @@ async function findLatestSourceChangeInPath(rootPath: string, targetPath: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readLatestAppSourceChange() {
|
async function readLatestAppSourceChange() {
|
||||||
const projectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
|
const projectRoot = normalizePath(resolveMainProjectRoot());
|
||||||
let latest: SourceChangeInfo | null = null;
|
let latest: SourceChangeInfo | null = null;
|
||||||
|
|
||||||
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
|
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
|
||||||
@@ -575,7 +576,7 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
|
|||||||
|
|
||||||
function getServerDefinitions(): ServerDefinition[] {
|
function getServerDefinitions(): ServerDefinition[] {
|
||||||
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
||||||
const mainProjectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT);
|
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { getAppConfigSnapshot } from './app-config-service.js';
|
import { getAppConfigSnapshot } from './app-config-service.js';
|
||||||
import { listBoardPosts, type BoardPostItem, type BoardPostRequestItem } from './board-service.js';
|
import { listBoardPosts, type BoardPostItem, type BoardPostRequestItem } from './board-service.js';
|
||||||
import { getActiveChatService } from './chat-service.js';
|
import { getActiveChatService } from './chat-service.js';
|
||||||
import { chatRuntimeService, type ChatRuntimeJobItem } from './chat-runtime-service.js';
|
import { chatRuntimeService, type ChatRuntimeJobItem } from './chat-runtime-service.js';
|
||||||
import { createNotificationMessage, deleteOlderNotificationMessagesBySource } from './notification-message-service.js';
|
import { createNotificationMessage, deleteOlderNotificationMessagesBySource } from './notification-message-service.js';
|
||||||
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||||
import {
|
import {
|
||||||
listServerCommands,
|
listServerCommands,
|
||||||
restartServerCommand,
|
restartServerCommand,
|
||||||
type ServerCommandSnapshot,
|
type ServerCommandSnapshot,
|
||||||
|
type ServerCommandKey,
|
||||||
} from './server-command-service.js';
|
} from './server-command-service.js';
|
||||||
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
|
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
|
||||||
|
|
||||||
@@ -19,9 +23,12 @@ const ACTIVE_CLIENT_WINDOW_MS = 3 * 60 * 1000;
|
|||||||
const TEST_TO_WORK_SERVER_DELAY_MS = 5_000;
|
const TEST_TO_WORK_SERVER_DELAY_MS = 5_000;
|
||||||
const RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
|
const RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
|
||||||
const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservation';
|
const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservation';
|
||||||
|
const RESERVED_RESTART_AUTO_FIX_SESSION_ID = 'server-restart-reservation';
|
||||||
|
const RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS = 600;
|
||||||
|
const RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS = 180;
|
||||||
|
|
||||||
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'completed' | 'cancelled' | 'failed';
|
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'recovering' | 'completed' | 'cancelled' | 'failed';
|
||||||
type RestartReservationTarget = 'all';
|
type RestartReservationTarget = 'all' | 'test' | 'work-server';
|
||||||
|
|
||||||
type RestartReservationWorkloadSummary = {
|
type RestartReservationWorkloadSummary = {
|
||||||
codexRunningCount: number;
|
codexRunningCount: number;
|
||||||
@@ -30,6 +37,31 @@ type RestartReservationWorkloadSummary = {
|
|||||||
automationQueuedCount: number;
|
automationQueuedCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RestartReservationWorkItem = {
|
||||||
|
kind: 'codex' | 'automation';
|
||||||
|
status: 'running' | 'queued' | 'waiting';
|
||||||
|
title: string;
|
||||||
|
detail: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RestartReservationAutoFixStatus = 'idle' | 'queued' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
type RestartReservationAutoFix = {
|
||||||
|
enabled: boolean;
|
||||||
|
targetKey: 'test' | 'work-server' | null;
|
||||||
|
requestId: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
status: RestartReservationAutoFixStatus;
|
||||||
|
summary: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
requestedAt: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
failedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type RestartReservationRow = {
|
type RestartReservationRow = {
|
||||||
id: number;
|
id: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -50,6 +82,7 @@ type RestartReservationRow = {
|
|||||||
auto_execute_at: string | null;
|
auto_execute_at: string | null;
|
||||||
auto_execute_delay_seconds: number | null;
|
auto_execute_delay_seconds: number | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
auto_fix_json: RestartReservationAutoFix | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServerRestartReservationSnapshot = {
|
export type ServerRestartReservationSnapshot = {
|
||||||
@@ -72,6 +105,8 @@ export type ServerRestartReservationSnapshot = {
|
|||||||
autoExecuteAt: string | null;
|
autoExecuteAt: string | null;
|
||||||
autoExecuteDelaySeconds: number;
|
autoExecuteDelaySeconds: number;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
workItems: RestartReservationWorkItem[];
|
||||||
|
autoFix: RestartReservationAutoFix;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
||||||
@@ -83,6 +118,22 @@ function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultAutoFixState(): RestartReservationAutoFix {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
targetKey: null,
|
||||||
|
requestId: null,
|
||||||
|
sessionId: null,
|
||||||
|
status: 'idle',
|
||||||
|
summary: null,
|
||||||
|
detail: null,
|
||||||
|
requestedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
failedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hasAcceptedAutomationRequest(requestItem: Pick<BoardPostRequestItem, 'planItemId' | 'automationReceivedAt' | 'workflowState'>) {
|
function hasAcceptedAutomationRequest(requestItem: Pick<BoardPostRequestItem, 'planItemId' | 'automationReceivedAt' | 'workflowState'>) {
|
||||||
return Boolean(requestItem.planItemId) || Boolean(requestItem.automationReceivedAt) || requestItem.workflowState !== 'pending';
|
return Boolean(requestItem.planItemId) || Boolean(requestItem.automationReceivedAt) || requestItem.workflowState !== 'pending';
|
||||||
}
|
}
|
||||||
@@ -168,7 +219,48 @@ function buildNextCheckAt(row: RestartReservationRow | null | undefined) {
|
|||||||
return new Date(baseTime + SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS).toISOString();
|
return new Date(baseTime + SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapReservationRow(row: RestartReservationRow | null | undefined): ServerRestartReservationSnapshot {
|
function parseAutoFixState(rawValue: RestartReservationRow['auto_fix_json']): RestartReservationAutoFix {
|
||||||
|
if (!rawValue) {
|
||||||
|
return getDefaultAutoFixState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
try {
|
||||||
|
return parseAutoFixState(JSON.parse(rawValue) as RestartReservationAutoFix);
|
||||||
|
} catch {
|
||||||
|
return getDefaultAutoFixState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = rawValue as Partial<RestartReservationAutoFix>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: value.enabled === true,
|
||||||
|
targetKey: value.targetKey === 'test' || value.targetKey === 'work-server' ? value.targetKey : null,
|
||||||
|
requestId: typeof value.requestId === 'string' ? value.requestId : null,
|
||||||
|
sessionId: typeof value.sessionId === 'string' ? value.sessionId : null,
|
||||||
|
status:
|
||||||
|
value.status === 'queued'
|
||||||
|
|| value.status === 'running'
|
||||||
|
|| value.status === 'completed'
|
||||||
|
|| value.status === 'failed'
|
||||||
|
? value.status
|
||||||
|
: 'idle',
|
||||||
|
summary: typeof value.summary === 'string' ? value.summary : null,
|
||||||
|
detail: typeof value.detail === 'string' ? value.detail : null,
|
||||||
|
requestedAt: typeof value.requestedAt === 'string' ? value.requestedAt : null,
|
||||||
|
startedAt: typeof value.startedAt === 'string' ? value.startedAt : null,
|
||||||
|
completedAt: typeof value.completedAt === 'string' ? value.completedAt : null,
|
||||||
|
failedAt: typeof value.failedAt === 'string' ? value.failedAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReservationRow(
|
||||||
|
row: RestartReservationRow | null | undefined,
|
||||||
|
options?: {
|
||||||
|
workItems?: RestartReservationWorkItem[];
|
||||||
|
},
|
||||||
|
): ServerRestartReservationSnapshot {
|
||||||
const rawSummary = row?.workload_summary_json;
|
const rawSummary = row?.workload_summary_json;
|
||||||
let workloadSummary = getDefaultWorkloadSummary();
|
let workloadSummary = getDefaultWorkloadSummary();
|
||||||
|
|
||||||
@@ -193,6 +285,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(row?.enabled),
|
enabled: Boolean(row?.enabled),
|
||||||
target: row?.target === 'all' ? 'all' : 'all',
|
target: row?.target === 'all' ? 'all' : 'all',
|
||||||
@@ -213,6 +307,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
|
|||||||
autoExecuteAt: row?.auto_execute_at ?? null,
|
autoExecuteAt: row?.auto_execute_at ?? null,
|
||||||
autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)),
|
autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)),
|
||||||
updatedAt: row?.updated_at ?? null,
|
updatedAt: row?.updated_at ?? null,
|
||||||
|
workItems: options?.workItems ?? [],
|
||||||
|
autoFix,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +335,7 @@ async function ensureServerRestartReservationTable() {
|
|||||||
table.string('app_origin', 255).nullable();
|
table.string('app_origin', 255).nullable();
|
||||||
table.timestamp('auto_execute_at', { useTz: true }).nullable();
|
table.timestamp('auto_execute_at', { useTz: true }).nullable();
|
||||||
table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10);
|
table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10);
|
||||||
|
table.jsonb('auto_fix_json').notNullable().defaultTo('{}');
|
||||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,6 +344,7 @@ async function ensureServerRestartReservationTable() {
|
|||||||
['app_origin', (table) => table.string('app_origin', 255).nullable()],
|
['app_origin', (table) => table.string('app_origin', 255).nullable()],
|
||||||
['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()],
|
['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()],
|
||||||
['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)],
|
['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)],
|
||||||
|
['auto_fix_json', (table) => table.jsonb('auto_fix_json').notNullable().defaultTo('{}')],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [columnName, addColumn] of requiredColumns) {
|
for (const [columnName, addColumn] of requiredColumns) {
|
||||||
@@ -268,6 +366,7 @@ async function ensureServerRestartReservationTable() {
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
workload_summary_json: getDefaultWorkloadSummary(),
|
workload_summary_json: getDefaultWorkloadSummary(),
|
||||||
active_client_count: 0,
|
active_client_count: 0,
|
||||||
|
auto_fix_json: getDefaultAutoFixState(),
|
||||||
updated_at: db.fn.now(),
|
updated_at: db.fn.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -295,6 +394,115 @@ async function countPendingAutomationWork() {
|
|||||||
return summarizeRestartReservationAutomationWork(await listBoardPosts());
|
return summarizeRestartReservationAutomationWork(await listBoardPosts());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listRestartReservationWorkItems(): Promise<RestartReservationWorkItem[]> {
|
||||||
|
const runtimeSnapshot = chatRuntimeService.getSnapshot();
|
||||||
|
const codexRunningItems = runtimeSnapshot.running.slice(0, 4).map((item) => ({
|
||||||
|
kind: 'codex' as const,
|
||||||
|
status: 'running' as const,
|
||||||
|
title: item.summary || 'Codex Live 요청',
|
||||||
|
detail: item.startedAt ? `실행 시작 ${item.startedAt}` : null,
|
||||||
|
requestId: item.requestId,
|
||||||
|
sessionId: item.sessionId,
|
||||||
|
}));
|
||||||
|
const codexQueuedItems = runtimeSnapshot.queued.slice(0, 4).map((item) => ({
|
||||||
|
kind: 'codex' as const,
|
||||||
|
status: 'queued' as const,
|
||||||
|
title: item.summary || 'Codex Live 요청',
|
||||||
|
detail: item.enqueuedAt ? `대기 등록 ${item.enqueuedAt}` : null,
|
||||||
|
requestId: item.requestId,
|
||||||
|
sessionId: item.sessionId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const boardPosts = await listBoardPosts();
|
||||||
|
const automationItems = boardPosts.flatMap((post) =>
|
||||||
|
post.requestItems
|
||||||
|
.filter((requestItem) => requestItem.status === 'in_progress' || requestItem.status === 'queued' || requestItem.status === 'waiting')
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((requestItem) => ({
|
||||||
|
kind: 'automation' as const,
|
||||||
|
status:
|
||||||
|
requestItem.status === 'in_progress'
|
||||||
|
? 'running' as const
|
||||||
|
: requestItem.status === 'queued'
|
||||||
|
? 'queued' as const
|
||||||
|
: 'waiting' as const,
|
||||||
|
title: requestItem.title.trim() || post.title.trim() || `자동화 요청 #${requestItem.id}`,
|
||||||
|
detail: [post.title.trim() || null, requestItem.statusLabel.trim() || null].filter(Boolean).join(' · ') || null,
|
||||||
|
requestId: requestItem.planItemId ? String(requestItem.planItemId) : null,
|
||||||
|
sessionId: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...codexRunningItems, ...codexQueuedItems, ...automationItems.slice(0, 6)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRunnerUrl(value: string) {
|
||||||
|
return value.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommandRunnerApiCandidates(requestPath: string) {
|
||||||
|
const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health';
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(configuredHealthUrl);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostVariants =
|
||||||
|
parsedUrl.hostname === 'host.docker.internal'
|
||||||
|
? ['host.docker.internal', '127.0.0.1', 'localhost']
|
||||||
|
: parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost'
|
||||||
|
? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal']
|
||||||
|
: [parsedUrl.hostname];
|
||||||
|
|
||||||
|
const deduped: string[] = [];
|
||||||
|
|
||||||
|
for (const hostname of hostVariants) {
|
||||||
|
const candidate = new URL(parsedUrl.toString());
|
||||||
|
candidate.hostname = hostname;
|
||||||
|
candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
|
||||||
|
candidate.search = '';
|
||||||
|
candidate.hash = '';
|
||||||
|
const serialized = normalizeRunnerUrl(candidate.toString());
|
||||||
|
|
||||||
|
if (!deduped.includes(serialized)) {
|
||||||
|
deduped.push(serialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCommandRunner(requestPath: string, init?: RequestInit) {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
|
||||||
|
if (init?.body != null && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) {
|
||||||
|
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const url of buildCommandRunnerApiCandidates(requestPath)) {
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
|
|
||||||
@@ -312,6 +520,224 @@ function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
|||||||
return reasons.length > 0 ? `${reasons.join(', ')} 진행 중이라 재기동을 대기합니다.` : null;
|
return reasons.length > 0 ? `${reasons.join(', ')} 진행 중이라 재기동을 대기합니다.` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRestartBuildFailure(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
return /(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReservedRestartAutoFixPrompt(args: {
|
||||||
|
targetKey: 'test' | 'work-server';
|
||||||
|
failureMessage: string;
|
||||||
|
}) {
|
||||||
|
const repoPath = resolveMainProjectRoot();
|
||||||
|
const targetLabel = args.targetKey === 'test' ? 'TEST 앱' : 'WORK-SERVER';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`당신은 ${repoPath} 저장소에서 ${targetLabel} 재기동 빌드 실패를 자동 복구하는 Codex 실행기입니다.`,
|
||||||
|
'반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
|
||||||
|
'목표는 현재 로컬 main 기준으로 재기동을 막는 빌드 오류를 직접 수정하는 것입니다.',
|
||||||
|
'필요한 범위만 수정하고, 불필요한 Git 작업은 하지 마세요.',
|
||||||
|
`현재 실패 대상: ${targetLabel}`,
|
||||||
|
'실패 로그:',
|
||||||
|
args.failureMessage,
|
||||||
|
'작업 지시:',
|
||||||
|
'1. 빌드 실패 원인을 확인합니다.',
|
||||||
|
'2. 현재 저장소에서 직접 수정합니다.',
|
||||||
|
'3. 대상 서버 재기동을 막는 빌드 오류가 해결되었는지 관련 빌드/검증 명령으로 확인합니다.',
|
||||||
|
'4. 최종 답변은 한국어로 간결하게 작성합니다.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateReservationAutoFixState(patch: Partial<RestartReservationAutoFix>) {
|
||||||
|
const row = await readReservationRow();
|
||||||
|
const current = parseAutoFixState(row?.auto_fix_json ?? null);
|
||||||
|
const nextState: RestartReservationAutoFix = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
|
||||||
|
return updateReservationRow({
|
||||||
|
auto_fix_json: nextState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReservedRestartAutoFix(
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
args: {
|
||||||
|
targetKey: 'test' | 'work-server';
|
||||||
|
failureMessage: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const repoPath = resolveMainProjectRoot();
|
||||||
|
const requestId = `server-restart-fix-${args.targetKey}-${Date.now().toString(36)}`;
|
||||||
|
const sessionId = RESERVED_RESTART_AUTO_FIX_SESSION_ID;
|
||||||
|
const prompt = buildReservedRestartAutoFixPrompt(args);
|
||||||
|
|
||||||
|
await updateReservationAutoFixState({
|
||||||
|
enabled: true,
|
||||||
|
targetKey: args.targetKey,
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
status: 'queued',
|
||||||
|
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선 요청을 준비 중입니다.`,
|
||||||
|
detail: args.failureMessage,
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
failedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await requestCommandRunner('/api/codex-live/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
repoPath,
|
||||||
|
prompt,
|
||||||
|
resourceDir: path.join(
|
||||||
|
repoPath,
|
||||||
|
'public',
|
||||||
|
'.codex_chat',
|
||||||
|
sessionId,
|
||||||
|
'resource',
|
||||||
|
),
|
||||||
|
uploadDir: path.join(
|
||||||
|
repoPath,
|
||||||
|
'public',
|
||||||
|
'.codex_chat',
|
||||||
|
sessionId,
|
||||||
|
'resource',
|
||||||
|
'uploads',
|
||||||
|
),
|
||||||
|
maxExecutionSeconds: RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS,
|
||||||
|
idleTimeoutSeconds: RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const message = (await response.text().catch(() => '')) || 'Codex 자동 개선 요청을 시작하지 못했습니다.';
|
||||||
|
await updateReservationAutoFixState({
|
||||||
|
enabled: true,
|
||||||
|
targetKey: args.targetKey,
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
status: 'failed',
|
||||||
|
summary: `${args.targetKey.toUpperCase()} 자동 개선 요청 시작 실패`,
|
||||||
|
detail: message,
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
let buffer = '';
|
||||||
|
let completedText = '';
|
||||||
|
let remoteError = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof event.type === 'string' ? event.type : '';
|
||||||
|
|
||||||
|
if (type === 'started') {
|
||||||
|
await updateReservationAutoFixState({
|
||||||
|
enabled: true,
|
||||||
|
targetKey: args.targetKey,
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
status: 'running',
|
||||||
|
summary: `${args.targetKey.toUpperCase()} 빌드 오류를 Codex가 분석 중입니다.`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'activity' || type === 'stdout' || type === 'stderr') {
|
||||||
|
const lineText = String(event.line ?? '').trim();
|
||||||
|
|
||||||
|
if (lineText) {
|
||||||
|
await updateReservationAutoFixState({
|
||||||
|
enabled: true,
|
||||||
|
targetKey: args.targetKey,
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
status: 'running',
|
||||||
|
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선 진행 중`,
|
||||||
|
detail: lineText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'completed') {
|
||||||
|
completedText = String(event.text ?? '').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
remoteError = String(event.message ?? '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteError) {
|
||||||
|
await updateReservationAutoFixState({
|
||||||
|
enabled: true,
|
||||||
|
targetKey: args.targetKey,
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
status: 'failed',
|
||||||
|
summary: `${args.targetKey.toUpperCase()} 자동 개선 실패`,
|
||||||
|
detail: remoteError,
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
throw new Error(remoteError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateReservationAutoFixState({
|
||||||
|
enabled: true,
|
||||||
|
targetKey: args.targetKey,
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
status: 'completed',
|
||||||
|
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선을 마쳤습니다.`,
|
||||||
|
detail: completedText || 'Codex 자동 개선이 완료되었습니다. 재기동을 다시 시도합니다.',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ requestId, targetKey: args.targetKey }, 'Reserved restart auto fix completed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
completedText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function listActiveClients() {
|
async function listActiveClients() {
|
||||||
await ensureVisitorHistoryTables();
|
await ensureVisitorHistoryTables();
|
||||||
const visitors = await listVisitorClients(50);
|
const visitors = await listVisitorClients(50);
|
||||||
@@ -452,6 +878,129 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
|
|||||||
return mapReservationRow(nextRow);
|
return mapReservationRow(nextRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restartReservedTargetWithRecovery(
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
targetKey: 'test' | 'work-server',
|
||||||
|
startMessage: string,
|
||||||
|
) {
|
||||||
|
await updateReservationRow({
|
||||||
|
enabled: true,
|
||||||
|
status: 'executing',
|
||||||
|
waiting_reason: startMessage,
|
||||||
|
last_checked_at: db.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartServerCommand(targetKey);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '재기동에 실패했습니다.';
|
||||||
|
|
||||||
|
if (!isRestartBuildFailure(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ err: error, targetKey }, 'Reserved restart build failure detected, requesting Codex auto fix');
|
||||||
|
await updateReservationRow({
|
||||||
|
enabled: true,
|
||||||
|
status: 'recovering',
|
||||||
|
waiting_reason: `${targetKey.toUpperCase()} 재기동 빌드 실패를 감지해 Codex 자동 개선을 시작합니다.`,
|
||||||
|
last_checked_at: db.fn.now(),
|
||||||
|
last_error: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runReservedRestartAutoFix(logger, {
|
||||||
|
targetKey,
|
||||||
|
failureMessage: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateReservationRow({
|
||||||
|
enabled: true,
|
||||||
|
status: 'executing',
|
||||||
|
waiting_reason: `${targetKey.toUpperCase()} 빌드 오류를 수정해 재기동을 다시 시도합니다.`,
|
||||||
|
last_checked_at: db.fn.now(),
|
||||||
|
last_error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await restartServerCommand(targetKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeSingleServerRestart(targetKey: 'test' | 'work-server') {
|
||||||
|
const nextRow = await updateReservationRow({
|
||||||
|
enabled: false,
|
||||||
|
target: targetKey,
|
||||||
|
status: 'completed',
|
||||||
|
completed_at: db.fn.now(),
|
||||||
|
waiting_reason: null,
|
||||||
|
workload_summary_json: getDefaultWorkloadSummary(),
|
||||||
|
last_error: null,
|
||||||
|
last_checked_at: db.fn.now(),
|
||||||
|
auto_execute_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapReservationRow(nextRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
let immediateRecoveryPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export async function requestImmediateRestartRecovery(
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
targetKey: 'test' | 'work-server',
|
||||||
|
failureMessage: string,
|
||||||
|
) {
|
||||||
|
await updateReservationRow({
|
||||||
|
enabled: true,
|
||||||
|
target: targetKey,
|
||||||
|
status: 'recovering',
|
||||||
|
requested_at: db.fn.now(),
|
||||||
|
requested_by_client_id: null,
|
||||||
|
waiting_reason: `${targetKey.toUpperCase()} 재기동 빌드 실패를 감지해 Codex 자동 개선을 시작합니다.`,
|
||||||
|
workload_summary_json: getDefaultWorkloadSummary(),
|
||||||
|
started_at: db.fn.now(),
|
||||||
|
completed_at: null,
|
||||||
|
cancelled_at: null,
|
||||||
|
last_error: failureMessage,
|
||||||
|
active_client_count: 0,
|
||||||
|
notified_active_clients_at: null,
|
||||||
|
app_origin: null,
|
||||||
|
auto_execute_at: null,
|
||||||
|
auto_execute_delay_seconds: 10,
|
||||||
|
last_checked_at: db.fn.now(),
|
||||||
|
auto_fix_json: getDefaultAutoFixState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (immediateRecoveryPromise) {
|
||||||
|
return getServerRestartReservation();
|
||||||
|
}
|
||||||
|
|
||||||
|
immediateRecoveryPromise = (async () => {
|
||||||
|
try {
|
||||||
|
await restartReservedTargetWithRecovery(
|
||||||
|
logger,
|
||||||
|
targetKey,
|
||||||
|
`${targetKey.toUpperCase()} 빌드 오류를 자동 수정한 뒤 재기동을 다시 시도합니다.`,
|
||||||
|
);
|
||||||
|
await finalizeSingleServerRestart(targetKey);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '즉시 재기동 자동 복구에 실패했습니다.';
|
||||||
|
logger.error({ err: error, targetKey }, 'Immediate restart recovery failed');
|
||||||
|
await updateReservationRow({
|
||||||
|
enabled: false,
|
||||||
|
target: targetKey,
|
||||||
|
status: 'failed',
|
||||||
|
waiting_reason: null,
|
||||||
|
last_error: message,
|
||||||
|
last_checked_at: db.fn.now(),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
} finally {
|
||||||
|
immediateRecoveryPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return getServerRestartReservation();
|
||||||
|
}
|
||||||
|
|
||||||
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
|
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
|
||||||
const activeClients = await listActiveClients();
|
const activeClients = await listActiveClients();
|
||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
@@ -494,7 +1043,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
|||||||
'Executing reserved restart',
|
'Executing reserved restart',
|
||||||
);
|
);
|
||||||
|
|
||||||
await restartServerCommand('test');
|
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
||||||
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
||||||
|
|
||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
@@ -504,11 +1053,21 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
|||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await restartServerCommand('work-server');
|
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerRestartReservation() {
|
export async function getServerRestartReservation() {
|
||||||
return mapReservationRow(await readReservationRow());
|
const row = await readReservationRow();
|
||||||
|
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
|
||||||
|
const shouldExposeWorkItems =
|
||||||
|
Boolean(row?.enabled)
|
||||||
|
|| row?.status === 'waiting'
|
||||||
|
|| row?.status === 'ready'
|
||||||
|
|| row?.status === 'executing'
|
||||||
|
|| row?.status === 'recovering'
|
||||||
|
|| autoFix.enabled;
|
||||||
|
const workItems = shouldExposeWorkItems ? await listRestartReservationWorkItems() : [];
|
||||||
|
return mapReservationRow(row, { workItems });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleServerRestartReservation(options?: {
|
export async function scheduleServerRestartReservation(options?: {
|
||||||
@@ -535,6 +1094,7 @@ export async function scheduleServerRestartReservation(options?: {
|
|||||||
app_origin: options?.appOrigin?.trim() || null,
|
app_origin: options?.appOrigin?.trim() || null,
|
||||||
auto_execute_at: null,
|
auto_execute_at: null,
|
||||||
auto_execute_delay_seconds: autoExecuteDelaySeconds,
|
auto_execute_delay_seconds: autoExecuteDelaySeconds,
|
||||||
|
auto_fix_json: getDefaultAutoFixState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapReservationRow(row);
|
return mapReservationRow(row);
|
||||||
@@ -550,6 +1110,7 @@ export async function cancelServerRestartReservation() {
|
|||||||
active_client_count: 0,
|
active_client_count: 0,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
auto_execute_at: null,
|
auto_execute_at: null,
|
||||||
|
auto_fix_json: getDefaultAutoFixState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapReservationRow(row);
|
return mapReservationRow(row);
|
||||||
@@ -580,6 +1141,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
|
|||||||
waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.',
|
waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.',
|
||||||
last_error: null,
|
last_error: null,
|
||||||
auto_execute_at: null,
|
auto_execute_at: null,
|
||||||
|
auto_fix_json: getDefaultAutoFixState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!nextRow) {
|
if (!nextRow) {
|
||||||
@@ -638,6 +1200,10 @@ export class ServerRestartReservationWorker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (row.status === 'recovering') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (row.status === 'executing' && row.started_at) {
|
if (row.status === 'executing' && row.started_at) {
|
||||||
await finalizeReservedRestart(row);
|
await finalizeReservedRestart(row);
|
||||||
return;
|
return;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -205,7 +205,7 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a 300%+ volume jump over the previous snapshot', () => {
|
test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks whose current jump beats the recent 5-record max by the configured ratio', () => {
|
||||||
const items: StockAlertItem[] = [
|
const items: StockAlertItem[] = [
|
||||||
{
|
{
|
||||||
id: '290550',
|
id: '290550',
|
||||||
@@ -300,6 +300,59 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'290550',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '290550:1',
|
||||||
|
stockCode: '290550',
|
||||||
|
stockName: '디케이티',
|
||||||
|
baselineVolume: 100000,
|
||||||
|
currentVolume: 160000,
|
||||||
|
volumeIncreasePercent: 60,
|
||||||
|
currentPrice: 25000,
|
||||||
|
changeRate: 3,
|
||||||
|
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
createdAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'005930',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '005930:1',
|
||||||
|
stockCode: '005930',
|
||||||
|
stockName: '삼성전자',
|
||||||
|
baselineVolume: 130000,
|
||||||
|
currentVolume: 200000,
|
||||||
|
volumeIncreasePercent: 53.85,
|
||||||
|
currentPrice: 205000,
|
||||||
|
changeRate: 2,
|
||||||
|
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
createdAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'035420',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '035420:1',
|
||||||
|
stockCode: '035420',
|
||||||
|
stockName: 'NAVER',
|
||||||
|
baselineVolume: 120000,
|
||||||
|
currentVolume: 190000,
|
||||||
|
volumeIncreasePercent: 58.33,
|
||||||
|
currentPrice: 240000,
|
||||||
|
changeRate: -3,
|
||||||
|
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
createdAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
thresholdPercent: 3,
|
thresholdPercent: 3,
|
||||||
minVolumeIncreasePercent: 300,
|
minVolumeIncreasePercent: 300,
|
||||||
@@ -315,7 +368,8 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
|
|||||||
currentVolume: 400000,
|
currentVolume: 400000,
|
||||||
previousVolume: 100000,
|
previousVolume: 100000,
|
||||||
volumeIncreasePercent: 300,
|
volumeIncreasePercent: 300,
|
||||||
volumeAmplificationPercent: 300,
|
recentMaxVolumeIncreasePercent: 60,
|
||||||
|
volumeAmplificationGrowthPercent: 400,
|
||||||
quotedAt: '2026-05-06T00:30:00.000Z',
|
quotedAt: '2026-05-06T00:30:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -358,6 +412,25 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'290550',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '290550:1',
|
||||||
|
stockCode: '290550',
|
||||||
|
stockName: '디케이티',
|
||||||
|
baselineVolume: 100000,
|
||||||
|
currentVolume: 150000,
|
||||||
|
volumeIncreasePercent: 50,
|
||||||
|
currentPrice: 25000,
|
||||||
|
changeRate: 3,
|
||||||
|
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
createdAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
thresholdPercent: 3,
|
thresholdPercent: 3,
|
||||||
minVolumeIncreasePercent: 50,
|
minVolumeIncreasePercent: 50,
|
||||||
@@ -373,13 +446,14 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
|
|||||||
currentVolume: 350000,
|
currentVolume: 350000,
|
||||||
previousVolume: 200000,
|
previousVolume: 200000,
|
||||||
volumeIncreasePercent: 75,
|
volumeIncreasePercent: 75,
|
||||||
volumeAmplificationPercent: 50,
|
recentMaxVolumeIncreasePercent: 50,
|
||||||
|
volumeAmplificationGrowthPercent: 50,
|
||||||
quotedAt: '2026-05-06T00:30:00.000Z',
|
quotedAt: '2026-05-06T00:30:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day average volume when no previous snapshot exists', () => {
|
test('buildChangeRateAndVolumeSpikeStockAlertCandidates skips stocks without a previous batch snapshot baseline', () => {
|
||||||
const items: StockAlertItem[] = [
|
const items: StockAlertItem[] = [
|
||||||
{
|
{
|
||||||
id: '290550',
|
id: '290550',
|
||||||
@@ -411,24 +485,35 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(items, new Map(), {
|
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||||
thresholdPercent: 3,
|
items,
|
||||||
minVolumeIncreasePercent: 300,
|
new Map(),
|
||||||
});
|
new Map([
|
||||||
|
[
|
||||||
assert.deepEqual(candidates, [
|
'290550',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '290550:1',
|
||||||
|
stockCode: '290550',
|
||||||
|
stockName: '디케이티',
|
||||||
|
baselineVolume: 100000,
|
||||||
|
currentVolume: 175000,
|
||||||
|
volumeIncreasePercent: 75,
|
||||||
|
currentPrice: 25000,
|
||||||
|
changeRate: 3,
|
||||||
|
quotedAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
createdAt: '2026-05-05T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
stockCode: '290550',
|
thresholdPercent: 3,
|
||||||
stockName: '디케이티',
|
minVolumeIncreasePercent: 300,
|
||||||
currentPrice: 26500,
|
|
||||||
changeRate: 11.11,
|
|
||||||
currentVolume: 400000,
|
|
||||||
previousVolume: 100000,
|
|
||||||
volumeIncreasePercent: 300,
|
|
||||||
volumeAmplificationPercent: 300,
|
|
||||||
quotedAt: '2026-05-06T00:30:00.000Z',
|
|
||||||
},
|
},
|
||||||
]);
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(candidates, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {
|
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
|
|||||||
|
|
||||||
export const STOCK_ALERT_TABLE = 'stock_alerts';
|
export const STOCK_ALERT_TABLE = 'stock_alerts';
|
||||||
export const STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots';
|
export const STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots';
|
||||||
|
export const STOCK_ALERT_VOLUME_HISTORY_TABLE = 'stock_alert_volume_histories';
|
||||||
export const STOCK_ALERT_LAYOUT_NAME = 'stock알림';
|
export const STOCK_ALERT_LAYOUT_NAME = 'stock알림';
|
||||||
|
|
||||||
export const STOCK_ALERT_TYPE_OPTIONS = [
|
export const STOCK_ALERT_TYPE_OPTIONS = [
|
||||||
@@ -73,6 +74,32 @@ export type StockAlertVolumeSnapshot = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StockAlertVolumeHistoryRow = {
|
||||||
|
id: string;
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
baseline_volume: number | string | null;
|
||||||
|
current_volume: number | string | null;
|
||||||
|
volume_increase_percent: number | string | null;
|
||||||
|
current_price: number | string | null;
|
||||||
|
change_rate: number | string | null;
|
||||||
|
quoted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockAlertVolumeHistory = {
|
||||||
|
id: string;
|
||||||
|
stockCode: string;
|
||||||
|
stockName: string;
|
||||||
|
baselineVolume: number | null;
|
||||||
|
currentVolume: number | null;
|
||||||
|
volumeIncreasePercent: number | null;
|
||||||
|
currentPrice: number | null;
|
||||||
|
changeRate: number | null;
|
||||||
|
quotedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type StockAlertVolumeSpikeCandidate = {
|
export type StockAlertVolumeSpikeCandidate = {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
stockName: string;
|
stockName: string;
|
||||||
@@ -81,7 +108,8 @@ export type StockAlertVolumeSpikeCandidate = {
|
|||||||
currentVolume: number | null;
|
currentVolume: number | null;
|
||||||
previousVolume: number | null;
|
previousVolume: number | null;
|
||||||
volumeIncreasePercent: number | null;
|
volumeIncreasePercent: number | null;
|
||||||
volumeAmplificationPercent: number | null;
|
recentMaxVolumeIncreasePercent: number | null;
|
||||||
|
volumeAmplificationGrowthPercent: number | null;
|
||||||
quotedAt: string | null;
|
quotedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,50 +373,17 @@ function calculateVolumeIncreasePercent(currentVolume: number | null, previousVo
|
|||||||
return ((currentVolume - previousVolume) / previousVolume) * 100;
|
return ((currentVolume - previousVolume) / previousVolume) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateVolumeAmplificationPercent(
|
function calculateVolumeAmplificationGrowthPercent(currentIncreasePercent: number | null, recentMaxIncreasePercent: number | null) {
|
||||||
currentVolume: number | null,
|
|
||||||
previousSnapshot: StockAlertVolumeSnapshot | null,
|
|
||||||
fallbackPercent: number | null,
|
|
||||||
) {
|
|
||||||
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
|
|
||||||
const previousCurrentVolume = normalizeNonNegativeVolume(previousSnapshot?.currentVolume);
|
|
||||||
const previousBaselineVolume = normalizeNonNegativeVolume(previousSnapshot?.previousVolume);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalizedCurrentVolume === null
|
!isFiniteNumber(currentIncreasePercent)
|
||||||
|| previousCurrentVolume === null
|
|| !isFiniteNumber(recentMaxIncreasePercent)
|
||||||
|| previousBaselineVolume === null
|
|| recentMaxIncreasePercent <= 0
|
||||||
|| previousCurrentVolume <= previousBaselineVolume
|
|| currentIncreasePercent < recentMaxIncreasePercent
|
||||||
|| normalizedCurrentVolume < previousCurrentVolume
|
|
||||||
) {
|
) {
|
||||||
return fallbackPercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousRiseAmount = previousCurrentVolume - previousBaselineVolume;
|
|
||||||
const currentRiseAmount = normalizedCurrentVolume - previousCurrentVolume;
|
|
||||||
|
|
||||||
if (previousRiseAmount <= 0) {
|
|
||||||
return fallbackPercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ((currentRiseAmount - previousRiseAmount) / previousRiseAmount) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveVolumeBaselineFromRate5d(item: StockAlertItem) {
|
|
||||||
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
|
|
||||||
const volumeRate5d = isFiniteNumber(item.volumeRate5d) ? item.volumeRate5d : null;
|
|
||||||
|
|
||||||
if (currentVolume === null || volumeRate5d === null || volumeRate5d <= 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseline = currentVolume / (volumeRate5d / 100);
|
return ((currentIncreasePercent - recentMaxIncreasePercent) / recentMaxIncreasePercent) * 100;
|
||||||
|
|
||||||
if (!Number.isFinite(baseline) || baseline <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(1, Math.round(baseline));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) {
|
function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) {
|
||||||
@@ -398,7 +393,7 @@ function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot:
|
|||||||
return snapshotBaseline;
|
return snapshotBaseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
return deriveVolumeBaselineFromRate5d(item);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow): StockAlertVolumeSnapshot {
|
function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow): StockAlertVolumeSnapshot {
|
||||||
@@ -416,6 +411,21 @@ function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStockAlertVolumeHistoryRow(row: StockAlertVolumeHistoryRow): StockAlertVolumeHistory {
|
||||||
|
return {
|
||||||
|
id: String(row.id ?? '').trim(),
|
||||||
|
stockCode: normalizeStockCode(row.stock_code),
|
||||||
|
stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code),
|
||||||
|
baselineVolume: normalizeNonNegativeVolume(row.baseline_volume),
|
||||||
|
currentVolume: normalizeNonNegativeVolume(row.current_volume),
|
||||||
|
volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent),
|
||||||
|
currentPrice: parseLooseNumber(row.current_price),
|
||||||
|
changeRate: parseLooseNumber(row.change_rate),
|
||||||
|
quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null,
|
||||||
|
createdAt: normalizeTimestamp(row.created_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildStockAlertVolumeSnapshotRecord(
|
function buildStockAlertVolumeSnapshotRecord(
|
||||||
item: StockAlertItem,
|
item: StockAlertItem,
|
||||||
currentVolume: number | null,
|
currentVolume: number | null,
|
||||||
@@ -451,6 +461,30 @@ function buildStockAlertVolumeSnapshotRecord(
|
|||||||
} satisfies Omit<StockAlertVolumeSnapshotRow, 'quoted_at'> & { quoted_at: string | null };
|
} satisfies Omit<StockAlertVolumeSnapshotRow, 'quoted_at'> & { quoted_at: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStockAlertVolumeHistoryRecord(
|
||||||
|
item: StockAlertItem,
|
||||||
|
baselineVolume: number | null,
|
||||||
|
currentVolume: number | null,
|
||||||
|
) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const normalizedBaselineVolume = normalizeNonNegativeVolume(baselineVolume);
|
||||||
|
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
|
||||||
|
const quotedAt = item.quotedAt ?? now;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${item.stockCode}:${quotedAt}`,
|
||||||
|
stock_code: item.stockCode,
|
||||||
|
stock_name: item.stockName,
|
||||||
|
baseline_volume: normalizedBaselineVolume,
|
||||||
|
current_volume: normalizedCurrentVolume,
|
||||||
|
volume_increase_percent: calculateVolumeIncreasePercent(normalizedCurrentVolume, normalizedBaselineVolume),
|
||||||
|
current_price: item.currentPrice,
|
||||||
|
change_rate: item.changeRate,
|
||||||
|
quoted_at: item.quotedAt,
|
||||||
|
created_at: now,
|
||||||
|
} satisfies Omit<StockAlertVolumeHistoryRow, 'quoted_at'> & { quoted_at: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
function buildStockSymbols(stockCode: string) {
|
function buildStockSymbols(stockCode: string) {
|
||||||
const normalizedCode = normalizeStockCode(stockCode);
|
const normalizedCode = normalizeStockCode(stockCode);
|
||||||
|
|
||||||
@@ -532,6 +566,27 @@ async function ensureStockAlertVolumeSnapshotTable() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureStockAlertVolumeHistoryTable() {
|
||||||
|
const exists = await db.schema.hasTable(STOCK_ALERT_VOLUME_HISTORY_TABLE);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.schema.createTable(STOCK_ALERT_VOLUME_HISTORY_TABLE, (table) => {
|
||||||
|
table.text('id').primary();
|
||||||
|
table.text('stock_code').notNullable();
|
||||||
|
table.text('stock_name').notNullable();
|
||||||
|
table.bigInteger('baseline_volume').nullable();
|
||||||
|
table.bigInteger('current_volume').nullable();
|
||||||
|
table.decimal('volume_increase_percent', 10, 2).nullable();
|
||||||
|
table.decimal('current_price', 14, 2).nullable();
|
||||||
|
table.decimal('change_rate', 10, 4).nullable();
|
||||||
|
table.timestamp('quoted_at', { useTz: true }).nullable();
|
||||||
|
table.timestamp('created_at', { useTz: true }).notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: URL, init?: RequestInit) {
|
async function fetchJson<T>(url: URL, init?: RequestInit) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
@@ -1494,6 +1549,34 @@ async function listStockAlertVolumeSnapshots() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listRecentStockAlertVolumeHistories(limitPerStock = 5) {
|
||||||
|
await ensureStockAlertVolumeHistoryTable();
|
||||||
|
|
||||||
|
const rows = (await db<StockAlertVolumeHistoryRow>(STOCK_ALERT_VOLUME_HISTORY_TABLE)
|
||||||
|
.select('*')
|
||||||
|
.orderBy('created_at', 'desc')) as StockAlertVolumeHistoryRow[];
|
||||||
|
const historyMap = new Map<string, StockAlertVolumeHistory[]>();
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const normalized = normalizeStockAlertVolumeHistoryRow(row);
|
||||||
|
|
||||||
|
if (!normalized.stockCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = historyMap.get(normalized.stockCode) ?? [];
|
||||||
|
|
||||||
|
if (items.length >= limitPerStock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(normalized);
|
||||||
|
historyMap.set(normalized.stockCode, items);
|
||||||
|
});
|
||||||
|
|
||||||
|
return historyMap;
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertStockAlertVolumeSnapshots(
|
async function upsertStockAlertVolumeSnapshots(
|
||||||
items: StockAlertItem[],
|
items: StockAlertItem[],
|
||||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||||
@@ -1523,9 +1606,35 @@ async function upsertStockAlertVolumeSnapshots(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function insertStockAlertVolumeHistories(
|
||||||
|
items: StockAlertItem[],
|
||||||
|
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||||
|
) {
|
||||||
|
await ensureStockAlertVolumeHistoryTable();
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = items
|
||||||
|
.map((item) => {
|
||||||
|
const previousSnapshot = previousSnapshots.get(item.stockCode) ?? null;
|
||||||
|
const baselineVolume = resolveComparableVolumeBaseline(item, previousSnapshot);
|
||||||
|
return buildStockAlertVolumeHistoryRecord(item, baselineVolume, item.currentVolume);
|
||||||
|
})
|
||||||
|
.filter((record) => record.current_volume !== null);
|
||||||
|
|
||||||
|
if (!records.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db(STOCK_ALERT_VOLUME_HISTORY_TABLE).insert(records).onConflict('id').ignore();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
||||||
items: StockAlertItem[],
|
items: StockAlertItem[],
|
||||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||||
|
recentHistories: Map<string, StockAlertVolumeHistory[]>,
|
||||||
options: {
|
options: {
|
||||||
thresholdPercent: number;
|
thresholdPercent: number;
|
||||||
minVolumeIncreasePercent: number;
|
minVolumeIncreasePercent: number;
|
||||||
@@ -1541,16 +1650,23 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
|||||||
const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null);
|
const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null);
|
||||||
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
|
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
|
||||||
const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume);
|
const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume);
|
||||||
const volumeAmplificationPercent = calculateVolumeAmplificationPercent(
|
const recentMaxVolumeIncreasePercent = Math.max(
|
||||||
currentVolume,
|
...((recentHistories.get(item.stockCode) ?? [])
|
||||||
previousSnapshot ?? null,
|
.map((history) => history.volumeIncreasePercent)
|
||||||
|
.filter((value): value is number => isFiniteNumber(value))),
|
||||||
|
);
|
||||||
|
const normalizedRecentMaxVolumeIncreasePercent = Number.isFinite(recentMaxVolumeIncreasePercent)
|
||||||
|
? recentMaxVolumeIncreasePercent
|
||||||
|
: null;
|
||||||
|
const volumeAmplificationGrowthPercent = calculateVolumeAmplificationGrowthPercent(
|
||||||
volumeIncreasePercent,
|
volumeIncreasePercent,
|
||||||
|
normalizedRecentMaxVolumeIncreasePercent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
volumeAmplificationPercent === null ||
|
volumeAmplificationGrowthPercent === null ||
|
||||||
Math.abs(item.changeRate ?? 0) < options.thresholdPercent ||
|
Math.abs(item.changeRate ?? 0) < options.thresholdPercent ||
|
||||||
volumeAmplificationPercent < options.minVolumeIncreasePercent
|
volumeAmplificationGrowthPercent < options.minVolumeIncreasePercent
|
||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1564,7 +1680,8 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
|||||||
currentVolume,
|
currentVolume,
|
||||||
previousVolume,
|
previousVolume,
|
||||||
volumeIncreasePercent,
|
volumeIncreasePercent,
|
||||||
volumeAmplificationPercent,
|
recentMaxVolumeIncreasePercent: normalizedRecentMaxVolumeIncreasePercent,
|
||||||
|
volumeAmplificationGrowthPercent,
|
||||||
quotedAt: item.quotedAt,
|
quotedAt: item.quotedAt,
|
||||||
} satisfies StockAlertVolumeSpikeCandidate,
|
} satisfies StockAlertVolumeSpikeCandidate,
|
||||||
];
|
];
|
||||||
@@ -1576,7 +1693,7 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
|||||||
return changeRateGap;
|
return changeRateGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeGap = (right.volumeAmplificationPercent ?? 0) - (left.volumeAmplificationPercent ?? 0);
|
const volumeGap = (right.volumeAmplificationGrowthPercent ?? 0) - (left.volumeAmplificationGrowthPercent ?? 0);
|
||||||
|
|
||||||
if (volumeGap !== 0) {
|
if (volumeGap !== 0) {
|
||||||
return volumeGap;
|
return volumeGap;
|
||||||
@@ -1589,12 +1706,13 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
|
|||||||
export function buildChangeRateAndVolumeSpikeStockAlertLines(
|
export function buildChangeRateAndVolumeSpikeStockAlertLines(
|
||||||
items: StockAlertItem[],
|
items: StockAlertItem[],
|
||||||
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
|
||||||
|
recentHistories: Map<string, StockAlertVolumeHistory[]>,
|
||||||
options: {
|
options: {
|
||||||
thresholdPercent: number;
|
thresholdPercent: number;
|
||||||
minVolumeIncreasePercent: number;
|
minVolumeIncreasePercent: number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map(
|
return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, recentHistories, options).map(
|
||||||
(item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`,
|
(item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1664,12 +1782,14 @@ export async function sendManagedStockAlertWebPush(options: {
|
|||||||
const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all');
|
const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all');
|
||||||
const previousSnapshots =
|
const previousSnapshots =
|
||||||
options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map<string, StockAlertVolumeSnapshot>();
|
options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map<string, StockAlertVolumeSnapshot>();
|
||||||
|
const recentHistories =
|
||||||
|
options.mode === 'change-threshold-volume-spike' ? await listRecentStockAlertVolumeHistories() : new Map<string, StockAlertVolumeHistory[]>();
|
||||||
const lines =
|
const lines =
|
||||||
options.mode === 'price'
|
options.mode === 'price'
|
||||||
? buildCurrentPriceStockAlertLines(items)
|
? buildCurrentPriceStockAlertLines(items)
|
||||||
: options.mode === 'change-threshold'
|
: options.mode === 'change-threshold'
|
||||||
? buildChangeRateThresholdStockAlertLines(items, thresholdPercent)
|
? buildChangeRateThresholdStockAlertLines(items, thresholdPercent)
|
||||||
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, {
|
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, {
|
||||||
thresholdPercent,
|
thresholdPercent,
|
||||||
minVolumeIncreasePercent,
|
minVolumeIncreasePercent,
|
||||||
});
|
});
|
||||||
@@ -1681,6 +1801,12 @@ export async function sendManagedStockAlertWebPush(options: {
|
|||||||
const previousSnapshot = previousSnapshots.get(item.stockCode);
|
const previousSnapshot = previousSnapshots.get(item.stockCode);
|
||||||
return resolveComparableVolumeBaseline(item, previousSnapshot ?? null) !== null;
|
return resolveComparableVolumeBaseline(item, previousSnapshot ?? null) !== null;
|
||||||
});
|
});
|
||||||
|
const hasRecentVolumeHistory =
|
||||||
|
options.mode !== 'change-threshold-volume-spike'
|
||||||
|
? false
|
||||||
|
: items.some((item) =>
|
||||||
|
(recentHistories.get(item.stockCode) ?? []).some((history) => isFiniteNumber(history.volumeIncreasePercent) && history.volumeIncreasePercent > 0),
|
||||||
|
);
|
||||||
const skippedReason =
|
const skippedReason =
|
||||||
options.mode === 'price'
|
options.mode === 'price'
|
||||||
? hasRegisteredTargets
|
? hasRegisteredTargets
|
||||||
@@ -1692,13 +1818,16 @@ export async function sendManagedStockAlertWebPush(options: {
|
|||||||
: '등록된 종목이 없습니다.'
|
: '등록된 종목이 없습니다.'
|
||||||
: !hasRegisteredTargets
|
: !hasRegisteredTargets
|
||||||
? '등록된 종목이 없습니다.'
|
? '등록된 종목이 없습니다.'
|
||||||
: !hasComparableVolumeBaseline
|
: !hasComparableVolumeBaseline
|
||||||
? '이전 거래량 또는 5영업일 평균 거래량 비교 기준이 없어 스냅샷만 갱신했습니다.'
|
? '이전 배치 실행 row 기준 거래량이 없어 스냅샷만 갱신했습니다.'
|
||||||
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 직전 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
|
: !hasRecentVolumeHistory
|
||||||
|
? '배치 실행 거래량 히스토리 row 5건 비교 기준이 없어 히스토리만 적재했습니다.'
|
||||||
|
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 최근 배치 실행 row 5건 최대값 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
|
||||||
const skippedResult = createSkippedNotificationResult(skippedReason);
|
const skippedResult = createSkippedNotificationResult(skippedReason);
|
||||||
|
|
||||||
if (options.mode === 'change-threshold-volume-spike') {
|
if (options.mode === 'change-threshold-volume-spike') {
|
||||||
await upsertStockAlertVolumeSnapshots(items, previousSnapshots);
|
await upsertStockAlertVolumeSnapshots(items, previousSnapshots);
|
||||||
|
await insertStockAlertVolumeHistories(items, previousSnapshots);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
@@ -1810,7 +1939,7 @@ export async function updateStockAlertLayoutFeatureDescription() {
|
|||||||
'알림유형의 경우 멀티선택 가능하게 해주세요.',
|
'알림유형의 경우 멀티선택 가능하게 해주세요.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const nextNotes =
|
const nextNotes =
|
||||||
'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.';
|
'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량 비교는 배치 실행 때마다 적재되는 종목별 거래량 스냅샷과 최근 히스토리 row 5건 기준으로 계산해 제공합니다.';
|
||||||
|
|
||||||
if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) {
|
if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||||
|
|
||||||
export type WorkServerBuildInfo = {
|
export type WorkServerBuildInfo = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -26,7 +27,7 @@ function normalizeRootPath(value: string | null | undefined) {
|
|||||||
|
|
||||||
function resolveSourceTargetRoots() {
|
function resolveSourceTargetRoots() {
|
||||||
const roots = [WORK_SERVER_ROOT_PATH];
|
const roots = [WORK_SERVER_ROOT_PATH];
|
||||||
const mainProjectRoot = normalizeRootPath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
|
const mainProjectRoot = normalizeRootPath(resolveMainProjectRoot());
|
||||||
|
|
||||||
if (mainProjectRoot) {
|
if (mainProjectRoot) {
|
||||||
const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server');
|
const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server');
|
||||||
@@ -51,7 +52,7 @@ export function resolveWorkServerBuildInfoFilePaths(options?: {
|
|||||||
const workServerRootPath = path.resolve(options?.workServerRootPath ?? WORK_SERVER_ROOT_PATH);
|
const workServerRootPath = path.resolve(options?.workServerRootPath ?? WORK_SERVER_ROOT_PATH);
|
||||||
const configuredDistDir = String(options?.configuredDistDir ?? env.WORK_SERVER_DIST_DIR ?? 'dist').trim() || 'dist';
|
const configuredDistDir = String(options?.configuredDistDir ?? env.WORK_SERVER_DIST_DIR ?? 'dist').trim() || 'dist';
|
||||||
const mainProjectRoot = normalizeRootPath(
|
const mainProjectRoot = normalizeRootPath(
|
||||||
options?.mainProjectRoot ?? env.SERVER_COMMAND_MAIN_PROJECT_ROOT ?? env.SERVER_COMMAND_PROJECT_ROOT,
|
options?.mainProjectRoot ?? resolveMainProjectRoot(),
|
||||||
);
|
);
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(resolveBuildInfoDirectoryPath(workServerRootPath, configuredDistDir), 'build-info.json'),
|
path.join(resolveBuildInfoDirectoryPath(workServerRootPath, configuredDistDir), 'build-info.json'),
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<!DOCTYPE html><body dir="ltr"><span style="caret-color: rgb(24, 34, 48); color: rgb(24, 34, 48); font-family: "SUIT Variable", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; font-size: medium; font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: pre; widows: 2; word-spacing: 0px; -webkit-tap-highlight-color: rgba(26, 26, 26, 0.3); -webkit-text-size-adjust: auto; -webkit-text-stroke-width: 0px; text-decoration: none; display: inline !important; float: none;">/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f</span></body>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 183 KiB |
1
src/app/main/AutomationContextManagementPage.css
Normal file
1
src/app/main/AutomationContextManagementPage.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import './ManagementPage.shared.css';
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useAutomationContextRegistry,
|
useAutomationContextRegistry,
|
||||||
} from './automationContextAccess';
|
} from './automationContextAccess';
|
||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import './ChatTypeManagementPage.css';
|
import './AutomationContextManagementPage.css';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
|||||||
1
src/app/main/AutomationTypeManagementPage.css
Normal file
1
src/app/main/AutomationTypeManagementPage.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import './ManagementPage.shared.css';
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type AutomationTypeRecord,
|
type AutomationTypeRecord,
|
||||||
} from './automationTypeAccess';
|
} from './automationTypeAccess';
|
||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import './ChatTypeManagementPage.css';
|
import './AutomationTypeManagementPage.css';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
|||||||
1
src/app/main/ChatDefaultContextManagementPage.css
Normal file
1
src/app/main/ChatDefaultContextManagementPage.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import './ManagementPage.shared.css';
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type ChatDefaultContextRecord,
|
type ChatDefaultContextRecord,
|
||||||
} from './chatContextSettingsAccess';
|
} from './chatContextSettingsAccess';
|
||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import './ChatTypeManagementPage.css';
|
import './ChatDefaultContextManagementPage.css';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -55,7 +55,13 @@ export function ChatDefaultContextManagementPage() {
|
|||||||
defaultContexts,
|
defaultContexts,
|
||||||
chatTypeDefaults,
|
chatTypeDefaults,
|
||||||
roomContexts,
|
roomContexts,
|
||||||
|
isLoading,
|
||||||
|
hasLoadedFromServer,
|
||||||
|
storeSource,
|
||||||
|
lastLoadedAt,
|
||||||
|
lastFailedAt,
|
||||||
errorMessage: contextSettingsErrorMessage,
|
errorMessage: contextSettingsErrorMessage,
|
||||||
|
reload,
|
||||||
setStore,
|
setStore,
|
||||||
} = useChatContextSettingsRegistry();
|
} = useChatContextSettingsRegistry();
|
||||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[0]?.id ?? null);
|
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[0]?.id ?? null);
|
||||||
@@ -65,12 +71,15 @@ export function ChatDefaultContextManagementPage() {
|
|||||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||||
|
const [isReloading, setIsReloading] = useState(false);
|
||||||
const [form] = Form.useForm<ChatDefaultContextFormValue>();
|
const [form] = Form.useForm<ChatDefaultContextFormValue>();
|
||||||
|
|
||||||
const selectedContext = useMemo(
|
const selectedContext = useMemo(
|
||||||
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
|
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
|
||||||
[defaultContexts, selectedContextId],
|
[defaultContexts, selectedContextId],
|
||||||
);
|
);
|
||||||
|
const shouldRenderServerList = hasLoadedFromServer && !contextSettingsErrorMessage;
|
||||||
|
const isServerDataReadyForEditing = hasLoadedFromServer && !contextSettingsErrorMessage && storeSource === 'server';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
|
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
|
||||||
@@ -181,58 +190,113 @@ export function ChatDefaultContextManagementPage() {
|
|||||||
title="기본 유형 관리"
|
title="기본 유형 관리"
|
||||||
className="chat-type-management-page__card"
|
className="chat-type-management-page__card"
|
||||||
extra={
|
extra={
|
||||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
<Button icon={<PlusOutlined />} onClick={openCreateForm} disabled={!isServerDataReadyForEditing}>
|
||||||
신규 기본 유형
|
신규 기본 유형
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="chat-type-management-page__list">
|
<div className="chat-type-management-page__list">
|
||||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
<div className="chat-type-management-page__list-scroll">
|
||||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||||
<div className="chat-type-management-page__list-header">
|
{contextSettingsErrorMessage ? (
|
||||||
<Title level={5}>등록 기본 유형</Title>
|
<Alert
|
||||||
<Text type="secondary">{`${defaultContexts.length}건`}</Text>
|
showIcon
|
||||||
</div>
|
type="error"
|
||||||
{defaultContexts.length > 0 ? (
|
message="기본 유형 목록을 서버에서 불러오지 못했습니다."
|
||||||
<List
|
description={
|
||||||
dataSource={defaultContexts}
|
<Space direction="vertical" size={8}>
|
||||||
renderItem={(item) => (
|
<Text>{contextSettingsErrorMessage}</Text>
|
||||||
<List.Item
|
<Space size={8} wrap>
|
||||||
className={
|
<Button
|
||||||
item.id === selectedContextId
|
onClick={() => {
|
||||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
setIsReloading(true);
|
||||||
: 'chat-type-management-page__item'
|
void reload()
|
||||||
}
|
.catch(() => undefined)
|
||||||
onClick={() => {
|
.finally(() => {
|
||||||
openDetail(item.id);
|
setIsReloading(false);
|
||||||
}}
|
});
|
||||||
actions={[
|
}}
|
||||||
<Button
|
loading={isReloading}
|
||||||
key="edit"
|
>
|
||||||
type="text"
|
다시 불러오기
|
||||||
icon={<EditOutlined />}
|
</Button>
|
||||||
onClick={(event) => {
|
{lastLoadedAt ? (
|
||||||
event.stopPropagation();
|
<Text type="secondary">{`마지막 정상 동기화: ${new Date(lastLoadedAt).toLocaleString()}`}</Text>
|
||||||
openDetail(item.id);
|
) : (
|
||||||
}}
|
<Text type="secondary">정상 동기화 이력이 없어 부분 목록을 대신 보여주지 않습니다.</Text>
|
||||||
/>,
|
)}
|
||||||
]}
|
{lastFailedAt ? (
|
||||||
>
|
<Text type="secondary">{`마지막 실패: ${new Date(lastFailedAt).toLocaleString()}`}</Text>
|
||||||
<div className="chat-type-management-page__item-main">
|
) : null}
|
||||||
<Space size={[8, 8]} wrap>
|
|
||||||
<Text strong>{item.title}</Text>
|
|
||||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
|
||||||
</Space>
|
</Space>
|
||||||
<div className="chat-type-management-page__item-description">
|
</Space>
|
||||||
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="chat-type-management-page__list-header">
|
||||||
|
<Title level={5}>등록 기본 유형</Title>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Text type="secondary">{shouldRenderServerList ? `${defaultContexts.length}건` : '서버 확인 전'}</Text>
|
||||||
|
{isLoading ? <Text type="secondary">서버 동기화 중</Text> : null}
|
||||||
|
{shouldRenderServerList && lastLoadedAt ? (
|
||||||
|
<Text type="secondary">{`서버 기준 ${new Date(lastLoadedAt).toLocaleTimeString()}`}</Text>
|
||||||
|
) : null}
|
||||||
|
{!shouldRenderServerList && !contextSettingsErrorMessage ? (
|
||||||
|
<Text type="secondary">표시는 `/api/chat-context-settings` 최신 응답 기준입니다.</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
{shouldRenderServerList && defaultContexts.length > 0 ? (
|
||||||
|
<List
|
||||||
|
dataSource={defaultContexts}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item
|
||||||
|
className={
|
||||||
|
item.id === selectedContextId
|
||||||
|
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||||
|
: 'chat-type-management-page__item'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
openDetail(item.id);
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="edit"
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openDetail(item.id);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="chat-type-management-page__item-main">
|
||||||
|
<Space size={[8, 8]} wrap>
|
||||||
|
<Text strong>{item.title}</Text>
|
||||||
|
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||||
|
</Space>
|
||||||
|
<div className="chat-type-management-page__item-description">
|
||||||
|
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</List.Item>
|
||||||
</List.Item>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
) : isLoading && !hasLoadedFromServer ? (
|
||||||
) : (
|
<Alert showIcon type="info" message="기본 유형 목록을 서버에서 불러오는 중입니다." />
|
||||||
<Empty description="등록된 기본 유형이 없습니다." />
|
) : (
|
||||||
)}
|
<Empty
|
||||||
|
description={
|
||||||
|
contextSettingsErrorMessage
|
||||||
|
? '서버 동기화 실패 상태입니다. 재조회 후 다시 확인해 주세요.'
|
||||||
|
: hasLoadedFromServer
|
||||||
|
? '등록된 기본 유형이 없습니다.'
|
||||||
|
: '서버 기준 기본 유형을 아직 확인하지 못했습니다.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -248,17 +312,25 @@ export function ChatDefaultContextManagementPage() {
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||||
|
disabled={!isServerDataReadyForEditing}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void form.submit();
|
void form.submit();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
aria-label="새 입력"
|
||||||
|
onClick={openCreateForm}
|
||||||
|
disabled={!isServerDataReadyForEditing}
|
||||||
|
/>
|
||||||
{!isCreating && selectedContext ? (
|
{!isCreating && selectedContext ? (
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
aria-label="삭제"
|
aria-label="삭제"
|
||||||
|
disabled={!isServerDataReadyForEditing}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleDelete();
|
void handleDelete();
|
||||||
}}
|
}}
|
||||||
@@ -271,6 +343,14 @@ export function ChatDefaultContextManagementPage() {
|
|||||||
<div className="chat-type-management-page__editor">
|
<div className="chat-type-management-page__editor">
|
||||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||||
|
{!isServerDataReadyForEditing ? (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="서버 최신 기본 유형을 확인한 뒤에만 수정할 수 있습니다."
|
||||||
|
description="부분 목록이나 오래된 상태로 저장해 서버 값이 덮어써지는 경로를 막기 위해, 서버 동기화가 완료되기 전에는 저장을 제한합니다."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Form
|
<Form
|
||||||
className="chat-type-management-page__editor-form"
|
className="chat-type-management-page__editor-form"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
|
|||||||
653
src/app/main/ChatTypeManagementPage.css
Executable file → Normal file
653
src/app/main/ChatTypeManagementPage.css
Executable file → Normal file
@@ -1,652 +1 @@
|
|||||||
.chat-type-management-page {
|
@import './ManagementPage.shared.css';
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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__card {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page .ant-card,
|
|
||||||
.chat-type-management-page__card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-head {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-head-title,
|
|
||||||
.chat-type-management-page .ant-card-extra {
|
|
||||||
padding: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 4px 14px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__list,
|
|
||||||
.chat-type-management-page__editor {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__list .ant-list {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-form {
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-scroll {
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0 0 calc(10px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-form .ant-form-item {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__list-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__list-header .ant-typography {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__item {
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__item--active {
|
|
||||||
border-color: #1677ff;
|
|
||||||
background: #f0f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__item-main {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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__default-context-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(248, 250, 252, 0.82);
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-field--hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-options {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-space {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-option {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-option-copy {
|
|
||||||
padding-left: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-preview {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__header-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__header-actions .ant-btn {
|
|
||||||
width: 36px;
|
|
||||||
min-width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-grid {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
align-items: stretch;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-grid--maximized {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
min-height: min(720px, calc(100dvh - 236px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 .ant-form-item-control,
|
|
||||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
|
||||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: clamp(360px, calc(100dvh - 360px), 720px);
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-textarea textarea {
|
|
||||||
height: 100% !important;
|
|
||||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
|
||||||
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 12px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
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__meta-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 6px 14px;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-grid--hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item {
|
|
||||||
min-width: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item .ant-form-item-control-input {
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
|
|
||||||
margin-inline-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--name {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--enabled {
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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__editor-scroll,
|
|
||||||
.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__editor-scroll {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-scroll {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 7px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-head-title,
|
|
||||||
.chat-type-management-page .ant-card-extra {
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-scroll {
|
|
||||||
gap: 3px;
|
|
||||||
padding: 0 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__mobile-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__editor-toolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 6px 12px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__default-context-header {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
|
|
||||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-pane--mobile-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-pane,
|
|
||||||
.chat-type-management-page__markdown-field,
|
|
||||||
.chat-type-management-page__markdown-editor,
|
|
||||||
.chat-type-management-page__markdown-preview {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
|
||||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
|
||||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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__markdown-textarea {
|
|
||||||
height: 100% !important;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-textarea textarea {
|
|
||||||
height: 100% !important;
|
|
||||||
min-height: 0 !important;
|
|
||||||
max-height: none !important;
|
|
||||||
overflow: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-pane
|
|
||||||
.ant-form-item,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-preview {
|
|
||||||
min-height: clamp(220px, calc(100dvh - 560px), 320px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-field,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-editor,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-grid,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-pane,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-preview {
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-pane
|
|
||||||
.ant-form-item,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-pane
|
|
||||||
.ant-form-item-control,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-pane
|
|
||||||
.ant-form-item-control-input,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-pane
|
|
||||||
.ant-form-item-control-input-content {
|
|
||||||
flex: none;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-textarea,
|
|
||||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
|
||||||
.chat-type-management-page__markdown-textarea textarea {
|
|
||||||
height: auto !important;
|
|
||||||
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized {
|
|
||||||
height: calc(100dvh - 52px);
|
|
||||||
max-height: calc(100dvh - 52px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .ant-card-head {
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .ant-card-head-title,
|
|
||||||
.chat-type-management-page--pane-maximized .ant-card-extra {
|
|
||||||
padding-top: 4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .ant-card-body {
|
|
||||||
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
|
|
||||||
.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__editor-scroll,
|
|
||||||
.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,
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
|
||||||
gap: 0;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
|
||||||
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
|
||||||
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
|
||||||
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized
|
|
||||||
.chat-type-management-page__markdown-pane
|
|
||||||
.ant-form-item-control-input-content {
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-preview {
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__markdown-preview-body {
|
|
||||||
max-height: none;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__header-actions {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__header-actions .ant-btn {
|
|
||||||
width: 34px;
|
|
||||||
min-width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
EditOutlined,
|
EditOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
ShrinkOutlined,
|
ShrinkOutlined,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -61,7 +62,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
|||||||
|
|
||||||
export function ChatTypeManagementPage() {
|
export function ChatTypeManagementPage() {
|
||||||
const { hasAccess } = useTokenAccess();
|
const { hasAccess } = useTokenAccess();
|
||||||
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
|
const { chatTypes, builtInChatTypes, customChatTypes, setChatTypes, isLoading, errorMessage, reload } = useChatTypeRegistry();
|
||||||
const {
|
const {
|
||||||
defaultContexts,
|
defaultContexts,
|
||||||
chatTypeDefaults,
|
chatTypeDefaults,
|
||||||
@@ -76,6 +77,7 @@ export function ChatTypeManagementPage() {
|
|||||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isReloading, setIsReloading] = useState(false);
|
||||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||||
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
|
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
|
||||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||||
@@ -83,17 +85,17 @@ export function ChatTypeManagementPage() {
|
|||||||
const isPaneMaximized = maximizedPane !== 'none';
|
const isPaneMaximized = maximizedPane !== 'none';
|
||||||
|
|
||||||
const selectedChatType = useMemo(
|
const selectedChatType = useMemo(
|
||||||
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
() => customChatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||||
[chatTypes, selectedChatTypeId],
|
[customChatTypes, selectedChatTypeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedChatTypeId && chatTypes.some((item) => item.id === selectedChatTypeId)) {
|
if (selectedChatTypeId && customChatTypes.some((item) => item.id === selectedChatTypeId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedChatTypeId(chatTypes[0]?.id ?? null);
|
setSelectedChatTypeId(customChatTypes[0]?.id ?? null);
|
||||||
}, [chatTypes, selectedChatTypeId]);
|
}, [customChatTypes, selectedChatTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailMode !== 'detail') {
|
if (detailMode !== 'detail') {
|
||||||
@@ -198,8 +200,8 @@ export function ChatTypeManagementPage() {
|
|||||||
setSaveErrorMessage('');
|
setSaveErrorMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||||
setSelectedChatTypeId(savedChatTypes[0]?.id ?? null);
|
setSelectedChatTypeId(savedSnapshot.customChatTypes[0]?.id ?? null);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setDetailMode('list');
|
setDetailMode('list');
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -211,6 +213,19 @@ export function ChatTypeManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReload = async () => {
|
||||||
|
setIsReloading(true);
|
||||||
|
setSaveErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reload();
|
||||||
|
} catch (error) {
|
||||||
|
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 재조회에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsReloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const detailHeaderActions = (
|
const detailHeaderActions = (
|
||||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||||
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
|
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
|
||||||
@@ -259,12 +274,12 @@ export function ChatTypeManagementPage() {
|
|||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return (
|
return (
|
||||||
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
|
<Card title="채팅유형 관리" className="chat-type-management-page">
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 컨텍스트와 권한을 관리하세요."
|
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요."
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -277,94 +292,150 @@ export function ChatTypeManagementPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{detailMode === 'list' ? (
|
{detailMode === 'list' ? (
|
||||||
<Card
|
<Card
|
||||||
title="컨텍스트 권한 관리"
|
title="채팅유형 관리"
|
||||||
className="chat-type-management-page__card"
|
className="chat-type-management-page__card"
|
||||||
extra={
|
extra={!isMobileViewport ? (
|
||||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
<Space size={8} wrap>
|
||||||
신규 컨텍스트
|
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
|
||||||
</Button>
|
재조회
|
||||||
}
|
</Button>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||||
|
신규 채팅유형
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
>
|
>
|
||||||
<div className="chat-type-management-page__list">
|
<div className="chat-type-management-page__list">
|
||||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||||
<div className="chat-type-management-page__list-header">
|
<div className="chat-type-management-page__list-header">
|
||||||
<Title level={5}>등록 컨텍스트</Title>
|
<Title level={5}>사용자 채팅유형</Title>
|
||||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${chatTypes.length}건`}</Text>
|
<Text type="secondary">{isLoading ? '불러오는 중' : `${customChatTypes.length}건`}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<Space size={8} wrap className="chat-type-management-page__list-actions">
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
|
||||||
|
재조회
|
||||||
|
</Button>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||||
|
신규 채팅유형
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{chatTypes.length > 0 ? (
|
<div className="chat-type-management-page__list-scroll">
|
||||||
<List
|
{builtInChatTypes.length > 0 ? (
|
||||||
dataSource={chatTypes}
|
<>
|
||||||
renderItem={(item) => {
|
<Alert
|
||||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
showIcon
|
||||||
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
|
type="info"
|
||||||
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
|
message="내장 기본 채팅유형은 코드 기준 고정값입니다."
|
||||||
.filter((context): context is NonNullable<typeof context> => Boolean(context));
|
description="이 목록은 참조용이며 여기서 수정·삭제되지 않습니다. 추가/삭제 가능한 대상은 아래 사용자 채팅유형입니다."
|
||||||
const itemClassName =
|
/>
|
||||||
item.id === selectedChatTypeId
|
<List
|
||||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
dataSource={builtInChatTypes}
|
||||||
: 'chat-type-management-page__item';
|
renderItem={(item) => {
|
||||||
|
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item className="chat-type-management-page__item">
|
||||||
className={itemClassName}
|
<div className="chat-type-management-page__item-main">
|
||||||
onClick={() => {
|
<Space size={[8, 8]} wrap>
|
||||||
openDetail(item.id);
|
<Text strong>{item.name}</Text>
|
||||||
}}
|
<Tag color="gold">내장 기본유형</Tag>
|
||||||
actions={[
|
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||||
<Button
|
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||||
key="edit"
|
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||||
type="text"
|
</Tag>
|
||||||
icon={<EditOutlined />}
|
</Space>
|
||||||
disabled={isSaving}
|
<div className="chat-type-management-page__item-description">
|
||||||
onClick={(event) => {
|
{item.description ? (
|
||||||
event.stopPropagation();
|
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||||
openDetail(item.id);
|
) : (
|
||||||
}}
|
'기본 문맥 설명 없음'
|
||||||
/>,
|
)}
|
||||||
]}
|
</div>
|
||||||
>
|
</div>
|
||||||
<div className="chat-type-management-page__item-main">
|
</List.Item>
|
||||||
<Space size={[8, 8]} wrap>
|
);
|
||||||
<Text strong>{item.name}</Text>
|
}}
|
||||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
/>
|
||||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
</>
|
||||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
) : null}
|
||||||
</Tag>
|
|
||||||
</Space>
|
{customChatTypes.length > 0 ? (
|
||||||
<div className="chat-type-management-page__item-description">
|
<List
|
||||||
{item.description ? (
|
dataSource={customChatTypes}
|
||||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
renderItem={(item) => {
|
||||||
) : (
|
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||||
'기본 문맥 설명 없음'
|
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
|
||||||
)}
|
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
|
||||||
</div>
|
.filter((context): context is NonNullable<typeof context> => Boolean(context));
|
||||||
<Space size={[6, 6]} wrap>
|
const itemClassName =
|
||||||
{item.permissions.map((permission) => (
|
item.id === selectedChatTypeId
|
||||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||||
))}
|
: 'chat-type-management-page__item';
|
||||||
{linkedDefaultContexts.map((context) => (
|
|
||||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
return (
|
||||||
{context.title}
|
<List.Item
|
||||||
|
className={itemClassName}
|
||||||
|
onClick={() => {
|
||||||
|
openDetail(item.id);
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="edit"
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openDetail(item.id);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="chat-type-management-page__item-main">
|
||||||
|
<Space size={[8, 8]} wrap>
|
||||||
|
<Text strong>{item.name}</Text>
|
||||||
|
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||||
|
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||||
|
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
</Space>
|
||||||
</Space>
|
<div className="chat-type-management-page__item-description">
|
||||||
</div>
|
{item.description ? (
|
||||||
</List.Item>
|
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||||
);
|
) : (
|
||||||
}}
|
'기본 문맥 설명 없음'
|
||||||
/>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<Empty description="등록된 컨텍스트가 없습니다." />
|
<Space size={[6, 6]} wrap>
|
||||||
)}
|
{item.permissions.map((permission) => (
|
||||||
|
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||||
|
))}
|
||||||
|
{linkedDefaultContexts.map((context) => (
|
||||||
|
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||||
|
{context.title}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="등록된 사용자 채팅유형이 없습니다." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card
|
<Card
|
||||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
title={isCreating ? '채팅유형 등록' : '채팅유형 상세'}
|
||||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||||
extra={detailHeaderActions}
|
extra={detailHeaderActions}
|
||||||
>
|
>
|
||||||
@@ -384,8 +455,10 @@ export function ChatTypeManagementPage() {
|
|||||||
setSaveErrorMessage('');
|
setSaveErrorMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||||
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
|
const savedChatType =
|
||||||
|
savedSnapshot.customChatTypes.find((item) => item.id === values.id || item.name === values.name) ??
|
||||||
|
savedSnapshot.chatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||||
const nextChatTypeDefaults = savedChatType
|
const nextChatTypeDefaults = savedChatType
|
||||||
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
|
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
|
||||||
: chatTypeDefaults;
|
: chatTypeDefaults;
|
||||||
@@ -413,9 +486,9 @@ export function ChatTypeManagementPage() {
|
|||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||||
label="컨텍스트명"
|
label="컨텍스트명"
|
||||||
name="name"
|
name="name"
|
||||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
rules={[{ required: true, message: '채팅유형명을 입력하세요.' }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="예: 운영 문의" />
|
<Input placeholder="예: 운영 문의 전용" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd';
|
import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Select, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import type { InputRef } from 'antd';
|
import type { InputRef } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +66,12 @@ import {
|
|||||||
type ChatDefaultContextRecord,
|
type ChatDefaultContextRecord,
|
||||||
} from './chatContextSettingsAccess';
|
} from './chatContextSettingsAccess';
|
||||||
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
||||||
import { createNotificationMessage } from './notificationApi';
|
import {
|
||||||
|
createNotificationMessage,
|
||||||
|
sendClientNotification,
|
||||||
|
shouldFallbackToLocalNotification,
|
||||||
|
showLocalClientNotification,
|
||||||
|
} from './notificationApi';
|
||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import {
|
import {
|
||||||
ChatConversationView,
|
ChatConversationView,
|
||||||
@@ -174,6 +179,14 @@ const CHAT_RESTART_EXCLUSION_PATTERNS = [
|
|||||||
] as const;
|
] as const;
|
||||||
const CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS = [0, 350, 900, 1800] as const;
|
const CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS = [0, 350, 900, 1800] as const;
|
||||||
|
|
||||||
|
function areStringListsEqual(left: string[], right: string[]) {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
function isStandaloneDisplayMode() {
|
function isStandaloneDisplayMode() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
@@ -299,6 +312,56 @@ function buildChatSessionLink(sessionId: string) {
|
|||||||
return `${url.pathname}${url.search}${url.hash}`;
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildChatSessionTargetUrl(sessionId: string) {
|
||||||
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
|
if (!normalizedSessionId || typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('/chat/live', window.location.origin);
|
||||||
|
url.searchParams.set('topMenu', 'chat');
|
||||||
|
url.searchParams.set('sessionId', normalizedSessionId);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChatQuestionAnswerNotificationBody(args: {
|
||||||
|
questionText?: string | null;
|
||||||
|
answerText?: string | null;
|
||||||
|
fallback: string;
|
||||||
|
}) {
|
||||||
|
const questionPreview = createConversationPreviewText(args.questionText ?? '');
|
||||||
|
const answerPreview = createConversationPreviewText(args.answerText ?? '');
|
||||||
|
|
||||||
|
if (questionPreview && answerPreview) {
|
||||||
|
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answerPreview) {
|
||||||
|
return `답변: ${answerPreview}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questionPreview) {
|
||||||
|
return `질문: ${questionPreview}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLocalChatNotification(args: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
threadId: string;
|
||||||
|
data: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
await showLocalClientNotification({
|
||||||
|
title: args.title,
|
||||||
|
body: args.body,
|
||||||
|
threadId: args.threadId,
|
||||||
|
data: args.data,
|
||||||
|
}).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
@@ -389,6 +452,8 @@ function buildOptimisticConversationSummary(args: {
|
|||||||
currentJobMessage: null,
|
currentJobMessage: null,
|
||||||
currentQueueSize: 0,
|
currentQueueSize: 0,
|
||||||
currentStatusUpdatedAt: null,
|
currentStatusUpdatedAt: null,
|
||||||
|
isPendingWork: false,
|
||||||
|
pendingWorkReason: null,
|
||||||
lastRequestPreview: '',
|
lastRequestPreview: '',
|
||||||
lastMessagePreview: '',
|
lastMessagePreview: '',
|
||||||
lastResponsePreview: '',
|
lastResponsePreview: '',
|
||||||
@@ -711,6 +776,106 @@ function resolveConversationListPreviewText(preview: string) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCAL_PENDING_WORK_ANALYSIS_PATTERNS = [
|
||||||
|
/분석/u,
|
||||||
|
/검토/u,
|
||||||
|
/조사/u,
|
||||||
|
/원인/u,
|
||||||
|
/파악/u,
|
||||||
|
/\banalysis\b/i,
|
||||||
|
/\binvestigat(?:e|ion)\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LOCAL_PENDING_WORK_DESIGN_PATTERNS = [
|
||||||
|
/설계/u,
|
||||||
|
/프롬프트/u,
|
||||||
|
/시안/u,
|
||||||
|
/구조/u,
|
||||||
|
/방향/u,
|
||||||
|
/기획/u,
|
||||||
|
/플로우/u,
|
||||||
|
/아키텍처/u,
|
||||||
|
/\bdesign\b/i,
|
||||||
|
/\barchitecture\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS = [
|
||||||
|
/구현했/u,
|
||||||
|
/수정했/u,
|
||||||
|
/반영했/u,
|
||||||
|
/적용했/u,
|
||||||
|
/완료했/u,
|
||||||
|
/마무리했/u,
|
||||||
|
/배포했/u,
|
||||||
|
/검증했/u,
|
||||||
|
/빌드.*통과/u,
|
||||||
|
/테스트.*통과/u,
|
||||||
|
/캡처/u,
|
||||||
|
/preview/iu,
|
||||||
|
/변경 파일/u,
|
||||||
|
/diff/u,
|
||||||
|
/\bimplement(?:ed|ation)?\b/i,
|
||||||
|
/\bfix(?:ed)?\b/i,
|
||||||
|
/\bverified?\b/i,
|
||||||
|
/\btested?\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
|
||||||
|
/원하시면/u,
|
||||||
|
/진행해드릴/u,
|
||||||
|
/이어(?:서|가)/u,
|
||||||
|
/다음 단계/u,
|
||||||
|
/선택/u,
|
||||||
|
/옵션/u,
|
||||||
|
/후속/u,
|
||||||
|
/\bif you want\b/i,
|
||||||
|
/\bnext step\b/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizeConversationPendingWorkText(text: string | null | undefined) {
|
||||||
|
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConversationPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
|
||||||
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferConversationPendingWorkReason(item: ChatConversationSummary) {
|
||||||
|
if (item.pendingWorkReason) {
|
||||||
|
return item.pendingWorkReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestText = normalizeConversationPendingWorkText(item.lastRequestPreview);
|
||||||
|
const responseText = normalizeConversationPendingWorkText(item.lastResponsePreview);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) &&
|
||||||
|
!hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
|
||||||
|
) {
|
||||||
|
return 'design' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS) &&
|
||||||
|
!hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
|
||||||
|
) {
|
||||||
|
return 'analysis' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) ||
|
||||||
|
hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS)
|
||||||
|
) {
|
||||||
|
if (hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
|
||||||
|
return hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS)
|
||||||
|
? 'design'
|
||||||
|
: 'analysis';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
|
function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
|
||||||
const normalized = label.replace(/\s+/g, ' ').trim();
|
const normalized = label.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
@@ -1244,6 +1409,32 @@ function buildMessageSyncKey(messages: ChatMessage[]) {
|
|||||||
return `${messages.length}:${latestMessage.id}:${latestMessage.text.length}:${latestMessage.timestamp}`;
|
return `${messages.length}:${latestMessage.id}:${latestMessage.text.length}:${latestMessage.timestamp}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_CONVERSATION_DETAIL_PAGE_SIZE = 8;
|
||||||
|
|
||||||
|
function collectVisibleConversationRequestIds(messages: ChatMessage[]) {
|
||||||
|
return new Set(
|
||||||
|
messages
|
||||||
|
.map((message) => message.clientRequestId?.trim() ?? '')
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVisibleConversationRequests(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
requestItems: ChatConversationRequest[],
|
||||||
|
sessionId: string,
|
||||||
|
) {
|
||||||
|
const visibleRequestIds = collectVisibleConversationRequestIds(messages);
|
||||||
|
|
||||||
|
if (visibleRequestIds.size === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestItems.filter(
|
||||||
|
(item) => item.sessionId === sessionId && item.status !== 'removed' && visibleRequestIds.has(item.requestId),
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) {
|
function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) {
|
||||||
if (attachments.length === 0) {
|
if (attachments.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
@@ -1689,6 +1880,8 @@ function mergeConversationSummaryPreservingChatType(
|
|||||||
generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName),
|
generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName),
|
||||||
contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
|
contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
|
||||||
contextDescription: nextItem.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
contextDescription: nextItem.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
||||||
|
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
|
||||||
|
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
|
||||||
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
||||||
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||||
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
||||||
@@ -1824,6 +2017,14 @@ function applyRuntimeSnapshotToConversationItems(
|
|||||||
return nextItems;
|
return nextItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConversationPendingWork(item: ChatConversationSummary) {
|
||||||
|
if (isConversationProcessing(item) || isConversationFailed(item) || item.hasUnreadResponse) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.isPendingWork === true || inferConversationPendingWorkReason(item) != null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) {
|
function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1859,7 +2060,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const { chatTypes, setChatTypes } = useChatTypeRegistry();
|
const { chatTypes, setChatTypes } = useChatTypeRegistry();
|
||||||
const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } =
|
const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } =
|
||||||
useChatContextSettingsRegistry();
|
useChatContextSettingsRegistry();
|
||||||
const [draft, setDraft] = useState('');
|
const draftRef = useRef('');
|
||||||
|
const [draftSeed, setDraftSeed] = useState({ value: '', version: 0 });
|
||||||
const [composerAttachments, setComposerAttachments] = useState<ChatComposerAttachment[]>([]);
|
const [composerAttachments, setComposerAttachments] = useState<ChatComposerAttachment[]>([]);
|
||||||
const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false);
|
const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false);
|
||||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||||
@@ -1889,6 +2091,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState<string | null>(null);
|
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState<string | null>(null);
|
||||||
const [editingChatTypeDescription, setEditingChatTypeDescription] = useState('');
|
const [editingChatTypeDescription, setEditingChatTypeDescription] = useState('');
|
||||||
const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
|
const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
|
||||||
|
const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false);
|
||||||
const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState('');
|
const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState('');
|
||||||
const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState('');
|
const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState('');
|
||||||
const [mobileConversationSectionOpen, setMobileConversationSectionOpen] =
|
const [mobileConversationSectionOpen, setMobileConversationSectionOpen] =
|
||||||
@@ -1945,6 +2148,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
|
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||||
|
const [pendingClearConversationSessionId, setPendingClearConversationSessionId] = useState<string | null>(null);
|
||||||
const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now());
|
const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now());
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const composerRef = useRef<TextAreaRef | null>(null);
|
const composerRef = useRef<TextAreaRef | null>(null);
|
||||||
@@ -1975,9 +2179,27 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const isClosingConversationRef = useRef(false);
|
const isClosingConversationRef = useRef(false);
|
||||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||||
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
|
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
|
||||||
|
const notifiedChatPushKeysRef = useRef<string[]>([]);
|
||||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||||
const isCreatingImportedDraftConversationRef = useRef(false);
|
const isCreatingImportedDraftConversationRef = useRef(false);
|
||||||
|
const setDraft = useCallback((value: string) => {
|
||||||
|
draftRef.current = value;
|
||||||
|
}, []);
|
||||||
|
const setDraftValue = useCallback((value: string) => {
|
||||||
|
const shouldRefreshComposer = draftRef.current !== value;
|
||||||
|
draftRef.current = value;
|
||||||
|
setDraftSeed((previous) => {
|
||||||
|
if (previous.value === value && !shouldRefreshComposer) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
version: previous.version + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
|
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
|
||||||
setRequestItemsState((previous) => {
|
setRequestItemsState((previous) => {
|
||||||
const safePrevious = Array.isArray(previous) ? previous : [];
|
const safePrevious = Array.isArray(previous) ? previous : [];
|
||||||
@@ -2019,10 +2241,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
|
setDraftValue(draftRef.current.trim() ? draftRef.current : queuedImportedDraft);
|
||||||
composerRef.current?.focus({ cursor: 'end' });
|
composerRef.current?.focus({ cursor: 'end' });
|
||||||
setQueuedImportedDraft('');
|
setQueuedImportedDraft('');
|
||||||
}, [activeSessionId, queuedImportedDraft]);
|
}, [activeSessionId, queuedImportedDraft, setDraftValue]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
conversationItems,
|
conversationItems,
|
||||||
@@ -2036,6 +2258,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
enabled: activeView === 'chat',
|
enabled: activeView === 'chat',
|
||||||
});
|
});
|
||||||
const conversationItemsRef = useRef<ChatConversationSummary[]>(conversationItems);
|
const conversationItemsRef = useRef<ChatConversationSummary[]>(conversationItems);
|
||||||
|
const activeConversation = useMemo(
|
||||||
|
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||||
|
[activeSessionId, conversationItems],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConversationItems((previous) => {
|
setConversationItems((previous) => {
|
||||||
const storedSectionNameMap = readStoredGeneralSectionNameMap();
|
const storedSectionNameMap = readStoredGeneralSectionNameMap();
|
||||||
@@ -2293,7 +2519,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
null;
|
null;
|
||||||
setEditingRoomChatTypeId(nextChatTypeId);
|
setEditingRoomChatTypeId(nextChatTypeId);
|
||||||
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
||||||
setEditingRoomDefaultContextIds(effectiveDefaultContextIds);
|
setEditingRoomDefaultContextIds(activeRoomContextSettings?.defaultContextIds ?? resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
|
||||||
|
setIsEditingRoomDefaultContextsDirty(false);
|
||||||
setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle);
|
setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle);
|
||||||
setEditingRoomCustomContextContent(effectiveRoomCustomContextContent);
|
setEditingRoomCustomContextContent(effectiveRoomCustomContextContent);
|
||||||
setContextDrawerTabKey('chat-type');
|
setContextDrawerTabKey('chat-type');
|
||||||
@@ -2327,32 +2554,47 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
permissions: nextChatType.permissions,
|
permissions: nextChatType.permissions,
|
||||||
enabled: nextChatType.enabled,
|
enabled: nextChatType.enabled,
|
||||||
});
|
});
|
||||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||||
nextChatType = savedChatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
|
nextChatType = savedSnapshot.chatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedChatType = nextChatType;
|
const resolvedChatType = nextChatType;
|
||||||
|
const normalizedDefaultContextIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
editingRoomDefaultContextIds
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => enabledDefaultContexts.some((context) => context.id === value)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const nextCustomContextTitle = editingRoomCustomContextTitle.trim();
|
||||||
|
const nextCustomContextContent = editingRoomCustomContextContent.trim();
|
||||||
|
const inheritedDefaultContextIds = resolveChatTypeDefaultContextIds(chatTypeDefaults, resolvedChatType.id);
|
||||||
|
const shouldPersistRoomDefaultContextIds = !areStringListsEqual(normalizedDefaultContextIds, inheritedDefaultContextIds);
|
||||||
|
const shouldPersistRoomCustomContext = Boolean(nextCustomContextTitle || nextCustomContextContent);
|
||||||
|
|
||||||
const nextRoomContexts = upsertChatRoomContextSettings(roomContexts, {
|
const nextRoomContexts =
|
||||||
sessionId: activeConversation.sessionId,
|
shouldPersistRoomDefaultContextIds || shouldPersistRoomCustomContext
|
||||||
defaultContextIds: editingRoomDefaultContextIds,
|
? upsertChatRoomContextSettings(roomContexts, {
|
||||||
customContextTitle: editingRoomCustomContextTitle,
|
sessionId: activeConversation.sessionId,
|
||||||
customContextContent: editingRoomCustomContextContent,
|
defaultContextIds: normalizedDefaultContextIds,
|
||||||
});
|
customContextTitle: nextCustomContextTitle,
|
||||||
|
customContextContent: nextCustomContextContent,
|
||||||
|
})
|
||||||
|
: roomContexts.filter((item) => item.sessionId !== activeConversation.sessionId);
|
||||||
const nextDescription = normalizeConversationContextDescription(
|
const nextDescription = normalizeConversationContextDescription(
|
||||||
resolveComposedChatTypeDescription(resolvedChatType, {
|
resolveComposedChatTypeDescription(resolvedChatType, {
|
||||||
sessionId: activeConversation.sessionId,
|
sessionId: activeConversation.sessionId,
|
||||||
defaultContextIds: editingRoomDefaultContextIds,
|
defaultContextIds: normalizedDefaultContextIds,
|
||||||
customContextTitle: editingRoomCustomContextTitle,
|
customContextTitle: nextCustomContextTitle,
|
||||||
customContextContent: editingRoomCustomContextContent,
|
customContextContent: nextCustomContextContent,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
void setChatContextSettingsStore({
|
await setChatContextSettingsStore({
|
||||||
defaultContexts,
|
defaultContexts,
|
||||||
chatTypeDefaults,
|
chatTypeDefaults,
|
||||||
roomContexts: nextRoomContexts,
|
roomContexts: nextRoomContexts,
|
||||||
}).catch(() => {});
|
});
|
||||||
setConversationItems((previous) =>
|
setConversationItems((previous) =>
|
||||||
previous.map((entry) =>
|
previous.map((entry) =>
|
||||||
entry.sessionId === activeConversation.sessionId
|
entry.sessionId === activeConversation.sessionId
|
||||||
@@ -2376,6 +2618,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
});
|
});
|
||||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
|
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
|
||||||
setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id);
|
setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id);
|
||||||
|
setIsEditingRoomDefaultContextsDirty(false);
|
||||||
setIsContextDrawerOpen(false);
|
setIsContextDrawerOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.');
|
messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.');
|
||||||
@@ -2396,7 +2639,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
return nextItems;
|
return nextItems;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const syncConversationPreviewForRequest = (sessionId: string, text: string, requestedAt = new Date().toISOString()) => {
|
const syncConversationPreviewForRequest = (
|
||||||
|
sessionId: string,
|
||||||
|
text: string,
|
||||||
|
requestedAt = new Date().toISOString(),
|
||||||
|
options?: {
|
||||||
|
requestId?: string;
|
||||||
|
mode?: 'queue' | 'direct';
|
||||||
|
queueSize?: number;
|
||||||
|
jobMessage?: string | null;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const nextPreview = createConversationPreviewText(text);
|
const nextPreview = createConversationPreviewText(text);
|
||||||
|
|
||||||
if (!nextPreview) {
|
if (!nextPreview) {
|
||||||
@@ -2413,6 +2666,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
lastMessagePreview: nextPreview,
|
lastMessagePreview: nextPreview,
|
||||||
lastMessageAt: requestedAt,
|
lastMessageAt: requestedAt,
|
||||||
updatedAt: requestedAt,
|
updatedAt: requestedAt,
|
||||||
|
currentRequestId: options?.requestId?.trim() || item.currentRequestId,
|
||||||
|
currentJobStatus:
|
||||||
|
options?.mode === 'queue'
|
||||||
|
? 'queued'
|
||||||
|
: options?.mode === 'direct'
|
||||||
|
? 'started'
|
||||||
|
: item.currentJobStatus,
|
||||||
|
currentJobMessage:
|
||||||
|
options?.jobMessage?.trim() ||
|
||||||
|
(options?.mode === 'queue'
|
||||||
|
? '대기열 등록 중'
|
||||||
|
: options?.mode === 'direct'
|
||||||
|
? '즉시 요청 실행 대기 중'
|
||||||
|
: item.currentJobMessage),
|
||||||
|
currentQueueSize:
|
||||||
|
options?.mode === 'queue' ? Math.max(1, Number(options?.queueSize ?? 1)) : item.currentQueueSize,
|
||||||
|
currentStatusUpdatedAt:
|
||||||
|
options?.mode === 'queue' || options?.mode === 'direct'
|
||||||
|
? requestedAt
|
||||||
|
: item.currentStatusUpdatedAt,
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
@@ -2486,18 +2759,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncToken = ++conversationDetailSyncTokenRef.current;
|
const syncToken = ++conversationDetailSyncTokenRef.current;
|
||||||
const activeSessionRequestCount = requestItemsRef.current.filter(
|
const visibleMessages =
|
||||||
(item) => item.sessionId === normalizedSessionId,
|
|
||||||
).length;
|
|
||||||
const activeSessionVisibleRequestCount =
|
|
||||||
normalizedSessionId === activeSessionId
|
normalizedSessionId === activeSessionId
|
||||||
? requestItemsRef.current.filter(
|
? messagesRef.current
|
||||||
(item) => item.sessionId === normalizedSessionId && item.status !== 'removed',
|
: getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId);
|
||||||
).length
|
const visibleRequestCount = countVisibleConversationRequests(
|
||||||
: 0;
|
visibleMessages,
|
||||||
|
requestItemsRef.current,
|
||||||
|
normalizedSessionId,
|
||||||
|
);
|
||||||
const detailLimit = Math.min(
|
const detailLimit = Math.min(
|
||||||
60,
|
60,
|
||||||
Math.max(20, activeSessionRequestCount || 0, activeSessionVisibleRequestCount || 0),
|
Math.max(CHAT_CONVERSATION_DETAIL_PAGE_SIZE, visibleRequestCount),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) {
|
for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) {
|
||||||
@@ -2824,6 +3097,78 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:codex-response`;
|
||||||
|
|
||||||
|
if (notifiedChatPushKeysRef.current.includes(chatNotificationKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifiedChatPushKeysRef.current = [...notifiedChatPushKeysRef.current, chatNotificationKey].slice(-80);
|
||||||
|
|
||||||
|
const conversationTitle = eventConversation?.title?.trim() || '현재 채팅방';
|
||||||
|
const targetUrl = buildChatSessionTargetUrl(sessionId);
|
||||||
|
const notificationTitle = `${conversationTitle} 새 답변`;
|
||||||
|
const notificationBody = createChatQuestionAnswerNotificationBody({
|
||||||
|
questionText: relatedQuestionText,
|
||||||
|
answerText: incomingMessage.text,
|
||||||
|
fallback: `${conversationTitle}에 새 답변이 도착했습니다.`,
|
||||||
|
});
|
||||||
|
const notificationData = {
|
||||||
|
category: 'chat',
|
||||||
|
priority: 'normal',
|
||||||
|
sessionId,
|
||||||
|
conversationTitle,
|
||||||
|
requestId: incomingMessage.clientRequestId ?? '',
|
||||||
|
questionText: relatedQuestionText,
|
||||||
|
answerText: incomingMessage.text,
|
||||||
|
targetUrl,
|
||||||
|
linkUrl: targetUrl,
|
||||||
|
linkLabel: '채팅 바로 열기',
|
||||||
|
};
|
||||||
|
const serializedNotificationData = Object.fromEntries(
|
||||||
|
Object.entries(notificationData).flatMap(([key, value]) => (value ? [[key, String(value)]] : [])),
|
||||||
|
);
|
||||||
|
|
||||||
|
void Promise.allSettled([
|
||||||
|
createNotificationMessage({
|
||||||
|
title: notificationTitle,
|
||||||
|
body: notificationBody,
|
||||||
|
category: 'chat',
|
||||||
|
source: 'codex-live',
|
||||||
|
priority: 'normal',
|
||||||
|
metadata: {
|
||||||
|
...notificationData,
|
||||||
|
previewText: `새 답변 · ${conversationTitle}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sendClientNotification({
|
||||||
|
title: notificationTitle,
|
||||||
|
body: notificationBody,
|
||||||
|
threadId: `chat:${sessionId}`,
|
||||||
|
data: serializedNotificationData,
|
||||||
|
}),
|
||||||
|
]).then(async ([storedResult, pushResult]) => {
|
||||||
|
if (pushResult.status === 'rejected') {
|
||||||
|
await showLocalChatNotification({
|
||||||
|
title: notificationTitle,
|
||||||
|
body: notificationBody,
|
||||||
|
threadId: `chat:${sessionId}`,
|
||||||
|
data: serializedNotificationData,
|
||||||
|
});
|
||||||
|
} else if (shouldFallbackToLocalNotification(pushResult.value)) {
|
||||||
|
await showLocalChatNotification({
|
||||||
|
title: notificationTitle,
|
||||||
|
body: notificationBody,
|
||||||
|
threadId: `chat:${sessionId}`,
|
||||||
|
data: serializedNotificationData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedResult.status === 'rejected' && pushResult.status === 'rejected') {
|
||||||
|
notifiedChatPushKeysRef.current = notifiedChatPushKeysRef.current.filter((key) => key !== chatNotificationKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const previewItems = useMemo(
|
const previewItems = useMemo(
|
||||||
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))),
|
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))),
|
||||||
@@ -2836,10 +3181,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
||||||
const activeConversation = useMemo(
|
|
||||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
|
||||||
[activeSessionId, conversationItems],
|
|
||||||
);
|
|
||||||
const activeConversationHasLocalActivity =
|
const activeConversationHasLocalActivity =
|
||||||
chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId);
|
chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId);
|
||||||
const persistedActiveChatTypeId =
|
const persistedActiveChatTypeId =
|
||||||
@@ -3266,6 +3607,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]);
|
}, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]);
|
||||||
const pendingDeleteConversation =
|
const pendingDeleteConversation =
|
||||||
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
|
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
|
||||||
|
const pendingClearConversation =
|
||||||
|
conversationItems.find((item) => item.sessionId === pendingClearConversationSessionId) ?? null;
|
||||||
const editingGeneralSectionConversation =
|
const editingGeneralSectionConversation =
|
||||||
conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null;
|
conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null;
|
||||||
const availableGeneralSectionNames = useMemo(
|
const availableGeneralSectionNames = useMemo(
|
||||||
@@ -3280,7 +3623,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
[conversationItems],
|
[conversationItems],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingContextConfirm && !pendingDeleteConversation) {
|
if (!pendingContextConfirm && !pendingDeleteConversation && !pendingClearConversation) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3313,7 +3656,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleEnterConfirm, true);
|
window.removeEventListener('keydown', handleEnterConfirm, true);
|
||||||
};
|
};
|
||||||
}, [pendingContextConfirm, pendingDeleteConversation]);
|
}, [pendingClearConversation, pendingContextConfirm, pendingDeleteConversation]);
|
||||||
const {
|
const {
|
||||||
activePreview,
|
activePreview,
|
||||||
isPreviewLoading,
|
isPreviewLoading,
|
||||||
@@ -3643,6 +3986,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
{jobStatusLabel}
|
{jobStatusLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isConversationPendingWork(item) ? (
|
||||||
|
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
|
||||||
|
작업중
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{isUnread ? (
|
{isUnread ? (
|
||||||
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
|
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
|
||||||
답변 도착
|
답변 도착
|
||||||
@@ -4005,6 +4353,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
currentJobMessage: null,
|
currentJobMessage: null,
|
||||||
currentQueueSize: 0,
|
currentQueueSize: 0,
|
||||||
currentStatusUpdatedAt: null,
|
currentStatusUpdatedAt: null,
|
||||||
|
isPendingWork: false,
|
||||||
|
pendingWorkReason: null,
|
||||||
lastRequestPreview: '',
|
lastRequestPreview: '',
|
||||||
lastMessagePreview: '',
|
lastMessagePreview: '',
|
||||||
lastResponsePreview: '',
|
lastResponsePreview: '',
|
||||||
@@ -4026,7 +4376,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages(hasCachedMessages ? cachedMessages : []);
|
setMessages(hasCachedMessages ? cachedMessages : []);
|
||||||
setRequestItems((previous) => previous.filter((item) => item.sessionId === sessionId));
|
setRequestItems((previous) => {
|
||||||
|
const visibleRequestIds = collectVisibleConversationRequestIds(cachedMessages);
|
||||||
|
|
||||||
|
return previous.filter(
|
||||||
|
(item) =>
|
||||||
|
item.sessionId === sessionId &&
|
||||||
|
(visibleRequestIds.size === 0 ? !hasCachedMessages : visibleRequestIds.has(item.requestId)),
|
||||||
|
);
|
||||||
|
});
|
||||||
setActivePreviewId(null);
|
setActivePreviewId(null);
|
||||||
setIsPreviewModalOpen(false);
|
setIsPreviewModalOpen(false);
|
||||||
setActiveSystemStatus(null);
|
setActiveSystemStatus(null);
|
||||||
@@ -4201,6 +4559,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
cancelPendingRequest,
|
cancelPendingRequest,
|
||||||
|
handleClearConversation,
|
||||||
deleteStoredRequest,
|
deleteStoredRequest,
|
||||||
handleDeleteConversation,
|
handleDeleteConversation,
|
||||||
handleRenameConversation,
|
handleRenameConversation,
|
||||||
@@ -4239,6 +4598,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
replaceChatSessionInUrl,
|
replaceChatSessionInUrl,
|
||||||
messageApi,
|
messageApi,
|
||||||
});
|
});
|
||||||
|
const openClearConversationDataModal = useCallback(() => {
|
||||||
|
if (!activeConversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingClearConversationSessionId(activeConversation.sessionId);
|
||||||
|
}, [activeConversation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionState !== 'connected') {
|
if (connectionState !== 'connected') {
|
||||||
@@ -4269,13 +4635,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
updatePendingMessageStatus(request.requestId, null, request.retryCount);
|
updatePendingMessageStatus(request.requestId, null, request.retryCount);
|
||||||
return [];
|
return [];
|
||||||
} catch {
|
} catch {
|
||||||
const nextRetryCount = request.retryCount + 1;
|
const nextRetryCount = Math.min(request.retryCount + 1, CHAT_MAX_RETRY_ATTEMPTS);
|
||||||
|
|
||||||
if (nextRetryCount >= CHAT_MAX_RETRY_ATTEMPTS) {
|
|
||||||
updatePendingMessageStatus(request.requestId, 'failed', nextRetryCount);
|
|
||||||
return [{ ...request, retryCount: nextRetryCount, failed: true }];
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount);
|
updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount);
|
||||||
return [{ ...request, retryCount: nextRetryCount }];
|
return [{ ...request, retryCount: nextRetryCount }];
|
||||||
}
|
}
|
||||||
@@ -4787,7 +5147,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
} = useConversationComposerController({
|
} = useConversationComposerController({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
appConfigChat: appConfig.chat,
|
appConfigChat: appConfig.chat,
|
||||||
draft,
|
getDraft: () => draftRef.current,
|
||||||
composerAttachments,
|
composerAttachments,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
selectedChatType: effectiveChatType
|
selectedChatType: effectiveChatType
|
||||||
@@ -4802,7 +5162,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
messagesRef,
|
messagesRef,
|
||||||
pendingRequestsRef,
|
pendingRequestsRef,
|
||||||
shouldStickToBottomRef,
|
shouldStickToBottomRef,
|
||||||
setDraft,
|
setDraft: setDraftValue,
|
||||||
setComposerAttachments,
|
setComposerAttachments,
|
||||||
setIsComposerAttachmentUploading,
|
setIsComposerAttachmentUploading,
|
||||||
setMessages,
|
setMessages,
|
||||||
@@ -4865,7 +5225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPendingImportedDraftRequest(null);
|
setPendingImportedDraftRequest(null);
|
||||||
setDraft('');
|
setDraftValue('');
|
||||||
executeSendMessage({
|
executeSendMessage({
|
||||||
mode: pendingImportedDraftRequest.sendMode,
|
mode: pendingImportedDraftRequest.sendMode,
|
||||||
text: pendingImportedDraftRequest.text,
|
text: pendingImportedDraftRequest.text,
|
||||||
@@ -4886,7 +5246,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
pendingImportedDraftRequest,
|
pendingImportedDraftRequest,
|
||||||
requestedSessionId,
|
requestedSessionId,
|
||||||
selectedChatType,
|
selectedChatType,
|
||||||
setDraft,
|
setDraftValue,
|
||||||
setSelectedChatTypeId,
|
setSelectedChatTypeId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -4905,7 +5265,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
description: resolveComposedChatTypeDescription(selectedChatType),
|
description: resolveComposedChatTypeDescription(selectedChatType),
|
||||||
}
|
}
|
||||||
: (availableChatTypes[0] ?? null));
|
: (availableChatTypes[0] ?? null));
|
||||||
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
|
const trimmed = buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments).trim();
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return;
|
return;
|
||||||
@@ -4944,7 +5304,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
buildOutgoingMessageText,
|
buildOutgoingMessageText,
|
||||||
composerAttachments,
|
composerAttachments,
|
||||||
createLocalMessage,
|
createLocalMessage,
|
||||||
draft,
|
draftRef,
|
||||||
effectiveChatType,
|
effectiveChatType,
|
||||||
executeSendMessage,
|
executeSendMessage,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
@@ -4981,6 +5341,44 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
[handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage],
|
[handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePromptSubmit = useCallback(
|
||||||
|
async ({ text, mode }: { text: string; mode: 'queue' | 'direct' }) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectiveChatType) {
|
||||||
|
setMessages((previous) => [
|
||||||
|
...previous.slice(-39),
|
||||||
|
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 prompt 선택을 전송하지 못했습니다.'),
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeSessionId.trim()) {
|
||||||
|
setMessages((previous) => [
|
||||||
|
...previous.slice(-39),
|
||||||
|
createLocalMessage('활성 대화방이 없어서 prompt 선택을 전송하지 못했습니다.'),
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
executeSendMessage({
|
||||||
|
mode,
|
||||||
|
text: trimmed,
|
||||||
|
chatTypeId: effectiveChatType.id,
|
||||||
|
chatTypeLabel: effectiveChatType.name,
|
||||||
|
chatTypeDescription: effectiveChatTypeDescription,
|
||||||
|
includedContextCount: 0,
|
||||||
|
omittedContextCount: 0,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[activeSessionId, createLocalMessage, effectiveChatType, effectiveChatTypeDescription, executeSendMessage, setMessages],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCopyMessage = async (message: ChatMessage) => {
|
const handleCopyMessage = async (message: ChatMessage) => {
|
||||||
await copyText(message.text);
|
await copyText(message.text);
|
||||||
setCopiedMessageId(message.id);
|
setCopiedMessageId(message.id);
|
||||||
@@ -5187,6 +5585,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="채팅방 데이터 초기화">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label="채팅방 데이터 초기화"
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileActionGroupOpen(false);
|
||||||
|
openClearConversationDataModal();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -5249,6 +5659,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="채팅방 데이터 초기화">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label="채팅방 데이터 초기화"
|
||||||
|
onClick={openClearConversationDataModal}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -5347,7 +5766,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
isSystemStatusPending={isSystemStatusPending}
|
isSystemStatusPending={isSystemStatusPending}
|
||||||
showScrollToBottom={showScrollToBottom}
|
showScrollToBottom={showScrollToBottom}
|
||||||
copiedMessageId={copiedMessageId}
|
copiedMessageId={copiedMessageId}
|
||||||
draft={draft}
|
draft={draftSeed.value}
|
||||||
|
draftVersion={draftSeed.version}
|
||||||
composerAttachments={composerAttachments}
|
composerAttachments={composerAttachments}
|
||||||
isConversationLoading={isConversationContentLoading}
|
isConversationLoading={isConversationContentLoading}
|
||||||
conversationLoadingLabel={conversationLoadingLabel}
|
conversationLoadingLabel={conversationLoadingLabel}
|
||||||
@@ -5395,7 +5815,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
setIsSendWithoutContextEnabled((current) => !current);
|
setIsSendWithoutContextEnabled((current) => !current);
|
||||||
}}
|
}}
|
||||||
onClearDraft={() => {
|
onClearDraft={() => {
|
||||||
setDraft('');
|
setDraftValue('');
|
||||||
}}
|
}}
|
||||||
onToggleResourceStrip={() => {
|
onToggleResourceStrip={() => {
|
||||||
setIsResourceStripOpen((current) => !current);
|
setIsResourceStripOpen((current) => !current);
|
||||||
@@ -5431,6 +5851,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
void deleteStoredRequest(message.clientRequestId);
|
void deleteStoredRequest(message.clientRequestId);
|
||||||
}}
|
}}
|
||||||
onRemoveQueuedRequest={removeQueuedComposerRequest}
|
onRemoveQueuedRequest={removeQueuedComposerRequest}
|
||||||
|
onSubmitPrompt={handlePromptSubmit}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="app-chat-panel__conversation-empty">
|
<div className="app-chat-panel__conversation-empty">
|
||||||
@@ -5508,15 +5929,35 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
children: (
|
children: (
|
||||||
<div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
|
<div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
|
||||||
<div className="app-chat-panel__context-drawer-section-head">
|
<div className="app-chat-panel__context-drawer-section-head">
|
||||||
<Text strong>현재 채팅유형</Text>
|
<Text strong>채팅기본유형</Text>
|
||||||
<Text type="secondary">다른 유형을 고르는 대신, 이 방이 쓰는 기본 문맥 내용을 바로 확인하고 수정합니다.</Text>
|
<Text type="secondary">이 채팅방이 기본으로 사용할 유형을 선택하고, 그 유형의 기본 문맥도 함께 수정할 수 있습니다.</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<Select
|
||||||
|
value={editingRoomChatTypeId ?? undefined}
|
||||||
|
placeholder="채팅유형을 선택하세요."
|
||||||
|
options={availableChatTypes.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
onChange={(nextChatTypeId) => {
|
||||||
|
setEditingRoomChatTypeId(nextChatTypeId);
|
||||||
|
const nextChatType =
|
||||||
|
chatTypes.find((item) => item.id === nextChatTypeId) ??
|
||||||
|
availableChatTypes.find((item) => item.id === nextChatTypeId) ??
|
||||||
|
null;
|
||||||
|
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
||||||
|
|
||||||
|
if (!activeRoomContextSettings && !isEditingRoomDefaultContextsDirty) {
|
||||||
|
setEditingRoomDefaultContextIds(resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{contextDrawerChatType ? (
|
{contextDrawerChatType ? (
|
||||||
<>
|
<>
|
||||||
<div className="app-chat-panel__context-drawer-card app-chat-panel__context-drawer-card--readonly">
|
<div className="app-chat-panel__context-drawer-card app-chat-panel__context-drawer-card--readonly">
|
||||||
<span className="app-chat-panel__context-drawer-card-title">{contextDrawerChatType.name}</span>
|
<span className="app-chat-panel__context-drawer-card-title">{contextDrawerChatType.name}</span>
|
||||||
<Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
|
<Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
|
||||||
저장하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다.
|
아래 문맥 설명을 수정하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-chat-panel__context-drawer-textarea-shell">
|
<div className="app-chat-panel__context-drawer-textarea-shell">
|
||||||
@@ -5579,6 +6020,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
className="app-chat-panel__context-drawer-checkbox"
|
className="app-chat-panel__context-drawer-checkbox"
|
||||||
value={editingRoomDefaultContextIds}
|
value={editingRoomDefaultContextIds}
|
||||||
onChange={(checkedValues) => {
|
onChange={(checkedValues) => {
|
||||||
|
setIsEditingRoomDefaultContextsDirty(true);
|
||||||
setEditingRoomDefaultContextIds(
|
setEditingRoomDefaultContextIds(
|
||||||
checkedValues
|
checkedValues
|
||||||
.map((value) => String(value).trim())
|
.map((value) => String(value).trim())
|
||||||
@@ -5894,6 +6336,34 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(pendingClearConversation)}
|
||||||
|
title="채팅방 데이터를 초기화할까요?"
|
||||||
|
okText="초기화"
|
||||||
|
cancelText="취소"
|
||||||
|
okButtonProps={{ danger: true, autoFocus: true }}
|
||||||
|
modalRender={renderModalWithEnterConfirm}
|
||||||
|
zIndex={1700}
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingClearConversationSessionId(null);
|
||||||
|
}}
|
||||||
|
onOk={async () => {
|
||||||
|
const targetSessionId = pendingClearConversationSessionId;
|
||||||
|
|
||||||
|
if (!targetSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleClearConversation(targetSessionId);
|
||||||
|
setPendingClearConversationSessionId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{pendingClearConversation?.title
|
||||||
|
? `"${pendingClearConversation.title}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.`
|
||||||
|
: '채팅방 이름과 설정은 유지하고, 메시지·요청·활동 로그만 초기화됩니다.'}
|
||||||
|
</Text>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(pendingDeleteConversation)}
|
open={Boolean(pendingDeleteConversation)}
|
||||||
title="대화방을 삭제할까요?"
|
title="대화방을 삭제할까요?"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { fetchPlanItems } from '../../features/planBoard/api';
|
import { fetchPlanItems } from '../../features/planBoard/api';
|
||||||
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
|
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
|
||||||
import {
|
import {
|
||||||
cancelServerRestartReservation,
|
cancelServerRestartReservation,
|
||||||
@@ -819,6 +820,32 @@ function getServerRestartReservationOverlayState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reservation.status === 'executing') {
|
if (reservation.status === 'executing') {
|
||||||
|
if (reservation.target === 'work-server') {
|
||||||
|
return {
|
||||||
|
title: 'WORK 서버 재기동 중',
|
||||||
|
statusText: '새 런타임 반영 중',
|
||||||
|
detail: reservation.waitingReason?.trim() || 'WORK 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
|
||||||
|
steps: [
|
||||||
|
{ label: '재기동 요청', status: 'done' },
|
||||||
|
{ label: 'WORK 재기동', status: 'active' },
|
||||||
|
{ label: '정상 기동 확인', status: 'pending' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reservation.target === 'test') {
|
||||||
|
return {
|
||||||
|
title: 'TEST 서버 재기동 중',
|
||||||
|
statusText: '새 런타임 반영 중',
|
||||||
|
detail: reservation.waitingReason?.trim() || 'TEST 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
|
||||||
|
steps: [
|
||||||
|
{ label: '재기동 요청', status: 'done' },
|
||||||
|
{ label: 'TEST 재기동', status: 'active' },
|
||||||
|
{ label: '정상 기동 확인', status: 'pending' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const startedTimestamp = reservation.startedAt ? Date.parse(reservation.startedAt) : Number.NaN;
|
const startedTimestamp = reservation.startedAt ? Date.parse(reservation.startedAt) : Number.NaN;
|
||||||
const workServerRestartAt = Number.isFinite(startedTimestamp)
|
const workServerRestartAt = Number.isFinite(startedTimestamp)
|
||||||
? startedTimestamp + RESERVED_RESTART_WORK_SERVER_DELAY_MS
|
? startedTimestamp + RESERVED_RESTART_WORK_SERVER_DELAY_MS
|
||||||
@@ -858,6 +885,33 @@ function getServerRestartReservationOverlayState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reservation.status === 'recovering') {
|
||||||
|
const targetLabel = reservation.autoFix.targetKey ? `${reservation.autoFix.targetKey.toUpperCase()} 자동 개선` : 'Codex 자동 개선';
|
||||||
|
const statusText =
|
||||||
|
reservation.autoFix.status === 'failed'
|
||||||
|
? '자동 개선 실패'
|
||||||
|
: reservation.autoFix.status === 'completed'
|
||||||
|
? '자동 개선 완료'
|
||||||
|
: '빌드 오류 분석 및 수정 중';
|
||||||
|
const detail =
|
||||||
|
reservation.autoFix.detail?.trim()
|
||||||
|
|| reservation.autoFix.summary?.trim()
|
||||||
|
|| reservation.waitingReason?.trim()
|
||||||
|
|| '재기동을 막는 빌드 오류를 Codex가 자동으로 수정한 뒤 재기동을 다시 시도합니다.';
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: targetLabel,
|
||||||
|
statusText,
|
||||||
|
detail,
|
||||||
|
steps: [
|
||||||
|
{ label: reservation.target === 'all' ? '예약 확인' : '재기동 요청', status: 'done' },
|
||||||
|
{ label: '빌드 실패 감지', status: 'done' },
|
||||||
|
{ label: 'Codex 자동 개선', status: reservation.autoFix.status === 'completed' ? 'done' : 'active' },
|
||||||
|
{ label: '재기동 재시도', status: reservation.autoFix.status === 'completed' ? 'active' : 'pending' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (reservation.status === 'completed' && reloadPending) {
|
if (reservation.status === 'completed' && reloadPending) {
|
||||||
return {
|
return {
|
||||||
title: '재기동 완료 처리 중',
|
title: '재기동 완료 처리 중',
|
||||||
@@ -954,7 +1008,7 @@ function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: Serve
|
|||||||
|
|
||||||
function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) {
|
function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) {
|
||||||
if (!previous || !next) {
|
if (!previous || !next) {
|
||||||
return Boolean(next) && !next.buildRequired && !next.updateAvailable;
|
return next ? !next.buildRequired && !next.updateAvailable : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousNeedsUpdate = previous.buildRequired || previous.updateAvailable;
|
const previousNeedsUpdate = previous.buildRequired || previous.updateAvailable;
|
||||||
@@ -1410,27 +1464,6 @@ function waitForDuration(durationMs: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(text: string) {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', 'true');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MainHeader({
|
export function MainHeader({
|
||||||
activeTopMenu,
|
activeTopMenu,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
@@ -1571,6 +1604,8 @@ export function MainHeader({
|
|||||||
? '대기 중'
|
? '대기 중'
|
||||||
: serverRestartReservation.status === 'ready'
|
: serverRestartReservation.status === 'ready'
|
||||||
? '자동 실행 대기'
|
? '자동 실행 대기'
|
||||||
|
: serverRestartReservation.status === 'recovering'
|
||||||
|
? 'Codex 자동 개선 중'
|
||||||
: serverRestartReservation.status === 'executing'
|
: serverRestartReservation.status === 'executing'
|
||||||
? '실행 중'
|
? '실행 중'
|
||||||
: serverRestartReservation.status === 'completed'
|
: serverRestartReservation.status === 'completed'
|
||||||
@@ -1599,6 +1634,10 @@ export function MainHeader({
|
|||||||
? serverRestartReservationPendingSummary
|
? serverRestartReservationPendingSummary
|
||||||
: serverRestartReservation?.status === 'ready'
|
: serverRestartReservation?.status === 'ready'
|
||||||
? serverRestartReservationPendingSummary
|
? serverRestartReservationPendingSummary
|
||||||
|
: serverRestartReservation?.status === 'recovering'
|
||||||
|
? serverRestartReservation.autoFix.summary
|
||||||
|
?? serverRestartReservation.waitingReason
|
||||||
|
?? '빌드 오류 자동 개선을 진행 중입니다.'
|
||||||
: serverRestartReservation?.status === 'executing'
|
: serverRestartReservation?.status === 'executing'
|
||||||
? serverRestartReservationTimingLabel
|
? serverRestartReservationTimingLabel
|
||||||
: serverRestartReservation?.status === 'completed'
|
: serverRestartReservation?.status === 'completed'
|
||||||
@@ -3050,9 +3089,12 @@ export function MainHeader({
|
|||||||
aria-label="메시지 복사"
|
aria-label="메시지 복사"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void copyText(feedback.message)
|
void copyTextToClipboard(feedback.message)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
setCopyFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: '메시지를 복사했습니다.',
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||||
@@ -3247,9 +3289,12 @@ export function MainHeader({
|
|||||||
aria-label="메시지 복사"
|
aria-label="메시지 복사"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void copyText(appConfigFeedback.message)
|
void copyTextToClipboard(appConfigFeedback.message)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
setAppConfigCopyFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: '메시지를 복사했습니다.',
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||||
@@ -4008,9 +4053,12 @@ export function MainHeader({
|
|||||||
aria-label="메시지 복사"
|
aria-label="메시지 복사"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void copyText(appConfigFeedback.message)
|
void copyTextToClipboard(appConfigFeedback.message)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
setAppConfigCopyFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: '메시지를 복사했습니다.',
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||||
@@ -4090,9 +4138,12 @@ export function MainHeader({
|
|||||||
aria-label="메시지 복사"
|
aria-label="메시지 복사"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void copyText(appConfigFeedback.message)
|
void copyTextToClipboard(appConfigFeedback.message)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
setAppConfigCopyFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: '메시지를 복사했습니다.',
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||||
|
|||||||
668
src/app/main/ManagementPage.shared.css
Executable file
668
src/app/main/ManagementPage.shared.css
Executable file
@@ -0,0 +1,668 @@
|
|||||||
|
.chat-type-management-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card,
|
||||||
|
.chat-type-management-page__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-head {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-head-title,
|
||||||
|
.chat-type-management-page .ant-card-extra {
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list,
|
||||||
|
.chat-type-management-page__editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list .ant-list {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list-scroll > .ant-list + .ant-list,
|
||||||
|
.chat-type-management-page__list-scroll > .ant-empty {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-form {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 0 calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-form .ant-form-item {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list-header .ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__list-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__item {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__item--active {
|
||||||
|
border-color: #1677ff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__item-main {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__default-context-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(248, 250, 252, 0.82);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-field--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-options {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-space {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-option-copy {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions .ant-btn {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-grid {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-grid--maximized {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-height: min(720px, calc(100dvh - 236px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 .ant-form-item-control,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: clamp(360px, calc(100dvh - 360px), 720px);
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-textarea textarea {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||||
|
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 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
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__meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px 14px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-grid--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item .ant-form-item-control-input {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--name {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__editor-scroll,
|
||||||
|
.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__editor-scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-head-title,
|
||||||
|
.chat-type-management-page .ant-card-extra {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll {
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__mobile-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__default-context-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
|
||||||
|
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane--mobile-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane,
|
||||||
|
.chat-type-management-page__markdown-field,
|
||||||
|
.chat-type-management-page__markdown-editor,
|
||||||
|
.chat-type-management-page__markdown-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__markdown-textarea {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-textarea textarea {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-pane
|
||||||
|
.ant-form-item,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-preview {
|
||||||
|
min-height: clamp(220px, calc(100dvh - 560px), 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-field,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-editor,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-grid,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-pane,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-preview {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-pane
|
||||||
|
.ant-form-item,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-pane
|
||||||
|
.ant-form-item-control,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-pane
|
||||||
|
.ant-form-item-control-input,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-pane
|
||||||
|
.ant-form-item-control-input-content {
|
||||||
|
flex: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-textarea,
|
||||||
|
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||||
|
.chat-type-management-page__markdown-textarea textarea {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized {
|
||||||
|
height: calc(100dvh - 52px);
|
||||||
|
max-height: calc(100dvh - 52px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .ant-card-head {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .ant-card-head-title,
|
||||||
|
.chat-type-management-page--pane-maximized .ant-card-extra {
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .ant-card-body {
|
||||||
|
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
|
||||||
|
.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__editor-scroll,
|
||||||
|
.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,
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||||
|
gap: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||||
|
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||||
|
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||||
|
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page--pane-maximized
|
||||||
|
.chat-type-management-page__markdown-pane
|
||||||
|
.ant-form-item-control-input-content {
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-preview {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-preview-body {
|
||||||
|
max-height: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions .ant-btn {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
.resource-management-page {
|
.resource-management-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 320px) minmax(320px, 1fr) minmax(360px, 1.08fr);
|
grid-template-columns: minmax(220px, 0.82fr) minmax(280px, 1fr) minmax(300px, 1.08fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -17,6 +19,9 @@
|
|||||||
.resource-management-page__sidebar,
|
.resource-management-page__sidebar,
|
||||||
.resource-management-page__content,
|
.resource-management-page__content,
|
||||||
.resource-management-page__preview-card {
|
.resource-management-page__preview-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
@@ -39,6 +44,19 @@
|
|||||||
.resource-management-page__content .ant-card-head,
|
.resource-management-page__content .ant-card-head,
|
||||||
.resource-management-page__preview-card .ant-card-head {
|
.resource-management-page__preview-card .ant-card-head {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__sidebar .ant-card-head-wrapper,
|
||||||
|
.resource-management-page__content .ant-card-head-wrapper,
|
||||||
|
.resource-management-page__preview-card .ant-card-head-wrapper,
|
||||||
|
.resource-management-page__sidebar .ant-card-head-title,
|
||||||
|
.resource-management-page__content .ant-card-head-title,
|
||||||
|
.resource-management-page__preview-card .ant-card-head-title,
|
||||||
|
.resource-management-page__sidebar .ant-card-extra,
|
||||||
|
.resource-management-page__content .ant-card-extra,
|
||||||
|
.resource-management-page__preview-card .ant-card-extra {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__sidebar .ant-card-body,
|
.resource-management-page__sidebar .ant-card-body,
|
||||||
@@ -53,6 +71,29 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__card-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__card-title-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__card-title-subtitle {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__scope-copy {
|
.resource-management-page__scope-copy {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -62,6 +103,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
||||||
@@ -77,12 +119,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__tree-title .ant-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__content {
|
.resource-management-page__content {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -105,6 +153,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__toolbar-main,
|
.resource-management-page__toolbar-main,
|
||||||
@@ -115,6 +164,25 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__toolbar-main {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__toolbar-actions {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__toolbar-path {
|
||||||
|
min-width: min(100%, 320px);
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__toolbar-path .ant-typography {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__guide {
|
.resource-management-page__guide {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -138,7 +206,7 @@
|
|||||||
.resource-management-page__list-header,
|
.resource-management-page__list-header,
|
||||||
.resource-management-page__list-row {
|
.resource-management-page__list-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 160px 88px 76px;
|
grid-template-columns: minmax(0, 1.8fr) minmax(116px, 0.95fr) minmax(72px, 0.5fr) 72px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -155,6 +223,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__list-row {
|
.resource-management-page__list-row {
|
||||||
@@ -184,13 +253,22 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__list-meta {
|
.resource-management-page__list-meta {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__list-meta > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__entry-name-text {
|
.resource-management-page__entry-name-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -206,6 +284,96 @@
|
|||||||
padding-bottom: 17px;
|
padding-bottom: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-title-file {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-meta .ant-typography {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-meta .ant-typography-copy {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-nav {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-nav-wrap,
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-nav-list {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-content-holder,
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-content,
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-tabpane {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-content-holder {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-tabs .ant-tabs-tabpane-active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__tab-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__editor-panel {
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__editor.ant-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100% !important;
|
||||||
|
padding-bottom: max(16px, calc(env(safe-area-inset-bottom, 0px) + 10px));
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__editor-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__preview-frame {
|
.resource-management-page__preview-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -217,12 +385,36 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__html-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__html-preview > .resource-management-page__rich-preview {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__html-mode-switch {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__html-mode-switch .ant-btn {
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__text-preview {
|
.resource-management-page__text-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -236,6 +428,124 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: inset 0 0 0 1px #d9e1f2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--markdown {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview p,
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview li,
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview a,
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--markdown .markdown-preview code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--code {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--code .previewer-ui__editor,
|
||||||
|
.resource-management-page__rich-preview--code .codex-diff-previewer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--code .previewer-ui__editor-body,
|
||||||
|
.resource-management-page__rich-preview--code .codex-diff-previewer__diff-body,
|
||||||
|
.resource-management-page__rich-preview--code .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid {
|
||||||
|
width: 100%;
|
||||||
|
min-width: max-content;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid th,
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a8a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid tbody tr:nth-child(even) td {
|
||||||
|
background: rgba(248, 250, 252, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__image-preview {
|
.resource-management-page__image-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@@ -447,13 +757,13 @@
|
|||||||
.resource-management-page {
|
.resource-management-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page--mobile {
|
.resource-management-page--mobile {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-inline: 1px;
|
padding-inline: 0;
|
||||||
padding-bottom: max(10px, calc(env(safe-area-inset-bottom, 0px) + 6px));
|
padding-bottom: max(6px, calc(env(safe-area-inset-bottom, 0px) + 2px));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,29 +774,85 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__mobile-nav {
|
.resource-management-page__mobile-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(244, 247, 252, 0.96);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(217, 225, 242, 0.92);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__mobile-card {
|
.resource-management-page__mobile-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__mobile-card > .ant-card {
|
.resource-management-page__mobile-card > .ant-card {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: auto;
|
height: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__mobile-nav-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: #52607a;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__mobile-nav-button:disabled {
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__mobile-nav-button--active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(186, 209, 255, 0.92),
|
||||||
|
0 6px 18px rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__mobile-nav-button-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__mobile-nav-button-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__sidebar .ant-card-body,
|
.resource-management-page__sidebar .ant-card-body,
|
||||||
.resource-management-page__content .ant-card-body,
|
.resource-management-page__content .ant-card-body,
|
||||||
.resource-management-page__preview-card .ant-card-body {
|
.resource-management-page__preview-card .ant-card-body {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__list-header {
|
.resource-management-page__list-header {
|
||||||
@@ -527,6 +893,20 @@
|
|||||||
min-width: min(208px, calc(100vw - 24px));
|
min-width: min(208px, calc(100vw - 24px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__tree {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__tree .ant-tree-treenode,
|
||||||
|
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__tree .ant-tree-switcher {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__toolbar {
|
.resource-management-page__toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -536,14 +916,40 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__toolbar-path {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__toolbar-actions .ant-btn {
|
.resource-management-page__toolbar-actions .ant-btn {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__preview-meta .ant-typography,
|
||||||
|
.resource-management-page__preview-meta .ant-space-compact {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__preview-frame,
|
.resource-management-page__preview-frame,
|
||||||
.resource-management-page__image-preview,
|
.resource-management-page__image-preview,
|
||||||
.resource-management-page__text-preview {
|
.resource-management-page__text-preview,
|
||||||
min-height: 220px;
|
.resource-management-page__rich-preview {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__html-mode-switch {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__html-mode-switch .ant-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-management-page__editor {
|
.resource-management-page__editor {
|
||||||
@@ -561,7 +967,7 @@
|
|||||||
|
|
||||||
.resource-management-page__preview-card .ant-tabs-content-holder {
|
.resource-management-page__preview-card .ant-tabs-content-holder {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-bottom: 1px;
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,10 +976,26 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page__editor-panel {
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-management-page__editor-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page--panel-preview .resource-management-page__preview-card .ant-card-body {
|
.resource-management-page--panel-preview .resource-management-page__preview-card .ant-card-body {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-management-page--panel-tree .resource-management-page__sidebar,
|
||||||
|
.resource-management-page--panel-list .resource-management-page__content,
|
||||||
|
.resource-management-page--panel-preview .resource-management-page__preview-card {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
.resource-management-page__preview-modal-wrap--mobile .ant-modal {
|
.resource-management-page__preview-modal-wrap--mobile .ant-modal {
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ReactNode,
|
||||||
type TouchEvent as ReactTouchEvent,
|
type TouchEvent as ReactTouchEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { Key } from 'react';
|
import type { Key } from 'react';
|
||||||
|
import { MarkdownPreviewContent } from '../../components/markdownPreview/MarkdownPreviewContent';
|
||||||
|
import { CodexDiffBlock } from '../../components/previewer';
|
||||||
|
import { inferCodeLanguage, renderEditorBlock } from '../../components/previewer/renderers';
|
||||||
|
import '../../components/previewer/PreviewerUI.css';
|
||||||
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
copyResourceManagerItem,
|
copyResourceManagerItem,
|
||||||
createResourceManagerDirectory,
|
createResourceManagerDirectory,
|
||||||
@@ -47,6 +53,7 @@ import {
|
|||||||
type ResourceManagerTreeNode,
|
type ResourceManagerTreeNode,
|
||||||
type ResourceManagerTreeRoot,
|
type ResourceManagerTreeRoot,
|
||||||
} from './resourceManagerApi';
|
} from './resourceManagerApi';
|
||||||
|
import { ChatDataTablePreview, resolveTabularPreviewModel } from './mainChatPanel/ChatDataTablePreview';
|
||||||
import './ResourceManagementPage.css';
|
import './ResourceManagementPage.css';
|
||||||
|
|
||||||
const { Paragraph, Text } = Typography;
|
const { Paragraph, Text } = Typography;
|
||||||
@@ -81,6 +88,8 @@ type PreviewOffset = {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HtmlPreviewMode = 'browser' | 'source';
|
||||||
|
|
||||||
type CreateEntryModalState =
|
type CreateEntryModalState =
|
||||||
| {
|
| {
|
||||||
type: 'file' | 'folder';
|
type: 'file' | 'folder';
|
||||||
@@ -150,6 +159,10 @@ function isPdfFile(file: ResourceManagerFileDetail | null) {
|
|||||||
return file?.mimeType === 'application/pdf';
|
return file?.mimeType === 'application/pdf';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFileExtension(file: ResourceManagerFileDetail | null) {
|
||||||
|
return file?.extension?.replace(/^\./, '').trim().toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return false;
|
return false;
|
||||||
@@ -158,12 +171,121 @@ function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
|||||||
return file.mimeType.includes('html') || file.extension === '.html' || file.extension === '.htm';
|
return file.mimeType.includes('html') || file.extension === '.html' || file.extension === '.htm';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextPreviewFile(file: ResourceManagerFileDetail | null) {
|
function buildHtmlPreviewDocument(file: ResourceManagerFileDetail) {
|
||||||
|
const content = file.content ?? '';
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
return '<!doctype html><html><body></body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHref = file.previewUrl.replace(/"/g, '"');
|
||||||
|
const baseTag = `<base href="${baseHref}">`;
|
||||||
|
|
||||||
|
if (/<head[\s>]/i.test(content)) {
|
||||||
|
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${baseTag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/<html[\s>]/i.test(content)) {
|
||||||
|
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${baseTag}</head>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!doctype html><html><head>${baseTag}</head><body>${content}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownFile(file: ResourceManagerFileDetail | null) {
|
||||||
|
const extension = normalizeFileExtension(file);
|
||||||
|
return extension === 'md' || extension === 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiffFile(file: ResourceManagerFileDetail | null) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.isTextEditable && !isHtmlFile(file);
|
const extension = normalizeFileExtension(file);
|
||||||
|
if (extension === 'diff' || extension === 'patch') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = file.content ?? '';
|
||||||
|
return /^(diff --git|@@\s|---\s|\+\+\+\s)/m.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCodeLanguageForResource(file: ResourceManagerFileDetail) {
|
||||||
|
const extension = normalizeFileExtension(file);
|
||||||
|
|
||||||
|
if (extension === 'tsx' || extension === 'ts') {
|
||||||
|
return 'typescript';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') {
|
||||||
|
return 'javascript';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'json') {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'css') {
|
||||||
|
return 'css';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'scss') {
|
||||||
|
return 'scss';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'html' || extension === 'htm') {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'java') {
|
||||||
|
return 'java';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'kt') {
|
||||||
|
return 'kotlin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'py') {
|
||||||
|
return 'python';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'go') {
|
||||||
|
return 'go';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'rs') {
|
||||||
|
return 'rust';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'sql') {
|
||||||
|
return 'sql';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
extension === 'sh' ||
|
||||||
|
extension === 'bash' ||
|
||||||
|
extension === 'zsh' ||
|
||||||
|
extension === 'env' ||
|
||||||
|
extension === 'ini' ||
|
||||||
|
extension === 'conf'
|
||||||
|
) {
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'yml' || extension === 'yaml') {
|
||||||
|
return 'yaml';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'xml') {
|
||||||
|
return 'xml';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === 'svg') {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferCodeLanguage(extension || 'text');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNativeContextMenuBypassTarget(target: EventTarget | null) {
|
function isNativeContextMenuBypassTarget(target: EventTarget | null) {
|
||||||
@@ -317,6 +439,15 @@ function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: stri
|
|||||||
return visit(treeRoot.tree);
|
return visit(treeRoot.tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCardTitle(title: string, subtitle?: ReactNode) {
|
||||||
|
return (
|
||||||
|
<div className="resource-management-page__card-title">
|
||||||
|
<span className="resource-management-page__card-title-text">{title}</span>
|
||||||
|
{subtitle ? <span className="resource-management-page__card-title-subtitle">{subtitle}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ResourceManagementPage() {
|
export function ResourceManagementPage() {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -335,6 +466,7 @@ export function ResourceManagementPage() {
|
|||||||
const [selectedFile, setSelectedFile] = useState<ResourceManagerFileDetail | null>(null);
|
const [selectedFile, setSelectedFile] = useState<ResourceManagerFileDetail | null>(null);
|
||||||
const [editorContent, setEditorContent] = useState('');
|
const [editorContent, setEditorContent] = useState('');
|
||||||
const [activePreviewTab, setActivePreviewTab] = useState('preview');
|
const [activePreviewTab, setActivePreviewTab] = useState('preview');
|
||||||
|
const [htmlPreviewMode, setHtmlPreviewMode] = useState<HtmlPreviewMode>('browser');
|
||||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||||
const [mobilePanel, setMobilePanel] = useState<MobilePanel>('list');
|
const [mobilePanel, setMobilePanel] = useState<MobilePanel>('list');
|
||||||
@@ -470,6 +602,7 @@ export function ResourceManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const directoryTargets = useMemo(() => collectDirectoryTargets(treeRoot), [treeRoot]);
|
const directoryTargets = useMemo(() => collectDirectoryTargets(treeRoot), [treeRoot]);
|
||||||
|
const currentDirectoryLabel = formatPathLabel(selectedDirectoryPath);
|
||||||
|
|
||||||
const updateViewportHeight = () => {
|
const updateViewportHeight = () => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -518,6 +651,7 @@ export function ResourceManagementPage() {
|
|||||||
setPreviewZoom(1);
|
setPreviewZoom(1);
|
||||||
setPreviewOffset({ x: 0, y: 0 });
|
setPreviewOffset({ x: 0, y: 0 });
|
||||||
previewTouchGestureRef.current = null;
|
previewTouchGestureRef.current = null;
|
||||||
|
setHtmlPreviewMode('browser');
|
||||||
}, [selectedFile?.path, isPreviewMaximized]);
|
}, [selectedFile?.path, isPreviewMaximized]);
|
||||||
|
|
||||||
const cancelLongPress = () => {
|
const cancelLongPress = () => {
|
||||||
@@ -1006,7 +1140,7 @@ export function ResourceManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleContextMenuAction = async (
|
const handleContextMenuAction = async (
|
||||||
action: 'open' | 'copy' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
action: 'open' | 'copy' | 'copy-path' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||||
) => {
|
) => {
|
||||||
const target = contextMenu.target;
|
const target = contextMenu.target;
|
||||||
setContextMenu((current) => ({ ...current, open: false, target: null }));
|
setContextMenu((current) => ({ ...current, open: false, target: null }));
|
||||||
@@ -1049,6 +1183,16 @@ export function ResourceManagementPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'copy-path') {
|
||||||
|
try {
|
||||||
|
await copyTextToClipboard(formatPathLabel(target.entry.path));
|
||||||
|
message.success('경로를 복사했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '경로 복사에 실패했습니다.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'move') {
|
if (action === 'move') {
|
||||||
openCopyMoveModal('move', target.entry);
|
openCopyMoveModal('move', target.entry);
|
||||||
return;
|
return;
|
||||||
@@ -1065,7 +1209,7 @@ export function ResourceManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bindContextMenuAction = (
|
const bindContextMenuAction = (
|
||||||
action: 'open' | 'copy' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
action: 'open' | 'copy' | 'copy-path' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||||
) => ({
|
) => ({
|
||||||
onPointerDown: (event: ReactMouseEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>) => {
|
onPointerDown: (event: ReactMouseEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1074,13 +1218,17 @@ export function ResourceManagementPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canZoomPreview = activePreviewTab === 'preview' && (isImageFile(selectedFile) || isHtmlFile(selectedFile));
|
const canZoomPreview =
|
||||||
|
activePreviewTab === 'preview' &&
|
||||||
|
(isImageFile(selectedFile) || (isHtmlFile(selectedFile) && htmlPreviewMode === 'browser'));
|
||||||
|
|
||||||
const renderPreviewContent = (maximized = false) => {
|
const renderPreviewContent = (maximized = false) => {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileContent = selectedFile.content ?? '';
|
||||||
|
|
||||||
if (isImageFile(selectedFile)) {
|
if (isImageFile(selectedFile)) {
|
||||||
if (!maximized) {
|
if (!maximized) {
|
||||||
return <img alt={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__image-preview" />;
|
return <img alt={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__image-preview" />;
|
||||||
@@ -1109,12 +1257,17 @@ export function ResourceManagementPage() {
|
|||||||
return <iframe title={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__preview-frame" />;
|
return <iframe title={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__preview-frame" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTextPreviewFile(selectedFile)) {
|
if (isMarkdownFile(selectedFile)) {
|
||||||
return <pre className="resource-management-page__text-preview">{selectedFile.content ?? ''}</pre>;
|
return (
|
||||||
|
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--markdown">
|
||||||
|
<MarkdownPreviewContent content={fileContent || '# Preview\n\n표시할 markdown 본문이 없습니다.'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlFile(selectedFile) && maximized) {
|
if (isHtmlFile(selectedFile)) {
|
||||||
return (
|
const previewDocument = buildHtmlPreviewDocument(selectedFile);
|
||||||
|
const browserPreview = maximized ? (
|
||||||
<div
|
<div
|
||||||
ref={previewShellRef}
|
ref={previewShellRef}
|
||||||
className="resource-management-page__zoom-shell resource-management-page__zoom-shell--frame resource-management-page__zoom-shell--touch-zoom"
|
className="resource-management-page__zoom-shell resource-management-page__zoom-shell--frame resource-management-page__zoom-shell--touch-zoom"
|
||||||
@@ -1129,12 +1282,61 @@ export function ResourceManagementPage() {
|
|||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
title={selectedFile.name}
|
title={selectedFile.name}
|
||||||
src={selectedFile.previewUrl}
|
srcDoc={previewDocument}
|
||||||
className="resource-management-page__preview-frame resource-management-page__preview-frame--zoomable"
|
className="resource-management-page__preview-frame resource-management-page__preview-frame--zoomable"
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<iframe title={selectedFile.name} srcDoc={previewDocument} className="resource-management-page__preview-frame" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourcePreview = (
|
||||||
|
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||||
|
{renderEditorBlock(fileContent || '표시할 preview 본문이 없습니다.', resolveCodeLanguageForResource(selectedFile), 'code')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <div className="resource-management-page__html-preview">{htmlPreviewMode === 'browser' ? browserPreview : sourcePreview}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFile.isTextEditable) {
|
||||||
|
const tabularModel = resolveTabularPreviewModel(
|
||||||
|
{
|
||||||
|
label: selectedFile.name,
|
||||||
|
url: selectedFile.previewUrl,
|
||||||
|
kind: 'document',
|
||||||
|
},
|
||||||
|
fileContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tabularModel) {
|
||||||
|
return (
|
||||||
|
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--table">
|
||||||
|
<ChatDataTablePreview model={tabularModel} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDiffFile(selectedFile)) {
|
||||||
|
return (
|
||||||
|
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||||
|
<CodexDiffBlock
|
||||||
|
diffText={fileContent}
|
||||||
|
summary={`${selectedFile.name} 기준 raw diff preview입니다.`}
|
||||||
|
showToolbar={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFile.isTextEditable) {
|
||||||
|
return (
|
||||||
|
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||||
|
{renderEditorBlock(fileContent || '표시할 preview 본문이 없습니다.', resolveCodeLanguageForResource(selectedFile), 'code')}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,7 +1353,7 @@ export function ResourceManagementPage() {
|
|||||||
<span>미리보기</span>
|
<span>미리보기</span>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
children: renderPreviewContent(),
|
children: <div className="resource-management-page__tab-panel">{renderPreviewContent()}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
@@ -1162,22 +1364,21 @@ export function ResourceManagementPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
children: selectedFile.isTextEditable ? (
|
children: selectedFile.isTextEditable ? (
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<div className="resource-management-page__tab-panel resource-management-page__editor-panel">
|
||||||
<TextArea
|
<TextArea
|
||||||
value={editorContent}
|
value={editorContent}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setEditorContent(event.target.value);
|
setEditorContent(event.target.value);
|
||||||
}}
|
}}
|
||||||
className="resource-management-page__editor"
|
className="resource-management-page__editor"
|
||||||
autoSize={{ minRows: 14, maxRows: 22 }}
|
|
||||||
/>
|
/>
|
||||||
<Space>
|
<Space className="resource-management-page__editor-actions">
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSaveFile()}>
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSaveFile()}>
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
<Text type="secondary">{selectedFile.mimeType}</Text>
|
<Text type="secondary">{selectedFile.mimeType}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert showIcon type="warning" message="이 파일은 텍스트 편집을 지원하지 않습니다." />
|
<Alert showIcon type="warning" message="이 파일은 텍스트 편집을 지원하지 않습니다." />
|
||||||
),
|
),
|
||||||
@@ -1276,7 +1477,7 @@ export function ResourceManagementPage() {
|
|||||||
}, [treeRoot]);
|
}, [treeRoot]);
|
||||||
|
|
||||||
const renderTreeCard = () => (
|
const renderTreeCard = () => (
|
||||||
<Card title="리소스 트리" className="resource-management-page__sidebar">
|
<Card title={renderCardTitle('리소스 트리', 'resource/ 루트')} className="resource-management-page__sidebar">
|
||||||
<Text type="secondary" className="resource-management-page__scope-copy">
|
<Text type="secondary" className="resource-management-page__scope-copy">
|
||||||
서버별 분리 없이 같은 `resource/` 루트를 트리와 탐색기에서 함께 봅니다.
|
서버별 분리 없이 같은 `resource/` 루트를 트리와 탐색기에서 함께 봅니다.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1300,7 +1501,7 @@ export function ResourceManagementPage() {
|
|||||||
|
|
||||||
const renderListCard = () => (
|
const renderListCard = () => (
|
||||||
<Card
|
<Card
|
||||||
title="폴더 목록"
|
title={renderCardTitle('폴더 목록', `${directoryItems.length}개 항목`)}
|
||||||
className="resource-management-page__content"
|
className="resource-management-page__content"
|
||||||
extra={
|
extra={
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void refreshAll(selectedDirectoryPath, false)}>
|
<Button icon={<ReloadOutlined />} onClick={() => void refreshAll(selectedDirectoryPath, false)}>
|
||||||
@@ -1311,7 +1512,11 @@ export function ResourceManagementPage() {
|
|||||||
<div className="resource-management-page__workspace">
|
<div className="resource-management-page__workspace">
|
||||||
<div className="resource-management-page__toolbar">
|
<div className="resource-management-page__toolbar">
|
||||||
<div className="resource-management-page__toolbar-main">
|
<div className="resource-management-page__toolbar-main">
|
||||||
<Text strong>{formatPathLabel(selectedDirectoryPath)}</Text>
|
<div className="resource-management-page__toolbar-path">
|
||||||
|
<Text strong ellipsis={{ tooltip: currentDirectoryLabel }}>
|
||||||
|
{currentDirectoryLabel}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
{selectedDirectoryPath ? (
|
{selectedDirectoryPath ? (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1481,7 +1686,18 @@ export function ResourceManagementPage() {
|
|||||||
|
|
||||||
const renderPreviewCard = () => (
|
const renderPreviewCard = () => (
|
||||||
<Card
|
<Card
|
||||||
title={selectedFile ? `미리보기 / 편집 - ${selectedFile.name}` : '미리보기 / 편집'}
|
title={
|
||||||
|
renderCardTitle(
|
||||||
|
'미리보기 / 편집',
|
||||||
|
selectedFile ? (
|
||||||
|
<span className="resource-management-page__preview-title-file" title={selectedFile.name}>
|
||||||
|
{selectedFile.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'선택한 파일 없음'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
className="resource-management-page__preview-card"
|
className="resource-management-page__preview-card"
|
||||||
extra={
|
extra={
|
||||||
selectedFile && activePreviewTab === 'preview' ? (
|
selectedFile && activePreviewTab === 'preview' ? (
|
||||||
@@ -1504,11 +1720,36 @@ export function ResourceManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : selectedFile ? (
|
) : selectedFile ? (
|
||||||
<>
|
<>
|
||||||
<Space size={[8, 8]} wrap>
|
<Space size={[8, 8]} wrap className="resource-management-page__preview-meta">
|
||||||
<Text type="secondary">{formatPathLabel(selectedFile.path)}</Text>
|
<Text type="secondary" ellipsis={{ tooltip: formatPathLabel(selectedFile.path) }}>
|
||||||
|
{formatPathLabel(selectedFile.path)}
|
||||||
|
</Text>
|
||||||
<Text copyable={{ text: selectedFile.previewUrl }}>미리보기 URL 복사</Text>
|
<Text copyable={{ text: selectedFile.previewUrl }}>미리보기 URL 복사</Text>
|
||||||
|
{isHtmlFile(selectedFile) ? (
|
||||||
|
<Space.Compact className="resource-management-page__html-mode-switch">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={htmlPreviewMode === 'browser' ? 'primary' : 'default'}
|
||||||
|
onClick={() => setHtmlPreviewMode('browser')}
|
||||||
|
>
|
||||||
|
브라우저
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={htmlPreviewMode === 'source' ? 'primary' : 'default'}
|
||||||
|
onClick={() => setHtmlPreviewMode('source')}
|
||||||
|
>
|
||||||
|
소스
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
<Tabs activeKey={activePreviewTab} onChange={setActivePreviewTab} items={previewTabItems} />
|
<Tabs
|
||||||
|
className="resource-management-page__preview-tabs"
|
||||||
|
activeKey={activePreviewTab}
|
||||||
|
onChange={setActivePreviewTab}
|
||||||
|
items={previewTabItems}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Empty
|
<Empty
|
||||||
@@ -1519,6 +1760,35 @@ export function ResourceManagementPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderMobileNavButton = (panel: MobilePanel, label: string, icon: ReactNode, disabled = false) => {
|
||||||
|
const isActive = mobilePanel === panel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={panel}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
className={[
|
||||||
|
'resource-management-page__mobile-nav-button',
|
||||||
|
isActive ? 'resource-management-page__mobile-nav-button--active' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
setMobilePanel(panel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="resource-management-page__mobile-nav-button-icon">{icon}</span>
|
||||||
|
<span className="resource-management-page__mobile-nav-button-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -1535,7 +1805,7 @@ export function ResourceManagementPage() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSelectStartCapture={preventSelection}
|
onMouseDownCapture={preventSelection}
|
||||||
onDragStartCapture={preventSelection}
|
onDragStartCapture={preventSelection}
|
||||||
onPointerDownCapture={(event) => {
|
onPointerDownCapture={(event) => {
|
||||||
if (event.pointerType === 'touch' && !isNativeContextMenuBypassTarget(event.target)) {
|
if (event.pointerType === 'touch' && !isNativeContextMenuBypassTarget(event.target)) {
|
||||||
@@ -1545,20 +1815,10 @@ export function ResourceManagementPage() {
|
|||||||
>
|
>
|
||||||
{isMobileViewport ? (
|
{isMobileViewport ? (
|
||||||
<>
|
<>
|
||||||
<div className="resource-management-page__mobile-nav">
|
<div className="resource-management-page__mobile-nav" role="tablist" aria-label="리소스 보기 전환">
|
||||||
<Button type={mobilePanel === 'tree' ? 'primary' : 'default'} onClick={() => setMobilePanel('tree')}>
|
{renderMobileNavButton('tree', '트리', <FolderOpenOutlined />)}
|
||||||
트리
|
{renderMobileNavButton('list', '목록', <FileTextOutlined />)}
|
||||||
</Button>
|
{renderMobileNavButton('preview', '미리보기', <EyeOutlined />, !selectedFile)}
|
||||||
<Button type={mobilePanel === 'list' ? 'primary' : 'default'} onClick={() => setMobilePanel('list')}>
|
|
||||||
목록
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type={mobilePanel === 'preview' ? 'primary' : 'default'}
|
|
||||||
disabled={!selectedFile}
|
|
||||||
onClick={() => setMobilePanel('preview')}
|
|
||||||
>
|
|
||||||
미리보기
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="resource-management-page__mobile-card">
|
<div className="resource-management-page__mobile-card">
|
||||||
{mobilePanel === 'tree' ? renderTreeCard() : null}
|
{mobilePanel === 'tree' ? renderTreeCard() : null}
|
||||||
@@ -1600,6 +1860,12 @@ export function ResourceManagementPage() {
|
|||||||
<CopyOutlined />
|
<CopyOutlined />
|
||||||
<span>복사</span>
|
<span>복사</span>
|
||||||
</button>
|
</button>
|
||||||
|
{contextMenu.target.entry.type === 'directory' ? (
|
||||||
|
<button type="button" {...bindContextMenuAction('copy-path')}>
|
||||||
|
<CopyOutlined />
|
||||||
|
<span>경로 복사</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button type="button" {...bindContextMenuAction('move')}>
|
<button type="button" {...bindContextMenuAction('move')}>
|
||||||
<ScissorOutlined />
|
<ScissorOutlined />
|
||||||
<span>이동</span>
|
<span>이동</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
import { appendClientIdHeader } from './clientIdentity';
|
import { appendClientIdHeader } from './clientIdentity';
|
||||||
|
|
||||||
export type ChatDefaultContextRecord = {
|
export type ChatDefaultContextRecord = {
|
||||||
@@ -29,30 +29,12 @@ type ChatContextSettingsStore = {
|
|||||||
roomContexts: ChatRoomContextSettings[];
|
roomContexts: ChatRoomContextSettings[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHAT_CONTEXT_SETTINGS_STORAGE_KEY = 'work-app:chat-context-settings';
|
export type ChatContextSettingsStoreSource = 'server' | 'optimistic';
|
||||||
|
|
||||||
const CHAT_CONTEXT_SETTINGS_SYNC_EVENT = 'work-app:chat-context-settings-changed';
|
const CHAT_CONTEXT_SETTINGS_SYNC_EVENT = 'work-app:chat-context-settings-changed';
|
||||||
const CHAT_CONTEXT_SETTINGS_API_PATH = '/chat-context-settings';
|
const CHAT_CONTEXT_SETTINGS_API_PATH = '/chat-context-settings';
|
||||||
const CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
|
const CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
|
|
||||||
{
|
|
||||||
id: 'chat-default-mobile-verification',
|
|
||||||
title: '모바일 검증',
|
|
||||||
content:
|
|
||||||
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
|
|
||||||
enabled: true,
|
|
||||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chat-default-resource-output',
|
|
||||||
title: '리소스 출력',
|
|
||||||
content:
|
|
||||||
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
|
|
||||||
enabled: true,
|
|
||||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function normalizeText(value: string | null | undefined) {
|
function normalizeText(value: string | null | undefined) {
|
||||||
return value?.trim() ?? '';
|
return value?.trim() ?? '';
|
||||||
}
|
}
|
||||||
@@ -94,7 +76,7 @@ function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefin
|
|||||||
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
|
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
|
||||||
const byId = new Map<string, ChatDefaultContextRecord>();
|
const byId = new Map<string, ChatDefaultContextRecord>();
|
||||||
|
|
||||||
[...(items ?? []), ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
|
(items ?? [])
|
||||||
.map((item) => normalizeDefaultContext(item))
|
.map((item) => normalizeDefaultContext(item))
|
||||||
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
@@ -176,23 +158,7 @@ function sanitizeStore(input: Partial<ChatContextSettingsStore> | null | undefin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStore() {
|
const EMPTY_CHAT_CONTEXT_SETTINGS_STORE = sanitizeStore(null);
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return sanitizeStore(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return sanitizeStore(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitizeStore(JSON.parse(raw) as Partial<ChatContextSettingsStore>);
|
|
||||||
} catch {
|
|
||||||
return sanitizeStore(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitStoreChange() {
|
function emitStoreChange() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -202,17 +168,6 @@ function emitStoreChange() {
|
|||||||
window.dispatchEvent(new Event(CHAT_CONTEXT_SETTINGS_SYNC_EVENT));
|
window.dispatchEvent(new Event(CHAT_CONTEXT_SETTINGS_SYNC_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStore(store: ChatContextSettingsStore) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = sanitizeStore(store);
|
|
||||||
window.localStorage.setItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY, JSON.stringify(sanitized));
|
|
||||||
emitStoreChange();
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveChatContextSettingsApiBaseUrl() {
|
function resolveChatContextSettingsApiBaseUrl() {
|
||||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||||
@@ -247,19 +202,33 @@ const CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL = resolveChatContextSettingsFallba
|
|||||||
async function requestChatContextSettingsOnce<T>(baseUrl: string, init?: RequestInit) {
|
async function requestChatContextSettingsOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||||
const headers = appendClientIdHeader(init?.headers);
|
const headers = appendClientIdHeader(init?.headers);
|
||||||
const hasBody = init?.body !== undefined && init.body !== null;
|
const hasBody = init?.body !== undefined && init.body !== null;
|
||||||
|
const normalizedMethod = init?.method?.toUpperCase() ?? 'GET';
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// 기본 유형 설정은 origin별 분기 없이 전역 설정을 source of truth로 사용한다.
|
||||||
|
headers.delete('X-App-Origin');
|
||||||
|
headers.delete('X-App-Domain');
|
||||||
|
|
||||||
if (hasBody && !headers.has('Content-Type')) {
|
if (hasBody && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedMethod === 'GET') {
|
||||||
|
headers.set('Cache-Control', 'no-cache, no-store, max-age=0');
|
||||||
|
headers.set('Pragma', 'no-cache');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`, {
|
const requestUrl =
|
||||||
|
normalizedMethod === 'GET'
|
||||||
|
? `${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}?__ts=${Date.now()}`
|
||||||
|
: `${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`;
|
||||||
|
const response = await fetch(requestUrl, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
cache: init?.cache ?? (normalizedMethod === 'GET' ? 'reload' : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -315,6 +284,132 @@ async function saveStoreToServer(store: ChatContextSettingsStore) {
|
|||||||
return sanitizeStore(response.settings ?? sanitized);
|
return sanitizeStore(response.settings ?? sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatContextSettingsRegistrySnapshot = ChatContextSettingsStore & {
|
||||||
|
isLoading: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
lastLoadedAt: string | null;
|
||||||
|
lastFailedAt: string | null;
|
||||||
|
hasLoadedFromServer: boolean;
|
||||||
|
storeSource: ChatContextSettingsStoreSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT: ChatContextSettingsRegistrySnapshot = {
|
||||||
|
...EMPTY_CHAT_CONTEXT_SETTINGS_STORE,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
lastLoadedAt: null,
|
||||||
|
lastFailedAt: null,
|
||||||
|
hasLoadedFromServer: false,
|
||||||
|
storeSource: 'server',
|
||||||
|
};
|
||||||
|
|
||||||
|
let chatContextSettingsRegistrySnapshot = EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT;
|
||||||
|
let chatContextSettingsRegistryRequestSequence = 0;
|
||||||
|
let chatContextSettingsRegistryWindowEventsBound = false;
|
||||||
|
const chatContextSettingsRegistryListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emitChatContextSettingsRegistryChange() {
|
||||||
|
chatContextSettingsRegistryListeners.forEach((listener) => {
|
||||||
|
listener();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatContextSettingsRegistrySnapshot() {
|
||||||
|
return chatContextSettingsRegistrySnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChatContextSettingsRegistrySnapshot(
|
||||||
|
updater:
|
||||||
|
| ChatContextSettingsRegistrySnapshot
|
||||||
|
| ((current: ChatContextSettingsRegistrySnapshot) => ChatContextSettingsRegistrySnapshot),
|
||||||
|
) {
|
||||||
|
const nextSnapshot = typeof updater === 'function' ? updater(chatContextSettingsRegistrySnapshot) : updater;
|
||||||
|
|
||||||
|
chatContextSettingsRegistrySnapshot = nextSnapshot;
|
||||||
|
emitChatContextSettingsRegistryChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeChatContextSettingsRegistry(listener: () => void) {
|
||||||
|
chatContextSettingsRegistryListeners.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chatContextSettingsRegistryListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadChatContextSettingsRegistryFromServer() {
|
||||||
|
const requestSequence = chatContextSettingsRegistryRequestSequence + 1;
|
||||||
|
chatContextSettingsRegistryRequestSequence = requestSequence;
|
||||||
|
|
||||||
|
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||||
|
...current,
|
||||||
|
isLoading: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverStore = await fetchStoreFromServer();
|
||||||
|
const saved = sanitizeStore(serverStore);
|
||||||
|
|
||||||
|
if (chatContextSettingsRegistryRequestSequence === requestSequence) {
|
||||||
|
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||||
|
...current,
|
||||||
|
...saved,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
lastLoadedAt: new Date().toISOString(),
|
||||||
|
lastFailedAt: null,
|
||||||
|
hasLoadedFromServer: true,
|
||||||
|
storeSource: 'server',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
} catch (error) {
|
||||||
|
if (chatContextSettingsRegistryRequestSequence === requestSequence) {
|
||||||
|
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||||
|
...(current.lastLoadedAt ? current : EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT),
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.',
|
||||||
|
lastFailedAt: new Date().toISOString(),
|
||||||
|
hasLoadedFromServer: current.lastLoadedAt !== null,
|
||||||
|
storeSource: current.lastLoadedAt ? current.storeSource : 'server',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChatContextSettingsRegistryWindowEvents() {
|
||||||
|
if (chatContextSettingsRegistryWindowEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = () => {
|
||||||
|
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||||
|
};
|
||||||
|
const handleWindowFocus = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||||
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
|
window.addEventListener('pageshow', handleWindowFocus);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
chatContextSettingsRegistryWindowEventsBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveChatTypeDefaultContextIds(
|
export function resolveChatTypeDefaultContextIds(
|
||||||
selections: ChatTypeDefaultContextSelection[],
|
selections: ChatTypeDefaultContextSelection[],
|
||||||
chatTypeId: string | null | undefined,
|
chatTypeId: string | null | undefined,
|
||||||
@@ -452,70 +547,71 @@ export function pruneChatRoomContextSettings(roomContexts: ChatRoomContextSettin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useChatContextSettingsRegistry() {
|
export function useChatContextSettingsRegistry() {
|
||||||
const [store, setStoreState] = useState<ChatContextSettingsStore>(() => loadStore());
|
const snapshot = useSyncExternalStore(
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
subscribeChatContextSettingsRegistry,
|
||||||
const isMountedRef = useRef(true);
|
getChatContextSettingsRegistrySnapshot,
|
||||||
|
getChatContextSettingsRegistrySnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
ensureChatContextSettingsRegistryWindowEvents();
|
||||||
|
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||||
const syncStore = async () => {
|
|
||||||
try {
|
|
||||||
const serverStore = await fetchStoreFromServer();
|
|
||||||
const saved = saveStore(serverStore);
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setStoreState(saved);
|
|
||||||
setErrorMessage('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const localStore = loadStore();
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setStoreState(localStore);
|
|
||||||
setErrorMessage(error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void syncStore();
|
|
||||||
const handleSync = () => {
|
|
||||||
void syncStore();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
|
||||||
window.addEventListener('storage', syncStore);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
window.removeEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
|
||||||
window.removeEventListener('storage', syncStore);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...store,
|
...snapshot,
|
||||||
errorMessage,
|
reload: reloadChatContextSettingsRegistryFromServer,
|
||||||
setStore: (
|
setStore: (
|
||||||
updater:
|
updater:
|
||||||
| ChatContextSettingsStore
|
| ChatContextSettingsStore
|
||||||
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
|
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
|
||||||
) => {
|
) => {
|
||||||
const nextStore = typeof updater === 'function' ? updater(loadStore()) : updater;
|
const currentStore: ChatContextSettingsStore = {
|
||||||
const saved = saveStore(nextStore);
|
defaultContexts: snapshot.defaultContexts,
|
||||||
|
chatTypeDefaults: snapshot.chatTypeDefaults,
|
||||||
|
roomContexts: snapshot.roomContexts,
|
||||||
|
};
|
||||||
|
const nextStore = typeof updater === 'function' ? updater(currentStore) : updater;
|
||||||
|
const saved = sanitizeStore(nextStore);
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||||
setStoreState(saved);
|
...current,
|
||||||
setErrorMessage('');
|
...saved,
|
||||||
}
|
errorMessage: '',
|
||||||
|
lastFailedAt: null,
|
||||||
|
storeSource: 'optimistic',
|
||||||
|
}));
|
||||||
|
|
||||||
return saveStoreToServer(saved).then((serverSaved) => {
|
return saveStoreToServer(saved)
|
||||||
if (isMountedRef.current) {
|
.then((serverSaved) => {
|
||||||
setStoreState(serverSaved);
|
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||||
}
|
...current,
|
||||||
|
...serverSaved,
|
||||||
|
errorMessage: '',
|
||||||
|
lastLoadedAt: new Date().toISOString(),
|
||||||
|
lastFailedAt: null,
|
||||||
|
hasLoadedFromServer: true,
|
||||||
|
storeSource: 'server',
|
||||||
|
}));
|
||||||
|
|
||||||
return serverSaved;
|
emitStoreChange();
|
||||||
});
|
return serverSaved;
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||||
|
...current,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '채팅 Context 설정 저장에 실패했습니다.',
|
||||||
|
lastFailedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reloadChatContextSettingsRegistryFromServer();
|
||||||
|
} catch {
|
||||||
|
// 저장 실패 직후에도 서버 원본 재조회는 best-effort로 유지한다.
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export type ChatTypeRecord = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatTypeRegistrySnapshot = {
|
||||||
|
builtInChatTypes: ChatTypeRecord[];
|
||||||
|
customChatTypes: ChatTypeRecord[];
|
||||||
|
chatTypes: ChatTypeRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatTypeInput = {
|
export type ChatTypeInput = {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -114,6 +120,11 @@ function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null |
|
|||||||
return sanitizeChatTypes([...(chatTypes ?? []), ...DEFAULT_CHAT_TYPES]);
|
return sanitizeChatTypes([...(chatTypes ?? []), ...DEFAULT_CHAT_TYPES]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripBuiltInChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null | undefined) {
|
||||||
|
const builtInIds = new Set(DEFAULT_CHAT_TYPES.map((item) => item.id));
|
||||||
|
return sanitizeChatTypes(chatTypes ?? []).filter((item) => !builtInIds.has(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
function emitChatTypesChange() {
|
function emitChatTypesChange() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -200,26 +211,58 @@ async function requestChatTypes<T>(init?: RequestInit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChatTypesFromServer() {
|
async function fetchChatTypesFromServer() {
|
||||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
const response = await requestChatTypes<{
|
||||||
|
ok: boolean;
|
||||||
|
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||||
|
customChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||||
|
chatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||||
|
}>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.chatTypes == null) {
|
const builtInChatTypes =
|
||||||
return mergeWithDefaultChatTypes(DEFAULT_CHAT_TYPES);
|
response.builtInChatTypes != null ? sanitizeChatTypes(response.builtInChatTypes) : sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||||
}
|
const customChatTypes =
|
||||||
|
response.customChatTypes != null
|
||||||
|
? stripBuiltInChatTypes(response.customChatTypes)
|
||||||
|
: stripBuiltInChatTypes(response.chatTypes);
|
||||||
|
const chatTypes =
|
||||||
|
response.chatTypes != null ? mergeWithDefaultChatTypes(response.chatTypes) : mergeWithDefaultChatTypes(customChatTypes);
|
||||||
|
|
||||||
return mergeWithDefaultChatTypes(response.chatTypes);
|
return {
|
||||||
|
builtInChatTypes,
|
||||||
|
customChatTypes,
|
||||||
|
chatTypes,
|
||||||
|
} satisfies ChatTypeRegistrySnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
||||||
const resolved = mergeWithDefaultChatTypes(chatTypes);
|
const customChatTypes = stripBuiltInChatTypes(chatTypes);
|
||||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] }>({
|
const response = await requestChatTypes<{
|
||||||
|
ok: boolean;
|
||||||
|
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||||
|
customChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||||
|
chatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||||
|
}>({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ chatTypes: resolved }),
|
body: JSON.stringify({ customChatTypes }),
|
||||||
});
|
});
|
||||||
|
|
||||||
emitChatTypesChange();
|
emitChatTypesChange();
|
||||||
return mergeWithDefaultChatTypes(response.chatTypes);
|
const builtInChatTypes =
|
||||||
|
response.builtInChatTypes != null ? sanitizeChatTypes(response.builtInChatTypes) : sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||||
|
const nextCustomChatTypes =
|
||||||
|
response.customChatTypes != null
|
||||||
|
? stripBuiltInChatTypes(response.customChatTypes)
|
||||||
|
: stripBuiltInChatTypes(response.chatTypes);
|
||||||
|
const nextChatTypes =
|
||||||
|
response.chatTypes != null ? mergeWithDefaultChatTypes(response.chatTypes) : mergeWithDefaultChatTypes(nextCustomChatTypes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
builtInChatTypes,
|
||||||
|
customChatTypes: nextCustomChatTypes,
|
||||||
|
chatTypes: nextChatTypes,
|
||||||
|
} satisfies ChatTypeRegistrySnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
|
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
|
||||||
@@ -264,9 +307,27 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
|
|||||||
|
|
||||||
export function useChatTypeRegistry() {
|
export function useChatTypeRegistry() {
|
||||||
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
|
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
|
||||||
|
const [builtInChatTypes, setBuiltInChatTypesState] = useState<ChatTypeRecord[]>(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
|
||||||
|
const [customChatTypes, setCustomChatTypesState] = useState<ChatTypeRecord[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
const syncChatTypesRef = useRef<() => Promise<ChatTypeRegistrySnapshot>>(async () => ({
|
||||||
|
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||||
|
customChatTypes: [],
|
||||||
|
chatTypes: DEFAULT_CHAT_TYPES,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const applySnapshot = (snapshot: ChatTypeRegistrySnapshot) => {
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuiltInChatTypesState(snapshot.builtInChatTypes);
|
||||||
|
setCustomChatTypesState(snapshot.customChatTypes);
|
||||||
|
setChatTypesState(snapshot.chatTypes);
|
||||||
|
setErrorMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
@@ -274,26 +335,39 @@ export function useChatTypeRegistry() {
|
|||||||
const syncChatTypes = async () => {
|
const syncChatTypes = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
|
let resolvedChatTypeSnapshot: ChatTypeRegistrySnapshot = {
|
||||||
|
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||||
|
customChatTypes: [],
|
||||||
|
chatTypes: DEFAULT_CHAT_TYPES,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverChatTypes = await fetchChatTypesFromServer();
|
const serverChatTypeSnapshot = await fetchChatTypesFromServer();
|
||||||
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
|
resolvedChatTypeSnapshot = serverChatTypeSnapshot ?? resolvedChatTypeSnapshot;
|
||||||
|
applySnapshot(resolvedChatTypeSnapshot);
|
||||||
if (isMountedRef.current) {
|
|
||||||
setChatTypesState(resolvedChatTypes);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
|
setBuiltInChatTypesState(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
|
||||||
|
setCustomChatTypesState([]);
|
||||||
setChatTypesState(DEFAULT_CHAT_TYPES);
|
setChatTypesState(DEFAULT_CHAT_TYPES);
|
||||||
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
|
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
|
||||||
}
|
}
|
||||||
|
resolvedChatTypeSnapshot = {
|
||||||
|
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||||
|
customChatTypes: [],
|
||||||
|
chatTypes: DEFAULT_CHAT_TYPES,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolvedChatTypeSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
syncChatTypesRef.current = syncChatTypes;
|
||||||
|
|
||||||
void syncChatTypes();
|
void syncChatTypes();
|
||||||
|
|
||||||
const handleSync = () => {
|
const handleSync = () => {
|
||||||
@@ -310,14 +384,15 @@ export function useChatTypeRegistry() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
chatTypes,
|
chatTypes,
|
||||||
|
builtInChatTypes,
|
||||||
|
customChatTypes,
|
||||||
isLoading,
|
isLoading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
reload: async () => syncChatTypesRef.current(),
|
||||||
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
|
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
|
||||||
const resolved = await saveChatTypesToServer(nextChatTypes);
|
await saveChatTypesToServer(nextChatTypes);
|
||||||
if (isMountedRef.current) {
|
const resolved = await fetchChatTypesFromServer();
|
||||||
setChatTypesState(resolved);
|
applySnapshot(resolved);
|
||||||
setErrorMessage('');
|
|
||||||
}
|
|
||||||
return resolved;
|
return resolved;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ export type DefaultChatTypeRecord = {
|
|||||||
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
|
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
|
||||||
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
|
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
|
||||||
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION =
|
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION =
|
||||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
||||||
|
|
||||||
|
export const MD_CONTEXT_MANAGED_CHAT_TYPE_ID = 'md-context-managed';
|
||||||
|
export const MD_CONTEXT_MANAGED_CHAT_TYPE_NAME = 'MD 기준 관리';
|
||||||
|
export const MD_CONTEXT_MANAGED_CHAT_TYPE_DESCRIPTION =
|
||||||
|
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.';
|
||||||
|
|
||||||
|
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_ID = 'chat-maximized-bottom-safe';
|
||||||
|
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_NAME = '채팅 최대화 하단 안전영역';
|
||||||
|
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_DESCRIPTION =
|
||||||
|
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.';
|
||||||
|
|
||||||
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
|
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
|
||||||
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
|
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
|
||||||
@@ -39,7 +49,23 @@ export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
|||||||
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
|
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
|
||||||
permissions: ['token-user'],
|
permissions: ['token-user'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: MD_CONTEXT_MANAGED_CHAT_TYPE_ID,
|
||||||
|
name: MD_CONTEXT_MANAGED_CHAT_TYPE_NAME,
|
||||||
|
description: MD_CONTEXT_MANAGED_CHAT_TYPE_DESCRIPTION,
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_ID,
|
||||||
|
name: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_NAME,
|
||||||
|
description: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_DESCRIPTION,
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
|
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function ConversationRoomPane({
|
|||||||
showScrollToBottom={false}
|
showScrollToBottom={false}
|
||||||
copiedMessageId={null}
|
copiedMessageId={null}
|
||||||
draft=""
|
draft=""
|
||||||
|
draftVersion={0}
|
||||||
composerAttachments={[]}
|
composerAttachments={[]}
|
||||||
requestStateMap={requestStateMap}
|
requestStateMap={requestStateMap}
|
||||||
isConversationLoading={isLoading}
|
isConversationLoading={isLoading}
|
||||||
@@ -85,6 +86,7 @@ export function ConversationRoomPane({
|
|||||||
onCancelMessage={() => {}}
|
onCancelMessage={() => {}}
|
||||||
onDeleteRequest={() => {}}
|
onDeleteRequest={() => {}}
|
||||||
onRemoveQueuedRequest={() => {}}
|
onRemoveQueuedRequest={() => {}}
|
||||||
|
onSubmitPrompt={async () => false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
clearChatConversationRoom,
|
||||||
createChatConversationRoom,
|
createChatConversationRoom,
|
||||||
deleteChatConversationRequest,
|
deleteChatConversationRequest,
|
||||||
deleteChatConversationRoom,
|
deleteChatConversationRoom,
|
||||||
@@ -57,6 +58,7 @@ export type ChatGateway = {
|
|||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) => Promise<ChatConversationSummary>;
|
) => Promise<ChatConversationSummary>;
|
||||||
|
clearConversation: (sessionId: string) => Promise<ChatConversationSummary>;
|
||||||
deleteConversation: (sessionId: string) => Promise<void>;
|
deleteConversation: (sessionId: string) => Promise<void>;
|
||||||
deleteConversationRequest: (sessionId: string, requestId: string) => Promise<void>;
|
deleteConversationRequest: (sessionId: string, requestId: string) => Promise<void>;
|
||||||
markConversationRead: (sessionId: string) => Promise<void>;
|
markConversationRead: (sessionId: string) => Promise<void>;
|
||||||
@@ -73,6 +75,7 @@ export const chatGateway: ChatGateway = {
|
|||||||
createConversation: createChatConversationRoom,
|
createConversation: createChatConversationRoom,
|
||||||
renameConversation: renameChatConversationRoom,
|
renameConversation: renameChatConversationRoom,
|
||||||
updateConversation: updateChatConversationRoom,
|
updateConversation: updateChatConversationRoom,
|
||||||
|
clearConversation: clearChatConversationRoom,
|
||||||
deleteConversation: async (sessionId) => {
|
deleteConversation: async (sessionId) => {
|
||||||
await deleteChatConversationRoom(sessionId);
|
await deleteChatConversationRoom(sessionId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type UseConversationComposerControllerOptions = {
|
|||||||
maxContextMessages: number;
|
maxContextMessages: number;
|
||||||
maxContextChars: number;
|
maxContextChars: number;
|
||||||
};
|
};
|
||||||
draft: string;
|
getDraft: () => string;
|
||||||
composerAttachments: ChatComposerAttachment[];
|
composerAttachments: ChatComposerAttachment[];
|
||||||
isComposerAttachmentUploading: boolean;
|
isComposerAttachmentUploading: boolean;
|
||||||
selectedChatType: SelectedChatType;
|
selectedChatType: SelectedChatType;
|
||||||
@@ -74,7 +74,17 @@ type UseConversationComposerControllerOptions = {
|
|||||||
setShowScrollToBottom: (value: boolean) => void;
|
setShowScrollToBottom: (value: boolean) => void;
|
||||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
syncConversationPreviewForRequest: (
|
||||||
|
sessionId: string,
|
||||||
|
text: string,
|
||||||
|
requestedAt?: string,
|
||||||
|
options?: {
|
||||||
|
requestId?: string;
|
||||||
|
mode?: 'queue' | 'direct';
|
||||||
|
queueSize?: number;
|
||||||
|
jobMessage?: string | null;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||||
createLocalMessage: (text: string) => ChatMessage;
|
createLocalMessage: (text: string) => ChatMessage;
|
||||||
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
|
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
|
||||||
@@ -97,7 +107,7 @@ type SendMessageOptions = {
|
|||||||
export function useConversationComposerController({
|
export function useConversationComposerController({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
appConfigChat,
|
appConfigChat,
|
||||||
draft,
|
getDraft,
|
||||||
composerAttachments,
|
composerAttachments,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
selectedChatType,
|
selectedChatType,
|
||||||
@@ -268,7 +278,12 @@ export function useConversationComposerController({
|
|||||||
answeredAt: null,
|
answeredAt: null,
|
||||||
terminalAt: null,
|
terminalAt: null,
|
||||||
});
|
});
|
||||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt);
|
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
|
||||||
|
requestId,
|
||||||
|
mode: 'queue',
|
||||||
|
queueSize: 1,
|
||||||
|
jobMessage: '대기열 등록 중',
|
||||||
|
});
|
||||||
|
|
||||||
shouldStickToBottomRef.current = true;
|
shouldStickToBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
@@ -304,6 +319,12 @@ export function useConversationComposerController({
|
|||||||
answeredAt: null,
|
answeredAt: null,
|
||||||
terminalAt: null,
|
terminalAt: null,
|
||||||
});
|
});
|
||||||
|
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
|
||||||
|
requestId,
|
||||||
|
mode: 'direct',
|
||||||
|
queueSize: 0,
|
||||||
|
jobMessage: '즉시 요청 실행 대기 중',
|
||||||
|
});
|
||||||
|
|
||||||
shouldStickToBottomRef.current = true;
|
shouldStickToBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
@@ -374,7 +395,7 @@ export function useConversationComposerController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
|
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return;
|
return;
|
||||||
@@ -423,7 +444,7 @@ export function useConversationComposerController({
|
|||||||
buildOutgoingMessageText,
|
buildOutgoingMessageText,
|
||||||
composerAttachments,
|
composerAttachments,
|
||||||
createLocalMessage,
|
createLocalMessage,
|
||||||
draft,
|
getDraft,
|
||||||
executeSendMessage,
|
executeSendMessage,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
messagesRef,
|
messagesRef,
|
||||||
|
|||||||
@@ -48,6 +48,32 @@ function mergeConversationItemsPreservingRequestedSession(
|
|||||||
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
||||||
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||||
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
|
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
|
||||||
|
currentRequestId:
|
||||||
|
item.currentRequestId?.trim() ||
|
||||||
|
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
|
||||||
|
null,
|
||||||
|
currentJobStatus:
|
||||||
|
item.currentJobStatus ??
|
||||||
|
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||||
|
? previousItem.currentJobStatus
|
||||||
|
: null),
|
||||||
|
currentJobMessage:
|
||||||
|
item.currentJobMessage?.trim() ||
|
||||||
|
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
|
||||||
|
null,
|
||||||
|
currentQueueSize:
|
||||||
|
item.currentQueueSize > 0
|
||||||
|
? item.currentQueueSize
|
||||||
|
: item.currentJobStatus === 'queued'
|
||||||
|
? Math.max(1, previousItem.currentQueueSize)
|
||||||
|
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
|
||||||
|
? Math.max(1, previousItem.currentQueueSize)
|
||||||
|
: item.currentQueueSize,
|
||||||
|
currentStatusUpdatedAt:
|
||||||
|
item.currentStatusUpdatedAt ||
|
||||||
|
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||||
|
? previousItem.currentStatusUpdatedAt
|
||||||
|
: null),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const normalizedRequestedSessionId = requestedSessionId.trim();
|
const normalizedRequestedSessionId = requestedSessionId.trim();
|
||||||
|
|||||||
@@ -359,8 +359,51 @@ export function useConversationRoomActionsController({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClearConversation = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const item = await chatGateway.clearConversation(sessionId);
|
||||||
|
sessionMessageCacheRef.current.set(sessionId, []);
|
||||||
|
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === sessionId ? item : entry)));
|
||||||
|
|
||||||
|
if (sessionId === activeSessionId) {
|
||||||
|
chatConnectionGateway.resetLastReceivedEventId(sessionId);
|
||||||
|
setMessages([]);
|
||||||
|
setRequestItems([]);
|
||||||
|
setDraft('');
|
||||||
|
setComposerAttachments([]);
|
||||||
|
setCopiedMessageId(null);
|
||||||
|
setActivePreviewId(null);
|
||||||
|
setIsPreviewModalOpen(false);
|
||||||
|
setActiveSystemStatus('채팅방 데이터를 초기화했습니다.');
|
||||||
|
setIsSystemStatusPending(false);
|
||||||
|
setIsResourceStripOpen(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '채팅방 데이터 초기화 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeSessionId,
|
||||||
|
messageApi,
|
||||||
|
sessionMessageCacheRef,
|
||||||
|
setActivePreviewId,
|
||||||
|
setActiveSystemStatus,
|
||||||
|
setComposerAttachments,
|
||||||
|
setConversationItems,
|
||||||
|
setCopiedMessageId,
|
||||||
|
setDraft,
|
||||||
|
setIsPreviewModalOpen,
|
||||||
|
setIsResourceStripOpen,
|
||||||
|
setIsSystemStatusPending,
|
||||||
|
setMessages,
|
||||||
|
setRequestItems,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cancelPendingRequest,
|
cancelPendingRequest,
|
||||||
|
handleClearConversation,
|
||||||
deleteStoredRequest,
|
deleteStoredRequest,
|
||||||
handleDeleteConversation,
|
handleDeleteConversation,
|
||||||
handleRenameConversation,
|
handleRenameConversation,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
} from '../../mainChatPanel/types';
|
} from '../../mainChatPanel/types';
|
||||||
|
|
||||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||||
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
|
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
|
||||||
|
|
||||||
function mergeConversationRequests(
|
function mergeConversationRequests(
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ export function appendClientIdHeader(headersInit?: HeadersInit) {
|
|||||||
headers.set('X-Client-Id', clientId);
|
headers.set('X-Client-Id', clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const { origin, hostname } = window.location;
|
||||||
|
|
||||||
|
if (origin && !headers.has('X-App-Origin')) {
|
||||||
|
headers.set('X-App-Origin', origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname && !headers.has('X-App-Domain')) {
|
||||||
|
headers.set('X-App-Domain', hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export function MainLayout() {
|
|||||||
useGesturePageState('anyway');
|
useGesturePageState('anyway');
|
||||||
useGestureLayer({
|
useGestureLayer({
|
||||||
id: 'main-layout',
|
id: 'main-layout',
|
||||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
|
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
|
||||||
gestures: [
|
gestures: [
|
||||||
{
|
{
|
||||||
id: 'mobile-top-right-pull-alert',
|
id: 'mobile-top-right-pull-alert',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { docsMarkdownEntries } from '../../manifests/docs.manifest';
|
|||||||
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
|
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
|
||||||
import { DOCS_DEFAULT_FOLDER } from '../routes';
|
import { DOCS_DEFAULT_FOLDER } from '../routes';
|
||||||
|
|
||||||
const DOCS_FOLDER_ORDER = ['worklogs', 'features', 'components', 'templates'] as const;
|
const DOCS_FOLDER_ORDER = ['project'] as const;
|
||||||
|
|
||||||
export function useMainLayoutData() {
|
export function useMainLayoutData() {
|
||||||
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);
|
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);
|
||||||
|
|||||||
323
src/app/main/mainChatPanel/ChatActivityChecklist.tsx
Normal file
323
src/app/main/mainChatPanel/ChatActivityChecklist.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleFilled,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseCircleFilled,
|
||||||
|
LoadingOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { GENERAL_REQUEST_CHAT_TYPE_ID } from '../chatTypeDefaults';
|
||||||
|
import type { ChatConversationRequest } from './types';
|
||||||
|
|
||||||
|
type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error';
|
||||||
|
type ActivityChecklistStageKey = 'intake' | 'analysis' | 'inspection' | 'execution' | 'result';
|
||||||
|
|
||||||
|
type ActivityChecklistEntry = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
state: ActivityChecklistState;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECKLIST_STAGE_ORDER: ActivityChecklistStageKey[] = ['intake', 'analysis', 'inspection', 'execution', 'result'];
|
||||||
|
|
||||||
|
const CHECKLIST_STAGE_LABELS: Record<ActivityChecklistStageKey, string> = {
|
||||||
|
intake: '요청 접수',
|
||||||
|
analysis: '요청 분석',
|
||||||
|
inspection: '관련 확인',
|
||||||
|
execution: '구현·응답 작성',
|
||||||
|
result: '검증·결과 정리',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECKLIST_STAGE_PATTERNS: Record<ActivityChecklistStageKey, RegExp[]> = {
|
||||||
|
intake: [/요청을 접수/i, /대기열 등록/i, /즉시 실행 대기/i, /요청을 처리합니다/i],
|
||||||
|
analysis: [/요청 분석/i, /분석/i, /생각 중/i, /의도/i, /문맥/i],
|
||||||
|
inspection: [
|
||||||
|
/\bdb\b/i,
|
||||||
|
/데이터베이스/i,
|
||||||
|
/\bapi\b/i,
|
||||||
|
/엔드포인트/i,
|
||||||
|
/응답/i,
|
||||||
|
/소스/i,
|
||||||
|
/코드/i,
|
||||||
|
/파일/i,
|
||||||
|
/흐름/i,
|
||||||
|
/쿼리/i,
|
||||||
|
/집계/i,
|
||||||
|
/resource/i,
|
||||||
|
/리소스/i,
|
||||||
|
/화면/i,
|
||||||
|
],
|
||||||
|
execution: [/구현/i, /수정/i, /변경/i, /작성/i, /빌드/i, /patch/i, /diff/i, /실시간으로 전송 중/i],
|
||||||
|
result: [/검증/i, /테스트/i, /캡처/i, /preview/i, /스크린샷/i, /완료/i, /결과/i, /정리/i],
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripActivityPrefix(line: string) {
|
||||||
|
return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeActivitySummary(value: string) {
|
||||||
|
const candidates = stripActivityPrefix(value)
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(
|
||||||
|
(line) =>
|
||||||
|
!line.startsWith('$ ') &&
|
||||||
|
!line.startsWith('# 결과:') &&
|
||||||
|
!line.startsWith('# 출력:') &&
|
||||||
|
!line.startsWith('# command-runner') &&
|
||||||
|
!/^\[(stderr|stdout)\]/i.test(line),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = candidates[0] ?? '';
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.length > 160 ? `${summary.slice(0, 157).trimEnd()}...` : summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLines(lines: string[]) {
|
||||||
|
return lines.map((line) => String(line ?? '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastStageSummary(lines: string[], stageKey: ActivityChecklistStageKey) {
|
||||||
|
const patterns = CHECKLIST_STAGE_PATTERNS[stageKey];
|
||||||
|
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.some((pattern) => pattern.test(candidate))) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveObservationSummary(lines: string[]) {
|
||||||
|
const labels = new Set<string>();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const normalized = sanitizeActivitySummary(line);
|
||||||
|
|
||||||
|
if (/\bdb\b/i.test(normalized) || /데이터베이스|sql|쿼리|집계/i.test(normalized)) {
|
||||||
|
labels.add('DB');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\bapi\b/i.test(normalized) || /엔드포인트|fetch|호출|응답/i.test(normalized)) {
|
||||||
|
labels.add('API');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/소스|코드|파일|tsx|ts|js|css|흐름/i.test(normalized)) {
|
||||||
|
labels.add('소스');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/화면|리소스|preview|캡처|스크린샷/i.test(normalized)) {
|
||||||
|
labels.add('화면');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(labels).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrentStageKey(lines: string[], request?: ChatConversationRequest) {
|
||||||
|
if (request?.status === 'completed' || request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed') {
|
||||||
|
return 'result' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stageKey of ['result', 'execution', 'inspection', 'analysis', 'intake'] as const) {
|
||||||
|
if (CHECKLIST_STAGE_PATTERNS[stageKey].some((pattern) => pattern.test(candidate))) {
|
||||||
|
return stageKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request?.status === 'started') {
|
||||||
|
return 'analysis' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'intake' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResultNote(request?: ChatConversationRequest) {
|
||||||
|
const normalizedStatusMessage = String(request?.statusMessage ?? '').trim();
|
||||||
|
|
||||||
|
if (request?.status === 'completed') {
|
||||||
|
return normalizedStatusMessage || '응답과 결과 정리가 완료되었습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request?.status === 'failed') {
|
||||||
|
return normalizedStatusMessage || '오류로 종료되었습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request?.status === 'cancelled') {
|
||||||
|
return normalizedStatusMessage || '사용자 요청으로 중단되었습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request?.status === 'removed') {
|
||||||
|
return normalizedStatusMessage || '요청 기록이 제거되었습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedStatusMessage || '최종 결과를 정리하는 단계입니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChecklistEntries(lines: string[], request?: ChatConversationRequest) {
|
||||||
|
const currentStageKey = resolveCurrentStageKey(lines, request);
|
||||||
|
const currentStageIndex = CHECKLIST_STAGE_ORDER.indexOf(currentStageKey);
|
||||||
|
const isTerminalComplete = request?.status === 'completed';
|
||||||
|
const isTerminalError = request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed';
|
||||||
|
const observationSummary = resolveObservationSummary(lines);
|
||||||
|
|
||||||
|
return CHECKLIST_STAGE_ORDER.map<ActivityChecklistEntry>((stageKey, index) => {
|
||||||
|
const summary = getLastStageSummary(lines, stageKey);
|
||||||
|
let state: ActivityChecklistState = 'pending';
|
||||||
|
|
||||||
|
if (isTerminalComplete) {
|
||||||
|
state = 'complete';
|
||||||
|
} else if (isTerminalError && index === currentStageIndex) {
|
||||||
|
state = 'error';
|
||||||
|
} else if (index < currentStageIndex) {
|
||||||
|
state = 'complete';
|
||||||
|
} else if (index === currentStageIndex) {
|
||||||
|
state = 'current';
|
||||||
|
}
|
||||||
|
|
||||||
|
let note = summary;
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
switch (stageKey) {
|
||||||
|
case 'intake':
|
||||||
|
note = request?.status === 'queued' ? '대기열 접수 후 순차 실행을 기다립니다.' : '요청을 접수하고 실행 준비를 시작합니다.';
|
||||||
|
break;
|
||||||
|
case 'analysis':
|
||||||
|
note = '요청 의도와 현재 화면 문맥을 정리합니다.';
|
||||||
|
break;
|
||||||
|
case 'inspection':
|
||||||
|
note = observationSummary ? `${observationSummary} 기준으로 확인합니다.` : 'DB, API, 소스, 화면 중 필요한 대상을 확인합니다.';
|
||||||
|
break;
|
||||||
|
case 'execution':
|
||||||
|
note = request?.hasResponse ? '응답 초안 또는 변경 결과를 작성 중입니다.' : '필요한 구현과 응답 작성을 진행합니다.';
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
note = resolveResultNote(request);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (stageKey === 'inspection' && observationSummary && !note.includes('·')) {
|
||||||
|
note = `${note} (${observationSummary})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: stageKey,
|
||||||
|
label: CHECKLIST_STAGE_LABELS[stageKey],
|
||||||
|
state,
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStateIcon(state: ActivityChecklistState) {
|
||||||
|
if (state === 'complete') {
|
||||||
|
return <CheckCircleFilled aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'current') {
|
||||||
|
return <LoadingOutlined aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'error') {
|
||||||
|
return <CloseCircleFilled aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ClockCircleOutlined aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
|
||||||
|
const completedCount = entries.filter((entry) => entry.state === 'complete').length;
|
||||||
|
const currentEntry = entries.find((entry) => entry.state === 'current');
|
||||||
|
const errorEntry = entries.find((entry) => entry.state === 'error');
|
||||||
|
|
||||||
|
if (errorEntry) {
|
||||||
|
return `${errorEntry.label} 단계에서 확인 필요`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEntry) {
|
||||||
|
return `${completedCount}/${entries.length} 완료 · ${currentEntry.label} 진행 중`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedCount === entries.length) {
|
||||||
|
return '체크리스트 완료';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${completedCount}/${entries.length} 완료`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatActivityChecklist({
|
||||||
|
lines,
|
||||||
|
request,
|
||||||
|
chatTypeId,
|
||||||
|
}: {
|
||||||
|
lines: string[];
|
||||||
|
request?: ChatConversationRequest;
|
||||||
|
chatTypeId?: string | null;
|
||||||
|
}) {
|
||||||
|
if ((chatTypeId ?? '').trim() !== GENERAL_REQUEST_CHAT_TYPE_ID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLines = normalizeLines(lines);
|
||||||
|
const entries = buildChecklistEntries(normalizedLines, request);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="app-chat-activity-checklist" aria-label="Plan 체크리스트">
|
||||||
|
<div className="app-chat-activity-checklist__header">
|
||||||
|
<div className="app-chat-activity-checklist__title-group">
|
||||||
|
<span className="app-chat-activity-checklist__title">Plan 체크리스트</span>
|
||||||
|
<span className="app-chat-activity-checklist__summary">{buildSummaryLabel(entries)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="app-chat-activity-checklist__legend">
|
||||||
|
<MinusCircleOutlined aria-hidden="true" />
|
||||||
|
<span>실시간 반영</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ol className="app-chat-activity-checklist__list">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.key}
|
||||||
|
className={`app-chat-activity-checklist__item app-chat-activity-checklist__item--${entry.state}`}
|
||||||
|
aria-current={entry.state === 'current' ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span className="app-chat-activity-checklist__icon">{renderStateIcon(entry.state)}</span>
|
||||||
|
<div className="app-chat-activity-checklist__content">
|
||||||
|
<div className="app-chat-activity-checklist__row">
|
||||||
|
<span className="app-chat-activity-checklist__label">{entry.label}</span>
|
||||||
|
<span className="app-chat-activity-checklist__state">
|
||||||
|
{entry.state === 'complete'
|
||||||
|
? '완료'
|
||||||
|
: entry.state === 'current'
|
||||||
|
? '진행중'
|
||||||
|
: entry.state === 'error'
|
||||||
|
? '확인필요'
|
||||||
|
: '대기'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="app-chat-activity-checklist__note">{entry.note}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import {
|
import {
|
||||||
startTransition,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -45,7 +44,9 @@ import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
|||||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||||
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
|
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
|
||||||
|
import { ChatActivityChecklist } from './ChatActivityChecklist';
|
||||||
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
||||||
|
import { buildPromptResponseText, ChatPromptCard, type PromptDraftSelection } from './ChatPromptCard';
|
||||||
import { openChatExternalLink } from './linkNavigation';
|
import { openChatExternalLink } from './linkNavigation';
|
||||||
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
||||||
import { extractChatMessageParts } from './messageParts';
|
import { extractChatMessageParts } from './messageParts';
|
||||||
@@ -109,6 +110,11 @@ type PendingComposerUpload = {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingPromptSelection = PromptDraftSelection & {
|
||||||
|
promptTitle: string;
|
||||||
|
target: Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||||
|
};
|
||||||
|
|
||||||
type PreviewFetchError = Error & {
|
type PreviewFetchError = Error & {
|
||||||
status?: number;
|
status?: number;
|
||||||
};
|
};
|
||||||
@@ -128,6 +134,7 @@ type MessageRenderPayload = {
|
|||||||
diffBlocks: string[];
|
diffBlocks: string[];
|
||||||
rankedLinkTargets: RankedLinkPreviewTarget[];
|
rankedLinkTargets: RankedLinkPreviewTarget[];
|
||||||
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||||||
|
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
||||||
@@ -172,6 +179,11 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
|
|||||||
return 'file';
|
return 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHtmlPreviewUrl(url: string) {
|
||||||
|
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||||||
|
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||||
|
}
|
||||||
|
|
||||||
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -453,20 +465,23 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||||
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const targets: InlinePreviewTarget[] = [];
|
const targets: InlinePreviewTarget[] = [];
|
||||||
|
const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => {
|
||||||
for (const matchedUrl of matches) {
|
|
||||||
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
|
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
|
||||||
const kind = classifyInlinePreviewKind(normalizedUrl);
|
const kind = classifyInlinePreviewKind(normalizedUrl);
|
||||||
|
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
continue;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain HTML artifact paths should stay as text unless the reply explicitly opts into preview rendering.
|
||||||
|
if (!options?.allowHtml && isHtmlPreviewUrl(normalizedUrl)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seen.has(normalizedUrl)) {
|
if (seen.has(normalizedUrl)) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
seen.add(normalizedUrl);
|
seen.add(normalizedUrl);
|
||||||
@@ -475,7 +490,14 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
|||||||
label: buildInlinePreviewLabel(normalizedUrl),
|
label: buildInlinePreviewLabel(normalizedUrl),
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
extractAutoDetectedPreviewUrls(text).forEach((matchedUrl) => {
|
||||||
|
pushTarget(matchedUrl);
|
||||||
|
});
|
||||||
|
extractHiddenPreviewUrls(text).forEach((matchedUrl) => {
|
||||||
|
pushTarget(matchedUrl, { allowHtml: true });
|
||||||
|
});
|
||||||
|
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
@@ -558,6 +580,18 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
|
|||||||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||||
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
||||||
|
const promptTargets = [
|
||||||
|
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||||
|
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||||
|
].filter(
|
||||||
|
(part, index, collection) =>
|
||||||
|
collection.findIndex(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.title === part.title &&
|
||||||
|
candidate.options.map((option) => `${option.value}:${option.label}`).join(',') ===
|
||||||
|
part.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||||
|
) === index,
|
||||||
|
);
|
||||||
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
||||||
.map((match) => match[1]?.trim())
|
.map((match) => match[1]?.trim())
|
||||||
.filter((value): value is string => Boolean(value));
|
.filter((value): value is string => Boolean(value));
|
||||||
@@ -572,6 +606,7 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
|
|||||||
diffBlocks,
|
diffBlocks,
|
||||||
rankedLinkTargets,
|
rankedLinkTargets,
|
||||||
linkCardTargets,
|
linkCardTargets,
|
||||||
|
promptTargets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +1012,7 @@ type ChatConversationViewProps = {
|
|||||||
showScrollToBottom: boolean;
|
showScrollToBottom: boolean;
|
||||||
copiedMessageId: number | null;
|
copiedMessageId: number | null;
|
||||||
draft: string;
|
draft: string;
|
||||||
|
draftVersion: number;
|
||||||
composerAttachments: ChatComposerAttachment[];
|
composerAttachments: ChatComposerAttachment[];
|
||||||
requestStateMap: Map<string, ChatConversationRequest>;
|
requestStateMap: Map<string, ChatConversationRequest>;
|
||||||
isConversationLoading: boolean;
|
isConversationLoading: boolean;
|
||||||
@@ -1015,6 +1051,7 @@ type ChatConversationViewProps = {
|
|||||||
onCancelMessage: (message: ChatMessage) => void;
|
onCancelMessage: (message: ChatMessage) => void;
|
||||||
onDeleteRequest: (message: ChatMessage) => void;
|
onDeleteRequest: (message: ChatMessage) => void;
|
||||||
onRemoveQueuedRequest: (requestId: string) => void;
|
onRemoveQueuedRequest: (requestId: string) => void;
|
||||||
|
onSubmitPrompt: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChatConversationView({
|
export function ChatConversationView({
|
||||||
@@ -1026,6 +1063,7 @@ export function ChatConversationView({
|
|||||||
showScrollToBottom,
|
showScrollToBottom,
|
||||||
copiedMessageId,
|
copiedMessageId,
|
||||||
draft,
|
draft,
|
||||||
|
draftVersion,
|
||||||
composerAttachments,
|
composerAttachments,
|
||||||
requestStateMap,
|
requestStateMap,
|
||||||
isConversationLoading,
|
isConversationLoading,
|
||||||
@@ -1064,7 +1102,10 @@ export function ChatConversationView({
|
|||||||
onCancelMessage,
|
onCancelMessage,
|
||||||
onDeleteRequest,
|
onDeleteRequest,
|
||||||
onRemoveQueuedRequest,
|
onRemoveQueuedRequest,
|
||||||
|
onSubmitPrompt,
|
||||||
}: ChatConversationViewProps) {
|
}: ChatConversationViewProps) {
|
||||||
|
const [composerDraft, setComposerDraft] = useState(draft);
|
||||||
|
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingPromptSelection>>({});
|
||||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||||
@@ -1073,37 +1114,41 @@ export function ChatConversationView({
|
|||||||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||||||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||||||
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
||||||
const [composerDraft, setComposerDraft] = useState(draft);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
||||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||||
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
|
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
|
||||||
const lastReportedDraftRef = useRef(draft);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft === lastReportedDraftRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setComposerDraft(draft);
|
setComposerDraft(draft);
|
||||||
}, [draft]);
|
}, [draft, draftVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
const pendingPromptSelectionEntries = useMemo(
|
||||||
if (composerDraft === lastReportedDraftRef.current) {
|
() => Object.entries(pendingPromptSelections).sort(([left], [right]) => left.localeCompare(right)),
|
||||||
return;
|
[pendingPromptSelections],
|
||||||
|
);
|
||||||
|
const pendingPromptSelectionCount = pendingPromptSelectionEntries.length;
|
||||||
|
|
||||||
|
const buildComposerOutboundText = (draftText: string) => {
|
||||||
|
const trimmedDraftText = draftText.trim();
|
||||||
|
|
||||||
|
if (pendingPromptSelectionEntries.length === 0) {
|
||||||
|
return draftText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const promptTexts = pendingPromptSelectionEntries.map(([, selection], index) => {
|
||||||
lastReportedDraftRef.current = composerDraft;
|
const mergedFreeText = [selection.freeText.trim(), index === pendingPromptSelectionEntries.length - 1 ? trimmedDraftText : '']
|
||||||
startTransition(() => {
|
.filter(Boolean)
|
||||||
onDraftChange(composerDraft);
|
.join('\n\n');
|
||||||
});
|
|
||||||
}, 120);
|
|
||||||
|
|
||||||
return () => {
|
return buildPromptResponseText(selection.target, {
|
||||||
window.clearTimeout(timeoutId);
|
...selection,
|
||||||
};
|
freeText: mergedFreeText,
|
||||||
}, [composerDraft, onDraftChange]);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return promptTexts.join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
const orderedMessages = useMemo(() => {
|
const orderedMessages = useMemo(() => {
|
||||||
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
|
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
|
||||||
@@ -1178,6 +1223,39 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
return [...ordered, ...orphanActivityMessages];
|
return [...ordered, ...orphanActivityMessages];
|
||||||
}, [requestStateMap, visibleMessages]);
|
}, [requestStateMap, visibleMessages]);
|
||||||
|
const lastNonSystemMessageId = useMemo(() => {
|
||||||
|
for (let index = orderedMessages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = orderedMessages[index];
|
||||||
|
if (message.author !== 'system') {
|
||||||
|
return message.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [orderedMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activePromptKeys = new Set<string>();
|
||||||
|
|
||||||
|
orderedMessages.forEach((message) => {
|
||||||
|
const { promptTargets } = extractMessageRenderPayload(message);
|
||||||
|
|
||||||
|
promptTargets.forEach((target, index) => {
|
||||||
|
activePromptKeys.add(`${message.id}:${index}:${target.title}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingPromptSelections((current) => {
|
||||||
|
const nextEntries = Object.entries(current).filter(([key]) => activePromptKeys.has(key));
|
||||||
|
|
||||||
|
if (nextEntries.length === Object.keys(current).length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(nextEntries);
|
||||||
|
});
|
||||||
|
}, [orderedMessages]);
|
||||||
|
|
||||||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||||||
const isChatTypeReadonly = isChatTypeSelectionLocked;
|
const isChatTypeReadonly = isChatTypeSelectionLocked;
|
||||||
const visiblePreviewItems = useMemo(() => {
|
const visiblePreviewItems = useMemo(() => {
|
||||||
@@ -1546,8 +1624,12 @@ export function ChatConversationView({
|
|||||||
const composerPlaceholder = isComposerDisabled
|
const composerPlaceholder = isComposerDisabled
|
||||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||||
: isMobileViewport
|
: isMobileViewport
|
||||||
? '메시지를 입력하세요.'
|
? pendingPromptSelectionCount > 0
|
||||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다.'
|
||||||
|
: '메시지를 입력하세요.'
|
||||||
|
: pendingPromptSelectionCount > 0
|
||||||
|
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다. Ctrl+Enter로 바로 전송할 수 있습니다.'
|
||||||
|
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||||
|
|
||||||
const renderActivityCard = (message: ChatMessage) => {
|
const renderActivityCard = (message: ChatMessage) => {
|
||||||
const requestId = message.clientRequestId?.trim() || String(message.id);
|
const requestId = message.clientRequestId?.trim() || String(message.id);
|
||||||
@@ -1555,6 +1637,7 @@ export function ChatConversationView({
|
|||||||
const lines = extractActivityLines(message);
|
const lines = extractActivityLines(message);
|
||||||
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
|
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
|
||||||
const activityCountLabel = `${lines.length}개 로그`;
|
const activityCountLabel = `${lines.length}개 로그`;
|
||||||
|
const request = requestStateMap.get(requestId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
|
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
|
||||||
@@ -1601,9 +1684,12 @@ export function ChatConversationView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
|
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
|
||||||
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
<div className="app-chat-activity-card__summary-grid">
|
||||||
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
||||||
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
||||||
|
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
||||||
|
</div>
|
||||||
|
<ChatActivityChecklist lines={lines} request={request} chatTypeId={selectedChatTypeId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@@ -1748,7 +1834,8 @@ export function ChatConversationView({
|
|||||||
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
|
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
|
||||||
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
|
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
|
||||||
}`;
|
}`;
|
||||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
|
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, promptTargets } =
|
||||||
|
extractMessageRenderPayload(message);
|
||||||
const renderedText = isRecoveredMissingRequest
|
const renderedText = isRecoveredMissingRequest
|
||||||
? getMissingRequestMessageText(message)
|
? getMissingRequestMessageText(message)
|
||||||
: isRecoveredExecutionFailure
|
: isRecoveredExecutionFailure
|
||||||
@@ -1761,9 +1848,14 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||||
const hasPreviewCards =
|
const hasPreviewCards =
|
||||||
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
|
diffBlocks.length > 0 ||
|
||||||
|
inlinePreviewTargets.length > 0 ||
|
||||||
|
rankedLinkTargets.length > 0 ||
|
||||||
|
linkCardTargets.length > 0 ||
|
||||||
|
promptTargets.length > 0;
|
||||||
const shouldRenderStandalonePreview =
|
const shouldRenderStandalonePreview =
|
||||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||||
|
const isPromptReadOnly = message.id !== lastNonSystemMessageId;
|
||||||
const stackClassName = [
|
const stackClassName = [
|
||||||
`app-chat-message-stack app-chat-message-stack--${message.author}`,
|
`app-chat-message-stack app-chat-message-stack--${message.author}`,
|
||||||
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
|
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
|
||||||
@@ -1909,6 +2001,42 @@ export function ChatConversationView({
|
|||||||
{linkCardTargets.map((target) => (
|
{linkCardTargets.map((target) => (
|
||||||
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||||
))}
|
))}
|
||||||
|
{promptTargets.map((target, index) => (
|
||||||
|
(() => {
|
||||||
|
const selectionKey = `${message.id}:${index}:${target.title}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatPromptCard
|
||||||
|
key={`${message.id}-prompt-${index}-${target.title}`}
|
||||||
|
target={target}
|
||||||
|
onSubmit={onSubmitPrompt}
|
||||||
|
readOnly={isPromptReadOnly}
|
||||||
|
onSelectionChange={(selection) => {
|
||||||
|
setPendingPromptSelections((current) => {
|
||||||
|
if (!selection) {
|
||||||
|
if (!(selectionKey in current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[selectionKey];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[selectionKey]: {
|
||||||
|
...selection,
|
||||||
|
promptTitle: target.title,
|
||||||
|
target,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
))}
|
||||||
{rankedLinkTargets.map((target) => (
|
{rankedLinkTargets.map((target) => (
|
||||||
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||||
))}
|
))}
|
||||||
@@ -2064,9 +2192,7 @@ export function ChatConversationView({
|
|||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
aria-label="즉시 요청"
|
aria-label="즉시 요청"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
lastReportedDraftRef.current = composerDraft;
|
onSendImmediate(buildComposerOutboundText(composerDraft));
|
||||||
onDraftChange(composerDraft);
|
|
||||||
onSendImmediate(composerDraft);
|
|
||||||
}}
|
}}
|
||||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||||
/>
|
/>
|
||||||
@@ -2075,9 +2201,7 @@ export function ChatConversationView({
|
|||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
aria-label="큐로 보내기"
|
aria-label="큐로 보내기"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
lastReportedDraftRef.current = composerDraft;
|
onSend(buildComposerOutboundText(composerDraft));
|
||||||
onDraftChange(composerDraft);
|
|
||||||
onSend(composerDraft);
|
|
||||||
}}
|
}}
|
||||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||||
/>
|
/>
|
||||||
@@ -2096,6 +2220,27 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
{composerAttachmentStrip}
|
{composerAttachmentStrip}
|
||||||
|
|
||||||
|
{pendingPromptSelectionCount > 0 ? (
|
||||||
|
<div className="app-chat-panel__composer-prompt-strip" aria-live="polite">
|
||||||
|
{pendingPromptSelectionEntries.map(([selectionKey, selection]) => {
|
||||||
|
const selectionLabel = selection.target.options
|
||||||
|
.filter((option) => selection.selectedValues.includes(option.value))
|
||||||
|
.map((option) => option.label)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={selectionKey} className="app-chat-panel__composer-prompt-chip">
|
||||||
|
<span className="app-chat-panel__composer-prompt-chip-title">{selection.promptTitle}</span>
|
||||||
|
<span className="app-chat-panel__composer-prompt-chip-value">
|
||||||
|
{selection.summaryText || selectionLabel || selection.selectedValues.join(', ')}
|
||||||
|
</span>
|
||||||
|
<span className="app-chat-panel__composer-prompt-chip-meta">일반 전송에 포함됨</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`app-chat-panel__composer-input-shell${
|
className={`app-chat-panel__composer-input-shell${
|
||||||
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
||||||
@@ -2138,7 +2283,9 @@ export function ChatConversationView({
|
|||||||
placeholder={composerPlaceholder}
|
placeholder={composerPlaceholder}
|
||||||
disabled={isComposerDisabled}
|
disabled={isComposerDisabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setComposerDraft(event.target.value);
|
const nextValue = event.target.value;
|
||||||
|
setComposerDraft(nextValue);
|
||||||
|
onDraftChange(nextValue);
|
||||||
}}
|
}}
|
||||||
onPaste={handleComposerPaste}
|
onPaste={handleComposerPaste}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -2158,9 +2305,7 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
lastReportedDraftRef.current = event.currentTarget.value;
|
onSend(buildComposerOutboundText(composerDraft));
|
||||||
onDraftChange(event.currentTarget.value);
|
|
||||||
onSend(event.currentTarget.value);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
1160
src/app/main/mainChatPanel/ChatPromptCard.tsx
Normal file
1160
src/app/main/mainChatPanel/ChatPromptCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||||
const CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
|
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||||
|
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||||
|
|
||||||
function extractEmbeddedResourcePath(value: string) {
|
function extractEmbeddedResourcePath(value: string) {
|
||||||
const normalized = String(value ?? '').trim();
|
const normalized = String(value ?? '').trim();
|
||||||
@@ -11,13 +12,23 @@ function extractEmbeddedResourcePath(value: string) {
|
|||||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||||
|
|
||||||
if (apiMarkerIndex >= 0) {
|
if (apiMarkerIndex >= 0) {
|
||||||
return normalized.slice(apiMarkerIndex);
|
const apiPath = normalized.slice(apiMarkerIndex);
|
||||||
|
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||||
|
return dotCodexIndex >= 0
|
||||||
|
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||||
|
: apiPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
|
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||||
|
|
||||||
if (publicMarkerIndex >= 0) {
|
if (publicDotCodexIndex >= 0) {
|
||||||
return normalized.slice(publicMarkerIndex);
|
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||||
|
|
||||||
|
if (dotCodexIndex >= 0) {
|
||||||
|
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
|||||||
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
|
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
|
||||||
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||||
import { reportClientError } from '../errorLogApi';
|
import { reportClientError } from '../errorLogApi';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import type {
|
import type {
|
||||||
ChatActivityEvent,
|
ChatActivityEvent,
|
||||||
ChatConversationActivityLog,
|
ChatConversationActivityLog,
|
||||||
@@ -839,32 +840,7 @@ export async function diagnoseConnectionFailure(targetUrl: string, closeEvent?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function copyText(text: string) {
|
export async function copyText(text: string) {
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
return copyTextToClipboard(text);
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
throw new Error('클립보드를 사용할 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', '');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const copied = document.execCommand('copy');
|
|
||||||
|
|
||||||
if (!copied) {
|
|
||||||
throw new Error('복사 명령이 거부되었습니다.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PreviewCopyResult = 'text' | 'image' | 'url';
|
export type PreviewCopyResult = 'text' | 'image' | 'url';
|
||||||
@@ -1419,6 +1395,23 @@ export async function deleteChatConversationRoom(sessionId: string) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearChatConversationRoom(sessionId: string) {
|
||||||
|
const clientId = getOrCreateClientId();
|
||||||
|
const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>(
|
||||||
|
`/conversations/${encodeURIComponent(sessionId)}/clear`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
invalidateChatConversationListCache();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response.item,
|
||||||
|
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteChatConversationRequest(sessionId: string, requestId: string) {
|
export async function deleteChatConversationRequest(sessionId: string, requestId: string) {
|
||||||
const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>(
|
const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>(
|
||||||
`/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`,
|
`/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`,
|
||||||
@@ -1450,13 +1443,16 @@ function areChatMessagesEquivalent(left: ChatMessage[], right: ChatMessage[]) {
|
|||||||
|
|
||||||
return left.every((message, index) => {
|
return left.every((message, index) => {
|
||||||
const other = right[index];
|
const other = right[index];
|
||||||
|
const leftParts = JSON.stringify(message.parts ?? []);
|
||||||
|
const rightParts = JSON.stringify(other?.parts ?? []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
other &&
|
other &&
|
||||||
message.id === other.id &&
|
message.id === other.id &&
|
||||||
message.author === other.author &&
|
message.author === other.author &&
|
||||||
message.text === other.text &&
|
message.text === other.text &&
|
||||||
message.timestamp === other.timestamp
|
message.timestamp === other.timestamp &&
|
||||||
|
leftParts === rightParts
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1491,6 +1487,7 @@ export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage
|
|||||||
...existingMessage,
|
...existingMessage,
|
||||||
...incoming,
|
...incoming,
|
||||||
text: nextText,
|
text: nextText,
|
||||||
|
parts: incoming.parts ?? existingMessage.parts ?? [],
|
||||||
deliveryStatus: null,
|
deliveryStatus: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export {
|
|||||||
createIntroMessage,
|
createIntroMessage,
|
||||||
createLocalMessage,
|
createLocalMessage,
|
||||||
cancelChatRuntimeJob,
|
cancelChatRuntimeJob,
|
||||||
|
clearChatConversationRoom,
|
||||||
deleteChatConversationRequest,
|
deleteChatConversationRequest,
|
||||||
deleteChatConversationRoom,
|
deleteChatConversationRoom,
|
||||||
fetchChatConversationDetail,
|
fetchChatConversationDetail,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { ChatMessagePart } from './types';
|
import type { ChatMessagePart } from './types';
|
||||||
|
|
||||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||||
|
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
|
||||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||||
|
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||||
|
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||||
|
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||||
|
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||||
|
type PromptOption = PromptPart['options'][number];
|
||||||
|
type PromptPreview = NonNullable<PromptOption['preview']>;
|
||||||
|
type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||||
|
|
||||||
function normalizeText(value: unknown) {
|
function normalizeText(value: unknown) {
|
||||||
return String(value ?? '').trim();
|
return String(value ?? '').trim();
|
||||||
@@ -21,6 +29,25 @@ function normalizeUrl(value: string) {
|
|||||||
return `/${malformedResourceMatch[1]}`;
|
return `/${malformedResourceMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||||
|
if (apiMarkerIndex >= 0) {
|
||||||
|
const apiPath = normalized.slice(apiMarkerIndex);
|
||||||
|
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||||
|
return dotCodexIndex >= 0
|
||||||
|
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||||
|
: apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||||
|
if (publicDotCodexIndex >= 0) {
|
||||||
|
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||||
|
if (dotCodexIndex >= 0) {
|
||||||
|
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -28,6 +55,116 @@ function normalizeUrl(value: string) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePromptPreview(value: unknown): PromptPreview | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const type =
|
||||||
|
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
||||||
|
? record.type
|
||||||
|
: null;
|
||||||
|
const url = normalizeUrl(normalizeText(record.url));
|
||||||
|
const content = String(record.content ?? '').trim() || null;
|
||||||
|
const alt = normalizeText(record.alt) || null;
|
||||||
|
const title = normalizeText(record.title) || null;
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'image' || type === 'resource') {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (!content && !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
url: url || null,
|
||||||
|
content,
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromptOption(value: PromptOption | null): value is PromptOption {
|
||||||
|
return value != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptOption(value: unknown): PromptOption | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const optionValue = normalizeText(record.value);
|
||||||
|
const label = normalizeText(record.label);
|
||||||
|
|
||||||
|
if (!optionValue || !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: optionValue,
|
||||||
|
label,
|
||||||
|
description: normalizeText(record.description) || null,
|
||||||
|
preview: normalizePromptPreview(record.preview),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptSelectedValues(value: unknown) {
|
||||||
|
return [
|
||||||
|
...(Array.isArray(value) ? value : []),
|
||||||
|
]
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((item, index, array) => array.indexOf(item) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.flatMap((item, index) => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const key = normalizeText(record.key) || `step-${index + 1}`;
|
||||||
|
const title = normalizeText(record.title);
|
||||||
|
const options = Array.isArray(record.options)
|
||||||
|
? record.options.map((option) => normalizePromptOption(option)).filter(isPromptOption)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!title || options.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
description: normalizeText(record.description) || null,
|
||||||
|
submitLabel: normalizeText(record.submitLabel) || null,
|
||||||
|
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
|
||||||
|
multiple: record.multiple === true,
|
||||||
|
optional: record.optional === true,
|
||||||
|
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||||
|
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||||
|
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||||
|
selectedValues: normalizePromptSelectedValues(record.selectedValues),
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function decodeUrlComponentSafely(value: string) {
|
function decodeUrlComponentSafely(value: string) {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
@@ -135,6 +272,64 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const title = normalizeText(record.title);
|
||||||
|
const options = Array.isArray(record.options)
|
||||||
|
? record.options.map((item) => normalizePromptOption(item)).filter(isPromptOption)
|
||||||
|
: [];
|
||||||
|
const steps = normalizePromptSteps(record.steps);
|
||||||
|
|
||||||
|
if (!title || (options.length === 0 && steps.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
|
||||||
|
const selectedValues = [
|
||||||
|
...normalizePromptSelectedValues(record.selectedValues),
|
||||||
|
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||||
|
]
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((value, index, values) => values.indexOf(value) === index);
|
||||||
|
const resolvedBy =
|
||||||
|
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
|
||||||
|
? record.resolvedBy
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'prompt',
|
||||||
|
title,
|
||||||
|
description: normalizeText(record.description) || null,
|
||||||
|
submitLabel: normalizeText(record.submitLabel) || null,
|
||||||
|
mode,
|
||||||
|
multiple: record.multiple === true,
|
||||||
|
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||||
|
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||||
|
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||||
|
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||||
|
steps: steps.length > 0 ? steps : undefined,
|
||||||
|
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||||
|
selectedValues,
|
||||||
|
resolvedBy,
|
||||||
|
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||||
|
resultText: normalizeText(record.resultText) || null,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function extractChatMessageParts(text: string) {
|
export function extractChatMessageParts(text: string) {
|
||||||
const lines = String(text ?? '').split('\n');
|
const lines = String(text ?? '').split('\n');
|
||||||
const keptLines: string[] = [];
|
const keptLines: string[] = [];
|
||||||
@@ -145,7 +340,38 @@ export function extractChatMessageParts(text: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
const dedupeKey =
|
||||||
|
nextPart.type === 'link_card'
|
||||||
|
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
|
||||||
|
: [
|
||||||
|
nextPart.type,
|
||||||
|
nextPart.title,
|
||||||
|
nextPart.options
|
||||||
|
.map((option) =>
|
||||||
|
[
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
option.preview?.type ?? '',
|
||||||
|
option.preview?.url ?? '',
|
||||||
|
option.preview?.content ?? '',
|
||||||
|
option.preview?.title ?? '',
|
||||||
|
].join('|'),
|
||||||
|
)
|
||||||
|
.join(','),
|
||||||
|
(nextPart.steps ?? [])
|
||||||
|
.map((step) =>
|
||||||
|
[
|
||||||
|
step.key,
|
||||||
|
step.title,
|
||||||
|
step.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||||
|
].join('|'),
|
||||||
|
)
|
||||||
|
.join(','),
|
||||||
|
nextPart.selectedValues?.join(',') ?? '',
|
||||||
|
nextPart.resolvedBy ?? '',
|
||||||
|
nextPart.resultText ?? '',
|
||||||
|
nextPart.readOnly === true ? 'readonly' : '',
|
||||||
|
].join(':');
|
||||||
|
|
||||||
if (seenLinkKeys.has(dedupeKey)) {
|
if (seenLinkKeys.has(dedupeKey)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -157,6 +383,15 @@ export function extractChatMessageParts(text: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
const promptMatched = line.match(PROMPT_LINE_PATTERN);
|
||||||
|
|
||||||
|
if (promptMatched) {
|
||||||
|
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
|
||||||
|
keptLines.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
@@ -190,7 +425,7 @@ export function extractChatMessageParts(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestPart = parts.at(-1);
|
const latestPart = parts.at(-1);
|
||||||
if (latestPart && isInternalResourceUrl(latestPart.url)) {
|
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
|
||||||
parts.pop();
|
parts.pop();
|
||||||
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
||||||
keptLines.push(latestPart.url);
|
keptLines.push(latestPart.url);
|
||||||
|
|||||||
1019
src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css
Normal file
1019
src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css
Normal file
File diff suppressed because it is too large
Load Diff
1692
src/app/main/mainChatPanel/styles/MainChatPanel.layout.css
Normal file
1692
src/app/main/mainChatPanel/styles/MainChatPanel.layout.css
Normal file
File diff suppressed because it is too large
Load Diff
1980
src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css
Normal file
1980
src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,64 @@ export type ChatMessagePart =
|
|||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
actionLabel?: string | null;
|
actionLabel?: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'prompt';
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
submitLabel?: string | null;
|
||||||
|
mode?: 'queue' | 'direct' | null;
|
||||||
|
multiple?: boolean;
|
||||||
|
responseTemplate?: string | null;
|
||||||
|
freeTextLabel?: string | null;
|
||||||
|
freeTextPlaceholder?: string | null;
|
||||||
|
currentStepKey?: string | null;
|
||||||
|
steps?: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
submitLabel?: string | null;
|
||||||
|
mode?: 'queue' | 'direct' | null;
|
||||||
|
multiple?: boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
responseTemplate?: string | null;
|
||||||
|
freeTextLabel?: string | null;
|
||||||
|
freeTextPlaceholder?: string | null;
|
||||||
|
selectedValues?: string[];
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
preview?:
|
||||||
|
| {
|
||||||
|
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||||
|
url?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
alt?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
selectedValues?: string[];
|
||||||
|
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||||
|
resolvedAt?: string | null;
|
||||||
|
resultText?: string | null;
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
preview?:
|
||||||
|
| {
|
||||||
|
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||||
|
url?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
alt?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
@@ -58,6 +116,8 @@ export type ChatConversationSummary = {
|
|||||||
currentJobMessage: string | null;
|
currentJobMessage: string | null;
|
||||||
currentQueueSize: number;
|
currentQueueSize: number;
|
||||||
currentStatusUpdatedAt: string | null;
|
currentStatusUpdatedAt: string | null;
|
||||||
|
isPendingWork: boolean;
|
||||||
|
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
|
||||||
lastRequestPreview: string;
|
lastRequestPreview: string;
|
||||||
lastMessagePreview: string;
|
lastMessagePreview: string;
|
||||||
lastResponsePreview: string;
|
lastResponsePreview: string;
|
||||||
|
|||||||
@@ -10,13 +10,10 @@ export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
|||||||
error: '실패 (0)',
|
error: '실패 (0)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||||
|
|
||||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||||
worklogs: '작업일지',
|
project: '프로젝트 구조',
|
||||||
features: '기능문서',
|
|
||||||
components: '컴포넌트문서',
|
|
||||||
templates: '문서템플릿',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||||
|
|||||||
@@ -22,16 +22,13 @@ export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'mana
|
|||||||
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
|
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
|
||||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||||
|
|
||||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||||
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
|
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
|
||||||
export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
|
export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
|
||||||
export const PLAN_GROUP_LABEL = '작업';
|
export const PLAN_GROUP_LABEL = '작업';
|
||||||
|
|
||||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||||
worklogs: '작업일지',
|
project: '프로젝트 구조',
|
||||||
features: '기능문서',
|
|
||||||
components: '컴포넌트문서',
|
|
||||||
templates: '문서템플릿',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||||
|
|||||||
@@ -10,26 +10,48 @@ const featureMarkdownModules = import.meta.glob('../../features/**/*.md', {
|
|||||||
import: 'default',
|
import: 'default',
|
||||||
}) as Record<string, () => Promise<string>>;
|
}) as Record<string, () => Promise<string>>;
|
||||||
|
|
||||||
function createMarkdownEntries(
|
const DOCS_ENTRY_DEFINITIONS: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>> = [
|
||||||
|
{
|
||||||
|
path: '/docs/README.md',
|
||||||
|
folder: 'project',
|
||||||
|
title: '프로젝트 구조',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEATURE_ENTRY_PATTERNS = [/\/overview\.md$/i, /\/README\.md$/i];
|
||||||
|
|
||||||
|
function createExplicitMarkdownEntries(
|
||||||
modules: Record<string, () => Promise<string>>,
|
modules: Record<string, () => Promise<string>>,
|
||||||
|
entries: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>>,
|
||||||
): MarkdownDocumentEntry[] {
|
): MarkdownDocumentEntry[] {
|
||||||
const sortedPaths = Object.keys(modules).sort((left, right) => {
|
return entries
|
||||||
const isLeftWorklog = left.includes('/docs/worklogs/');
|
.filter((entry) => typeof modules[entry.path] === 'function')
|
||||||
const isRightWorklog = right.includes('/docs/worklogs/');
|
.map((entry, index) => ({
|
||||||
|
...entry,
|
||||||
if (isLeftWorklog && isRightWorklog) {
|
load: modules[entry.path],
|
||||||
return right.localeCompare(left);
|
order: index,
|
||||||
}
|
}));
|
||||||
|
|
||||||
return left.localeCompare(right);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortedPaths.map((path, index) => ({
|
|
||||||
path,
|
|
||||||
load: modules[path],
|
|
||||||
order: index,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const docsMarkdownEntries = createMarkdownEntries(docsMarkdownModules);
|
function createFilteredMarkdownEntries(
|
||||||
export const featureMarkdownEntries = createMarkdownEntries(featureMarkdownModules);
|
modules: Record<string, () => Promise<string>>,
|
||||||
|
includePatterns: RegExp[],
|
||||||
|
): MarkdownDocumentEntry[] {
|
||||||
|
return Object.keys(modules)
|
||||||
|
.filter((path) => includePatterns.some((pattern) => pattern.test(path)))
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((path, index) => ({
|
||||||
|
path,
|
||||||
|
load: modules[path],
|
||||||
|
order: index,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsMarkdownEntries = createExplicitMarkdownEntries(
|
||||||
|
docsMarkdownModules,
|
||||||
|
DOCS_ENTRY_DEFINITIONS,
|
||||||
|
);
|
||||||
|
export const featureMarkdownEntries = createFilteredMarkdownEntries(
|
||||||
|
featureMarkdownModules,
|
||||||
|
FEATURE_ENTRY_PATTERNS,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,61 +1,25 @@
|
|||||||
# Components Package Guide
|
# Components
|
||||||
|
|
||||||
`src/components`는 앱 전용 화면이 아니라 여러 화면과 샘플, 문서에서 공통 재사용할 UI 조각을 두는 패키지입니다. 컴포넌트 추가나 수정 시 이 문서를 기본 규약으로 사용합니다.
|
`src/components`는 여러 화면에서 재사용하는 공통 UI 패키지입니다.
|
||||||
|
|
||||||
## 목적
|
## 구조
|
||||||
|
|
||||||
- 화면 조합에 재사용되는 공통 UI를 보관합니다.
|
|
||||||
- 라이브러리 export 대상과 앱 내부 재사용 대상을 같은 폴더 기준으로 관리합니다.
|
|
||||||
- 컴포넌트 문서(`docs/components`)와 샘플(`samples`)의 기준 소스 역할을 합니다.
|
|
||||||
|
|
||||||
## 공통 설계 원칙
|
|
||||||
|
|
||||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
|
|
||||||
- 컴포넌트 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
|
|
||||||
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
|
||||||
- 공통 컴포넌트는 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
|
|
||||||
|
|
||||||
## 현재 하위 구조
|
|
||||||
|
|
||||||
- `common`: 범용 보조 컴포넌트
|
|
||||||
- `dashboard`: 진행률, 다중 progress 등 대시보드 계열 공통 UI
|
|
||||||
- `dataListTable`, `dataStatePanel`, `embeddedMap`, `emptyIllustrationCard`, `evidenceAttachmentStrip`, `formField`, `markdownPreview`, `navigation`, `previewer`, `processFlow`, `queryFilterBuilder`, `search`, `stateKit`, `status-badge`, `stepper`, `timelinePanel`, `window`: 독립 재사용 가능한 컴포넌트 패키지
|
|
||||||
- `inputs`: 입력 계열 공통 UI
|
|
||||||
- `primitives`: 가장 작은 입력 단위
|
|
||||||
- `specialized`: 목적이 뚜렷한 파생 입력
|
|
||||||
- `composite`: 여러 입력을 묶은 조합형 UI
|
|
||||||
- `select`, `checkCombo`, `popup`: plugin 확장과 샘플이 포함된 입력 패키지
|
|
||||||
|
|
||||||
## 폴더 구성 규약
|
|
||||||
|
|
||||||
컴포넌트 패키지는 가능하면 아래 구조를 따릅니다.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
component-name/
|
src/components
|
||||||
├─ ComponentName.tsx
|
├─ common
|
||||||
├─ ComponentName.css
|
├─ inputs
|
||||||
├─ index.ts
|
├─ markdownPreview
|
||||||
├─ types/ # 외부 노출 타입 또는 내부 분리 타입
|
├─ navigation
|
||||||
├─ plugins/ # plugin factory 또는 preset
|
├─ previewer
|
||||||
└─ samples/ # Docs/APIs 화면에서 쓰는 예제
|
└─ ...
|
||||||
```
|
```
|
||||||
|
|
||||||
- 진입점은 항상 해당 폴더의 `index.ts`로 둡니다.
|
- `common`: 범용 보조 UI
|
||||||
- 외부에서 직접 import 해야 하는 타입은 `index.ts` 또는 `types/index.ts`를 통해 다시 export 합니다.
|
- `inputs`: 입력 계열 컴포넌트
|
||||||
- CSS가 필요하면 컴포넌트 폴더 내부에 함께 둡니다.
|
- 그 외 폴더: 독립 재사용 컴포넌트 패키지
|
||||||
- 복잡한 로직이 생기면 `types`, `plugins`, `samples`처럼 역할별 하위 폴더로 분리합니다.
|
|
||||||
|
|
||||||
## 구현 규약
|
## 기준
|
||||||
|
|
||||||
- 공통 패키지에는 프로젝트 화면에 종속된 상태나 라우팅 의존을 넣지 않습니다.
|
- 화면 전용 상태와 비즈니스 로직은 넣지 않습니다.
|
||||||
- 컴포넌트 이름, 파일명, export 이름은 PascalCase를 유지합니다. 폴더명은 기존 저장소 스타일대로 kebab-case 또는 lowerCamelCase를 따릅니다.
|
- 외부 진입점은 각 폴더의 `index.ts`를 사용합니다.
|
||||||
- 라이브러리로 공개할 컴포넌트는 최종적으로 `src/index.ts`에서 다시 export 되어야 합니다.
|
- 복잡도가 커지면 `types`, `plugins`, `samples`로 분리합니다.
|
||||||
- 샘플이 필요한 컴포넌트는 `samples/Sample.tsx`를 기본 진입 예제로 두고, 변형 예제는 같은 폴더에 추가합니다.
|
|
||||||
- plugin 확장형 컴포넌트는 `plugins/*.plugin.ts` 또는 `plugins/index.ts`에서 생성 함수를 모읍니다.
|
|
||||||
- 공통 타입은 컴포넌트 폴더 안에서 우선 관리하고, 여러 컴포넌트가 공유할 때만 상위 공통 타입으로 승격합니다.
|
|
||||||
|
|
||||||
## 문서 규약
|
|
||||||
|
|
||||||
- 화면 사용법과 제약은 `docs/components/*.md`에 문서화합니다.
|
|
||||||
- 새 컴포넌트를 추가하면 최소한 목적, 주요 props, 샘플 위치, plugin 여부를 문서에 남깁니다.
|
|
||||||
- 패키지 구조나 규약이 바뀌면 이 문서와 해당 컴포넌트 문서를 함께 갱신합니다.
|
|
||||||
|
|||||||
157
src/components/chatPromptCard/samples/Sample.tsx
Normal file
157
src/components/chatPromptCard/samples/Sample.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { Flex, Typography } from 'antd';
|
||||||
|
import type { SampleMeta } from '../../../widgets/core';
|
||||||
|
import { ChatPromptCard } from '../../../app/main/mainChatPanel/ChatPromptCard';
|
||||||
|
|
||||||
|
const { Paragraph } = Typography;
|
||||||
|
|
||||||
|
export const sampleMeta: SampleMeta = {
|
||||||
|
id: 'chat-prompt-card',
|
||||||
|
componentId: 'chat-prompt-card',
|
||||||
|
title: 'Chat Prompt Card',
|
||||||
|
description: '채팅방 안에서 선택형 시안과 시간초과 자동선택 결과를 읽기 전용으로 보여주는 prompt 카드입니다.',
|
||||||
|
category: 'Chat',
|
||||||
|
kind: 'feature',
|
||||||
|
variantLabel: 'Prompt',
|
||||||
|
order: 95,
|
||||||
|
features: ['docs'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sample() {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={16}>
|
||||||
|
<Paragraph>
|
||||||
|
첫 카드는 사용자가 직접 선택할 수 있는 상태이고, 두 번째 카드는 시간 초과 뒤 자동 선택된 결과를 읽기 전용으로 보여줍니다.
|
||||||
|
</Paragraph>
|
||||||
|
<ChatPromptCard
|
||||||
|
target={{
|
||||||
|
type: 'prompt',
|
||||||
|
title: 'UI 수정 흐름 선택',
|
||||||
|
description: '단계형 prompt를 통해 시안과 후속 작업 범위를 순서대로 정합니다.',
|
||||||
|
submitLabel: '흐름 전달',
|
||||||
|
mode: 'queue',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '기본안',
|
||||||
|
value: 'default',
|
||||||
|
description: 'steps가 없을 때를 위한 fallback 옵션',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'layout',
|
||||||
|
title: '시안 선택',
|
||||||
|
description: '아래 시안 중 하나를 골라 기본 레이아웃을 정합니다.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'A안',
|
||||||
|
value: 'option-a',
|
||||||
|
description: '상단 헤더 강조와 큰 썸네일 중심 레이아웃',
|
||||||
|
preview: {
|
||||||
|
type: 'image',
|
||||||
|
url: 'https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&w=900&q=80',
|
||||||
|
alt: '대시보드 와이어프레임 샘플',
|
||||||
|
title: 'A안 시안',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'B안',
|
||||||
|
value: 'option-b',
|
||||||
|
description: '중간 요약 카드와 탭 전환 중심 레이아웃',
|
||||||
|
preview: {
|
||||||
|
type: 'markdown',
|
||||||
|
title: 'B안 요약',
|
||||||
|
content: '## B안 핵심\n- 상단에 상태 요약 카드\n- 중간에 탭 3개\n- 하단 액션은 최소화',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'C안',
|
||||||
|
value: 'option-c',
|
||||||
|
description: '하단 고정 액션과 짧은 설명 중심 레이아웃',
|
||||||
|
preview: {
|
||||||
|
type: 'html',
|
||||||
|
title: 'C안 레이아웃',
|
||||||
|
content:
|
||||||
|
'<section style="font-family:system-ui;padding:16px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#fff;border-radius:16px"><h3 style="margin:0 0 8px">C안</h3><p style="margin:0 0 12px">하단 고정 액션과 짧은 설명 중심</p><div style="display:grid;gap:8px"><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div></div></section>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseTemplate: '{{selection_label}} 시안을 기본 레이아웃으로 채택했습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scope',
|
||||||
|
title: '후속 작업 범위',
|
||||||
|
description: '선택 시안 기준으로 어떤 후속 작업을 이어갈지 고릅니다.',
|
||||||
|
multiple: true,
|
||||||
|
optional: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '모바일 여백 정리',
|
||||||
|
value: 'mobile-spacing',
|
||||||
|
description: '모바일 화면 여백과 버튼 배치를 먼저 다듬습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '상태 요약 추가',
|
||||||
|
value: 'summary-card',
|
||||||
|
description: '상단 요약 카드와 상태 문구를 함께 추가합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '미리보기 문서 생성',
|
||||||
|
value: 'preview-doc',
|
||||||
|
description: '세션 리소스에 HTML/Markdown 시안을 같이 생성합니다.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
freeTextLabel: '세부 요청',
|
||||||
|
freeTextPlaceholder: '예: 첫 단계는 시안만 정하고 구현은 다음 응답에서 이어가세요.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseTemplate: '사용자가 다음 단계형 흐름을 선택했습니다.\n{{step_summaries}}\n{{custom_text_block}}',
|
||||||
|
}}
|
||||||
|
onSubmit={async () => true}
|
||||||
|
/>
|
||||||
|
<ChatPromptCard
|
||||||
|
target={{
|
||||||
|
type: 'prompt',
|
||||||
|
title: '작업 결과안 자동 선택',
|
||||||
|
description: '응답 시간이 지나 시스템이 기본안을 선택했습니다.',
|
||||||
|
readOnly: true,
|
||||||
|
selectedValues: ['result-b'],
|
||||||
|
resolvedBy: 'timeout',
|
||||||
|
resultText: 'B안이 기본 시안으로 채택되었고, 다음 응답부터 이 흐름을 기준으로 이어갑니다.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '결과안 A',
|
||||||
|
value: 'result-a',
|
||||||
|
description: '카드형 설명을 크게 보여주는 결과안',
|
||||||
|
preview: {
|
||||||
|
type: 'image',
|
||||||
|
url: 'https://images.unsplash.com/photo-1558655146-9f40138edfeb?auto=format&fit=crop&w=900&q=80',
|
||||||
|
alt: '결과안 A 샘플',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '결과안 B',
|
||||||
|
value: 'result-b',
|
||||||
|
description: '선택 요약과 다음 액션을 한 줄로 정리한 결과안',
|
||||||
|
preview: {
|
||||||
|
type: 'markdown',
|
||||||
|
content: '### 결과안 B\n선택 요약과 다음 액션을 한 줄로 정리합니다.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '결과안 C',
|
||||||
|
value: 'result-c',
|
||||||
|
description: '추가 제안 링크를 함께 노출하는 결과안',
|
||||||
|
preview: {
|
||||||
|
type: 'resource',
|
||||||
|
url: '/docs/index.md',
|
||||||
|
title: '문서 리소스 예시',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
onSubmit={async () => false}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
EvidenceAttachmentPreviewBodyProps,
|
EvidenceAttachmentPreviewBodyProps,
|
||||||
EvidenceAttachmentStripProps,
|
EvidenceAttachmentStripProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import './EvidenceAttachmentStrip.css';
|
import './EvidenceAttachmentStrip.css';
|
||||||
|
|
||||||
const { Paragraph, Text } = Typography;
|
const { Paragraph, Text } = Typography;
|
||||||
@@ -108,25 +109,7 @@ function getAttachmentTypeIcon(kind: EvidenceAttachmentKind): ReactNode {
|
|||||||
|
|
||||||
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
|
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
|
||||||
const copyValue = attachment.copyValue ?? attachment.value;
|
const copyValue = attachment.copyValue ?? attachment.value;
|
||||||
|
return copyTextToClipboard(copyValue);
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(copyValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
throw new Error('clipboard-unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = copyValue;
|
|
||||||
textarea.setAttribute('readonly', 'true');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
|
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd';
|
import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
CODEX_DIFF_STATUS_LABEL_MAP,
|
CODEX_DIFF_STATUS_LABEL_MAP,
|
||||||
CodexDiffBlock,
|
CodexDiffBlock,
|
||||||
@@ -100,30 +101,9 @@ export function CodexDiffPreviewer({
|
|||||||
const canShowDiff = Boolean(diffText);
|
const canShowDiff = Boolean(diffText);
|
||||||
const resolvedMode = mode === 'auto' ? activeMode : mode;
|
const resolvedMode = mode === 'auto' ? activeMode : mode;
|
||||||
|
|
||||||
async function copyText(text: string) {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', 'true');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopy(content: string) {
|
async function handleCopy(content: string) {
|
||||||
try {
|
try {
|
||||||
await copyText(content);
|
await copyTextToClipboard(content);
|
||||||
messageApi.success('복사했습니다.');
|
messageApi.success('복사했습니다.');
|
||||||
} catch {
|
} catch {
|
||||||
messageApi.error('복사에 실패했습니다.');
|
messageApi.error('복사에 실패했습니다.');
|
||||||
|
|||||||
@@ -169,17 +169,22 @@
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(22, 93, 255, 0.08);
|
background: rgba(22, 93, 255, 0.08);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewer-ui__markdown pre code {
|
.previewer-ui__markdown pre code {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewer-ui__markdown pre {
|
.previewer-ui__markdown pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
max-width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
color: #dbe7ff;
|
color: #dbe7ff;
|
||||||
background: linear-gradient(180deg, #0f172a 0%, #111f39 100%);
|
background: linear-gradient(180deg, #0f172a 0%, #111f39 100%);
|
||||||
@@ -187,6 +192,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.previewer-ui__editor {
|
.previewer-ui__editor {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -235,6 +244,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.previewer-ui__editor-body {
|
.previewer-ui__editor-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button, Empty, Input, Select, message } from 'antd';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { InlineImage } from '../common/InlineImage';
|
import { InlineImage } from '../common/InlineImage';
|
||||||
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import { CodexDiffBlock } from './CodexDiffBlock';
|
import { CodexDiffBlock } from './CodexDiffBlock';
|
||||||
import type { PreviewerUIProps } from './types';
|
import type { PreviewerUIProps } from './types';
|
||||||
import { inferCodeLanguage, renderEditorBlock } from './renderers';
|
import { inferCodeLanguage, renderEditorBlock } from './renderers';
|
||||||
@@ -121,27 +122,6 @@ function renderMarkdown(markdown: string) {
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(text: string) {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', 'true');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
throw new Error('다운로드를 사용할 수 없습니다.');
|
throw new Error('다운로드를 사용할 수 없습니다.');
|
||||||
@@ -340,7 +320,7 @@ export function PreviewerUI({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyText(resolvedCopyValue);
|
await copyTextToClipboard(resolvedCopyValue);
|
||||||
messageApi.success('복사했습니다.');
|
messageApi.success('복사했습니다.');
|
||||||
} catch {
|
} catch {
|
||||||
messageApi.error('복사에 실패했습니다.');
|
messageApi.error('복사에 실패했습니다.');
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
useAutomationTypeRegistry,
|
useAutomationTypeRegistry,
|
||||||
} from '../../app/main/automationTypeAccess';
|
} from '../../app/main/automationTypeAccess';
|
||||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||||
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
createBoardPost,
|
createBoardPost,
|
||||||
deleteBoardPost,
|
deleteBoardPost,
|
||||||
@@ -123,23 +124,6 @@ function resolveBoardAttachmentSessionId(
|
|||||||
return draftAttachmentSessionIdRef.current;
|
return draftAttachmentSessionIdRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(value: string) {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = value;
|
|
||||||
textArea.setAttribute('readonly', '');
|
|
||||||
textArea.style.position = 'fixed';
|
|
||||||
textArea.style.left = '-9999px';
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasBoardPostAutomation(item: BoardPost | null | undefined) {
|
function hasBoardPostAutomation(item: BoardPost | null | undefined) {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return false;
|
return false;
|
||||||
@@ -562,7 +546,7 @@ export function BoardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyText(draft.content);
|
await copyTextToClipboard(draft.content);
|
||||||
messageApi.success('공통 메모를 복사했습니다.');
|
messageApi.success('공통 메모를 복사했습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
messageApi.error(error instanceof Error ? error.message : '공통 메모 복사에 실패했습니다.');
|
messageApi.error(error instanceof Error ? error.message : '공통 메모 복사에 실패했습니다.');
|
||||||
|
|||||||
@@ -1,50 +1,16 @@
|
|||||||
# Layout Feature
|
# Layout Feature
|
||||||
|
|
||||||
프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리합니다.
|
`src/features/layout`은 현재 프로젝트 전용 레이아웃 기능을 둡니다.
|
||||||
|
|
||||||
## 포함 항목
|
## 포함 범위
|
||||||
|
|
||||||
- 컴포넌트 샘플 레이아웃
|
- 컴포넌트 샘플 레이아웃
|
||||||
- 위젯 샘플 레이아웃
|
- 위젯 샘플 레이아웃
|
||||||
- Markdown preview 리스트 레이아웃
|
- 문서 미리보기 레이아웃
|
||||||
- `Layout Editor`와 저장 레이아웃 흐름
|
- `Layout Editor`
|
||||||
|
|
||||||
## 규칙
|
## 기준
|
||||||
|
|
||||||
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
|
- 현재 프로젝트 화면에만 의미가 있으면 여기 둡니다.
|
||||||
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
|
- 공통 재사용 가치가 높아지면 `src/components` 또는 `src/widgets`로 승격합니다.
|
||||||
|
- `Layout Editor`의 기능 명세는 위젯 스펙 문서가 아니라 현재 레이아웃 안에서의 역할 설명으로 취급합니다.
|
||||||
## Layout Editor 기준
|
|
||||||
|
|
||||||
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
|
|
||||||
|
|
||||||
용어 기준:
|
|
||||||
|
|
||||||
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
|
|
||||||
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
|
|
||||||
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
|
|
||||||
|
|
||||||
허용 범위:
|
|
||||||
|
|
||||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
|
||||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
|
||||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
|
||||||
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
|
|
||||||
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
|
|
||||||
|
|
||||||
구현 우선순위:
|
|
||||||
|
|
||||||
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
|
|
||||||
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
|
|
||||||
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
|
|
||||||
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
|
|
||||||
|
|
||||||
금지 해석:
|
|
||||||
|
|
||||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
|
||||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
|
||||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
|
||||||
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
|
|
||||||
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
|
|
||||||
|
|
||||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Features Overview
|
# Features Overview
|
||||||
|
|
||||||
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
|
`src/features`는 프로젝트 전용 기능 영역입니다.
|
||||||
|
|
||||||
## 목적
|
## 구조 기준
|
||||||
|
|
||||||
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
|
- 공통 UI로 분리하기 어려운 화면 로직은 `src/features`에 둡니다.
|
||||||
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
|
- 재사용 가능한 UI는 `src/components`, 카드형 조합은 `src/widgets`로 분리합니다.
|
||||||
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장
|
- 레이아웃 전용 기능은 `src/features/layout`에서 관리합니다.
|
||||||
|
|||||||
@@ -1160,7 +1160,7 @@ export function PlanBoardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error instanceof Error ? error.message : '작업 목록을 불러오지 못했습니다.');
|
setErrorMessage(error instanceof Error ? error.message : '자동화 목록을 불러오지 못했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1703,60 +1703,66 @@ export function PlanBoardPage({
|
|||||||
? currentReleaseUsageSummaryByHistoryId.get(selectedSourceWork.id) ?? null
|
? currentReleaseUsageSummaryByHistoryId.get(selectedSourceWork.id) ?? null
|
||||||
: null;
|
: null;
|
||||||
const memoRows = screens.md ? 18 : 9;
|
const memoRows = screens.md ? 18 : 9;
|
||||||
|
const isMobileAutomationLayout = !screens.md;
|
||||||
|
const overviewActionContent = (
|
||||||
|
<Space wrap>
|
||||||
|
<Space size={8} className="plan-board-page__auto-refresh-control">
|
||||||
|
<LongPressButton
|
||||||
|
onClick={() => void loadItems(statusFilter)}
|
||||||
|
onLongPress={() => {
|
||||||
|
if (!hasAccess) {
|
||||||
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAutoRefreshToggle();
|
||||||
|
}}
|
||||||
|
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
||||||
|
loading={loading}
|
||||||
|
title="길게 눌러 자동조회 On/Off"
|
||||||
|
className={`plan-board-page__auto-refresh-button${
|
||||||
|
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</LongPressButton>
|
||||||
|
{isAutoRefreshRunning ? (
|
||||||
|
<Text className="plan-board-page__auto-refresh-countdown">
|
||||||
|
자동 조회까지 {autoRefreshCountdownSeconds}초
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
{listRequestMeta ? (
|
||||||
|
<Text type="secondary">
|
||||||
|
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
||||||
|
새 메모
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
||||||
|
테이블 생성
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="plan-board-page">
|
<div className="plan-board-page">
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
|
||||||
<Card className="plan-board-page__overview" bordered={false}>
|
{isMobileAutomationLayout ? null : (
|
||||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
<Card className="plan-board-page__overview" bordered={false}>
|
||||||
<div>
|
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||||
<Title level={4}>자동화</Title>
|
<div>
|
||||||
<Paragraph className="plan-board-page__intro">
|
<Title level={4}>자동화</Title>
|
||||||
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
<Paragraph className="plan-board-page__intro">
|
||||||
</Paragraph>
|
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
||||||
</div>
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Space wrap>
|
{overviewActionContent}
|
||||||
<Space size={8} className="plan-board-page__auto-refresh-control">
|
</Flex>
|
||||||
<LongPressButton
|
</Card>
|
||||||
onClick={() => void loadItems(statusFilter)}
|
)}
|
||||||
onLongPress={() => {
|
|
||||||
if (!hasAccess) {
|
|
||||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAutoRefreshToggle();
|
|
||||||
}}
|
|
||||||
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
|
||||||
loading={loading}
|
|
||||||
title="길게 눌러 자동조회 On/Off"
|
|
||||||
className={`plan-board-page__auto-refresh-button${
|
|
||||||
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
조회
|
|
||||||
</LongPressButton>
|
|
||||||
{isAutoRefreshRunning ? (
|
|
||||||
<Text className="plan-board-page__auto-refresh-countdown">
|
|
||||||
자동 조회까지 {autoRefreshCountdownSeconds}초
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
{listRequestMeta ? (
|
|
||||||
<Text type="secondary">
|
|
||||||
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
|
||||||
새 메모
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
|
||||||
테이블 생성
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{isRestrictedClient ? (
|
{isRestrictedClient ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -1788,7 +1794,7 @@ export function PlanBoardPage({
|
|||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
className="plan-board-page__alert"
|
className="plan-board-page__alert"
|
||||||
message="작업 요청 메뉴를 아직 사용할 수 없습니다."
|
message="자동화 현황 메뉴를 아직 사용할 수 없습니다."
|
||||||
description={<ExpandableDetailText text={errorMessage} />}
|
description={<ExpandableDetailText text={errorMessage} />}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
@@ -1804,107 +1810,128 @@ export function PlanBoardPage({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PlanListDetailLayout
|
<PlanListDetailLayout
|
||||||
listTitle={`작업 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
listTitle={`자동화 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
||||||
listExtra={<Text code>{filteredItems.length} items</Text>}
|
listExtra={<Text code>{filteredItems.length} items</Text>}
|
||||||
listContent={
|
listContent={
|
||||||
<>
|
<div className="plan-board-page__list-panel">
|
||||||
{quickFilter ? (
|
<div className="plan-board-page__list-controls">
|
||||||
<Alert
|
{isMobileAutomationLayout ? (
|
||||||
showIcon
|
<div className="plan-board-page__mobile-overview">
|
||||||
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
<Flex vertical gap={10}>
|
||||||
className="plan-board-page__alert"
|
<div>
|
||||||
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
<Text strong className="plan-board-page__mobile-overview-title">
|
||||||
description={
|
자동화 목록
|
||||||
quickFilter === 'automation-failed'
|
</Text>
|
||||||
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
<Paragraph className="plan-board-page__mobile-overview-description">
|
||||||
: quickFilter === 'working'
|
작업 메모와 이력, 증적을 한 화면 흐름에서 바로 확인합니다.
|
||||||
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
</Paragraph>
|
||||||
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
</div>
|
||||||
}
|
{overviewActionContent}
|
||||||
/>
|
</Flex>
|
||||||
) : null}
|
</div>
|
||||||
{selectedItem?.lastError ? (
|
) : null}
|
||||||
<Alert
|
{quickFilter ? (
|
||||||
showIcon
|
<Alert
|
||||||
type="error"
|
showIcon
|
||||||
className="plan-board-page__alert"
|
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
||||||
message="현재 선택된 작업에 오류가 있습니다."
|
className="plan-board-page__alert"
|
||||||
description={<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />}
|
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
||||||
action={
|
description={
|
||||||
<Button
|
quickFilter === 'automation-failed'
|
||||||
type="text"
|
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
||||||
size="small"
|
: quickFilter === 'working'
|
||||||
aria-label="오류 메시지 복사"
|
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
||||||
icon={<CopyOutlined />}
|
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
||||||
disabled={!hasAccess}
|
}
|
||||||
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
/>
|
||||||
/>
|
) : null}
|
||||||
}
|
{selectedItem?.lastError ? (
|
||||||
/>
|
<Alert
|
||||||
) : null}
|
showIcon
|
||||||
|
type="error"
|
||||||
|
className="plan-board-page__alert"
|
||||||
|
message="현재 선택된 자동화 항목에 오류가 있습니다."
|
||||||
|
description={
|
||||||
|
<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
aria-label="오류 메시지 복사"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
||||||
disabled={isRestrictedClient}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearchKeyword(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
value={workerStateFilter}
|
|
||||||
options={WORKER_STATE_FILTER_OPTIONS}
|
|
||||||
disabled={isRestrictedClient}
|
disabled={isRestrictedClient}
|
||||||
onChange={setWorkerStateFilter}
|
onChange={(event) => {
|
||||||
|
setSearchKeyword(event.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
||||||
size="small"
|
<Select
|
||||||
value={releaseStateFilter}
|
size="small"
|
||||||
options={RELEASE_STATE_FILTER_OPTIONS}
|
value={workerStateFilter}
|
||||||
disabled={isRestrictedClient}
|
options={WORKER_STATE_FILTER_OPTIONS}
|
||||||
onChange={setReleaseStateFilter}
|
disabled={isRestrictedClient}
|
||||||
/>
|
onChange={setWorkerStateFilter}
|
||||||
<Select
|
/>
|
||||||
size="small"
|
<Select
|
||||||
value={mainStateFilter}
|
size="small"
|
||||||
options={MAIN_STATE_FILTER_OPTIONS}
|
value={releaseStateFilter}
|
||||||
disabled={isRestrictedClient}
|
options={RELEASE_STATE_FILTER_OPTIONS}
|
||||||
onChange={setMainStateFilter}
|
disabled={isRestrictedClient}
|
||||||
/>
|
onChange={setReleaseStateFilter}
|
||||||
<Select
|
/>
|
||||||
size="small"
|
<Select
|
||||||
value={issueStateFilter}
|
size="small"
|
||||||
options={ISSUE_STATE_FILTER_OPTIONS}
|
value={mainStateFilter}
|
||||||
disabled={isRestrictedClient}
|
options={MAIN_STATE_FILTER_OPTIONS}
|
||||||
onChange={setIssueStateFilter}
|
disabled={isRestrictedClient}
|
||||||
/>
|
onChange={setMainStateFilter}
|
||||||
<Select
|
/>
|
||||||
size="small"
|
<Select
|
||||||
value={costStateFilter}
|
size="small"
|
||||||
options={COST_STATE_FILTER_OPTIONS}
|
value={issueStateFilter}
|
||||||
disabled={isRestrictedClient}
|
options={ISSUE_STATE_FILTER_OPTIONS}
|
||||||
onChange={setCostStateFilter}
|
disabled={isRestrictedClient}
|
||||||
/>
|
onChange={setIssueStateFilter}
|
||||||
</Flex>
|
/>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={costStateFilter}
|
||||||
|
options={COST_STATE_FILTER_OPTIONS}
|
||||||
|
disabled={isRestrictedClient}
|
||||||
|
onChange={setCostStateFilter}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PlanItemList
|
<div className="plan-board-page__list-scroller">
|
||||||
activeDraftId={draft.id}
|
<PlanItemList
|
||||||
currentPage={currentListPage}
|
activeDraftId={draft.id}
|
||||||
editorOpen={editorOpen}
|
currentPage={currentListPage}
|
||||||
hasAccess={hasAccess}
|
editorOpen={editorOpen}
|
||||||
items={filteredItems}
|
hasAccess={hasAccess}
|
||||||
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
items={filteredItems}
|
||||||
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
||||||
searchKeyword={searchKeyword}
|
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
||||||
usageSummaryByPlanId={usageSummaryByPlanId}
|
searchKeyword={searchKeyword}
|
||||||
onChangePage={setCurrentListPage}
|
usageSummaryByPlanId={usageSummaryByPlanId}
|
||||||
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
onChangePage={setCurrentListPage}
|
||||||
onSelectItem={handleSelectItem}
|
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
||||||
/>
|
onSelectItem={handleSelectItem}
|
||||||
</>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
desktopDetailOpen={editorOpen}
|
desktopDetailOpen={editorOpen}
|
||||||
mobileDetailOpen={editorOpen}
|
mobileDetailOpen={editorOpen}
|
||||||
@@ -1934,7 +1961,7 @@ export function PlanBoardPage({
|
|||||||
emptyDetailTitle="상세 보기"
|
emptyDetailTitle="상세 보기"
|
||||||
detailContent={
|
detailContent={
|
||||||
!sourceViewerOpen ? (
|
!sourceViewerOpen ? (
|
||||||
<>
|
<div className="plan-board-page__detail-panel">
|
||||||
{selectedItem ? (
|
{selectedItem ? (
|
||||||
<Alert
|
<Alert
|
||||||
type={selectedItem.hasOpenIssues ? 'warning' : 'info'}
|
type={selectedItem.hasOpenIssues ? 'warning' : 'info'}
|
||||||
@@ -2488,7 +2515,7 @@ export function PlanBoardPage({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="plan-board-page__overlay-body">
|
<div className="plan-board-page__overlay-body">
|
||||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function PlanListDetailLayout({
|
|||||||
const showMobileDetail = mobileOverlayEnabled && mobileDetailOpen;
|
const showMobileDetail = mobileOverlayEnabled && mobileDetailOpen;
|
||||||
const showMobileOverlay = showMobileDetail && mobileLayoutMode === 'overlay';
|
const showMobileOverlay = showMobileDetail && mobileLayoutMode === 'overlay';
|
||||||
const showMobileDetailOnly = showMobileDetail && mobileLayoutMode === 'detail-only';
|
const showMobileDetailOnly = showMobileDetail && mobileLayoutMode === 'detail-only';
|
||||||
|
const hideInlineDetailCardOnMobile = mobileOverlayEnabled && (!showMobileDetail || showMobileOverlay);
|
||||||
|
|
||||||
useBodyScrollLock(showMobileOverlay || showMobileDetailOnly);
|
useBodyScrollLock(showMobileOverlay || showMobileDetailOnly);
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export function PlanListDetailLayout({
|
|||||||
showMobileDetailOnly ? ` ${classNamePrefix}__list-card--mobile-hidden` : ''
|
showMobileDetailOnly ? ` ${classNamePrefix}__list-card--mobile-hidden` : ''
|
||||||
}`;
|
}`;
|
||||||
const detailCardClassName = `${classNamePrefix}__editor-card ${classNamePrefix}__detail-card${
|
const detailCardClassName = `${classNamePrefix}__editor-card ${classNamePrefix}__detail-card${
|
||||||
showMobileOverlay ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
hideInlineDetailCardOnMobile ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
||||||
}${showMobileDetailOnly ? ` ${classNamePrefix}__detail-card--mobile-only` : ''}`;
|
}${showMobileDetailOnly ? ` ${classNamePrefix}__detail-card--mobile-only` : ''}`;
|
||||||
const detailActionsClassName = `${classNamePrefix}__detail-actions`;
|
const detailActionsClassName = `${classNamePrefix}__detail-actions`;
|
||||||
const detailEmptyClassName = `${classNamePrefix}__detail-empty`;
|
const detailEmptyClassName = `${classNamePrefix}__detail-empty`;
|
||||||
|
|||||||
@@ -136,6 +136,25 @@
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-board-page__mobile-overview {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(22, 93, 255, 0.06) 0%, rgba(22, 93, 255, 0.02) 100%),
|
||||||
|
#ffffff;
|
||||||
|
border: 1px solid rgba(22, 93, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__mobile-overview-title.ant-typography {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__mobile-overview-description.ant-typography {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-board-page__list {
|
.plan-board-page__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -148,6 +167,40 @@
|
|||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-panel,
|
||||||
|
.plan-board-page__detail-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-controls {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-scroller {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__detail-panel {
|
||||||
|
gap: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-board-page__list-filter-bar {
|
.plan-board-page__list-filter-bar {
|
||||||
margin: 12px 0 16px;
|
margin: 12px 0 16px;
|
||||||
}
|
}
|
||||||
@@ -592,6 +645,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
.plan-board-page {
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-board-page__split--mobile-detail-only {
|
.plan-board-page__split--mobile-detail-only {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
@@ -600,6 +659,65 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-card.ant-card {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-card .ant-card-body,
|
||||||
|
.plan-board-page__editor-card .ant-card-body,
|
||||||
|
.plan-board-page__detail-card .ant-card-body {
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-controls {
|
||||||
|
position: static;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__mobile-overview {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__mobile-overview .ant-space {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__mobile-overview .ant-space-item {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-filter-bar {
|
||||||
|
margin: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow: visible;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-filter-bar .ant-select {
|
||||||
|
min-width: 136px;
|
||||||
|
flex: 1 1 136px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list,
|
||||||
|
.plan-board-page__list-scroller {
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-scroller {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-board-page__detail-panel {
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-board-page__list-card--mobile-hidden,
|
.plan-board-page__list-card--mobile-hidden,
|
||||||
.plan-board-page__detail-card--mobile-hidden {
|
.plan-board-page__detail-card--mobile-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -911,6 +1029,10 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-card .ant-card-head {
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-board-page__form > div {
|
.plan-board-page__form > div {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -941,6 +1063,10 @@
|
|||||||
padding: 14px 14px max(18px, env(safe-area-inset-bottom, 0px));
|
padding: 14px 14px max(18px, env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-board-page__list-filter-bar .ant-select {
|
||||||
|
min-width: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-board-page__readonly-field {
|
.plan-board-page__readonly-field {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,16 +4,31 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||||
import { copyText } from '../../app/main/mainChatPanel';
|
import { copyText } from '../../app/main/mainChatPanel';
|
||||||
import { fetchServerCommands, restartServerCommand } from './api';
|
import {
|
||||||
import type { ServerCommandItem, ServerCommandKey } from './types';
|
ServerCommandApiError,
|
||||||
|
fetchServerCommands,
|
||||||
|
fetchServerRestartReservation,
|
||||||
|
restartServerCommand,
|
||||||
|
scheduleServerRestartReservation,
|
||||||
|
} from './api';
|
||||||
|
import type {
|
||||||
|
RestartReservationWorkloadSummary,
|
||||||
|
ServerCommandItem,
|
||||||
|
ServerCommandKey,
|
||||||
|
ServerRestartReservation,
|
||||||
|
ServerRestartReservationAutoFix,
|
||||||
|
ServerRestartReservationWorkItem,
|
||||||
|
} from './types';
|
||||||
import './serverCommand.css';
|
import './serverCommand.css';
|
||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
type RestartErrorInfo = {
|
type RestartErrorInfo = {
|
||||||
|
tone: 'error' | 'warning';
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
missingScriptPath: string | null;
|
missingScriptPath: string | null;
|
||||||
|
canScheduleReservation: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LastActionInfo = {
|
type LastActionInfo = {
|
||||||
@@ -83,28 +98,120 @@ function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErro
|
|||||||
const missingScriptPath = missingScriptMatch[1].trim();
|
const missingScriptPath = missingScriptMatch[1].trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
tone: 'error',
|
||||||
title: `${targetLabel} 재기동 실패`,
|
title: `${targetLabel} 재기동 실패`,
|
||||||
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
||||||
missingScriptPath,
|
missingScriptPath,
|
||||||
|
canScheduleReservation: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
tone: 'error',
|
||||||
title: `${targetLabel} 재기동 실패`,
|
title: `${targetLabel} 재기동 실패`,
|
||||||
detail,
|
detail,
|
||||||
missingScriptPath: null,
|
missingScriptPath: null,
|
||||||
|
canScheduleReservation: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWorkloadSummary(summary: RestartReservationWorkloadSummary | null) {
|
||||||
|
if (!summary) {
|
||||||
|
return '진행 중 작업이 있어 즉시 재기동할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Codex 실행 ${summary.codexRunningCount}건, Codex 대기 ${summary.codexQueuedCount}건, 자동화 실행 ${summary.automationRunningCount}건, 자동화 대기 ${summary.automationQueuedCount}건이 감지되었습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRestartReservationInfo(targetLabel: string, summary: RestartReservationWorkloadSummary | null, detail: string) {
|
||||||
|
return {
|
||||||
|
tone: 'warning' as const,
|
||||||
|
title: `${targetLabel} 즉시 재기동 보류`,
|
||||||
|
detail: `${detail}\n\n${formatWorkloadSummary(summary)}\n현재 화면에서는 전체 재기동 예약으로 이어서 처리할 수 있습니다.`,
|
||||||
|
missingScriptPath: null,
|
||||||
|
canScheduleReservation: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReservationStatusTag(reservation: ServerRestartReservation) {
|
||||||
|
switch (reservation.status) {
|
||||||
|
case 'waiting':
|
||||||
|
return <Tag color="gold">대기 중</Tag>;
|
||||||
|
case 'ready':
|
||||||
|
return <Tag color="blue">자동 실행 예정</Tag>;
|
||||||
|
case 'executing':
|
||||||
|
return <Tag color="processing">재기동 실행 중</Tag>;
|
||||||
|
case 'recovering':
|
||||||
|
return <Tag color="purple">Codex 자동 개선 중</Tag>;
|
||||||
|
case 'completed':
|
||||||
|
return <Tag color="success">완료</Tag>;
|
||||||
|
case 'failed':
|
||||||
|
return <Tag color="error">실패</Tag>;
|
||||||
|
case 'cancelled':
|
||||||
|
return <Tag>취소됨</Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag>대기 없음</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
|
||||||
|
if (item.kind === 'automation') {
|
||||||
|
if (item.status === 'running') {
|
||||||
|
return <Tag color="processing">자동화 실행</Tag>;
|
||||||
|
}
|
||||||
|
if (item.status === 'queued') {
|
||||||
|
return <Tag color="blue">자동화 대기열</Tag>;
|
||||||
|
}
|
||||||
|
return <Tag color="gold">자동화 선행대기</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'running') {
|
||||||
|
return <Tag color="processing">Codex 실행</Tag>;
|
||||||
|
}
|
||||||
|
if (item.status === 'queued') {
|
||||||
|
return <Tag color="blue">Codex 대기열</Tag>;
|
||||||
|
}
|
||||||
|
return <Tag color="gold">Codex 대기</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAutoFixTone(autoFix: ServerRestartReservationAutoFix) {
|
||||||
|
if (autoFix.status === 'failed') {
|
||||||
|
return 'error' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoFix.status === 'completed') {
|
||||||
|
return 'success' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'info' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['status']) {
|
||||||
|
switch (status) {
|
||||||
|
case 'queued':
|
||||||
|
return '요청 대기';
|
||||||
|
case 'running':
|
||||||
|
return '개선 실행 중';
|
||||||
|
case 'completed':
|
||||||
|
return '개선 완료';
|
||||||
|
case 'failed':
|
||||||
|
return '개선 실패';
|
||||||
|
default:
|
||||||
|
return '대기 없음';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ServerCommandPage() {
|
export function ServerCommandPage() {
|
||||||
const { hasAccess } = useTokenAccess();
|
const { hasAccess } = useTokenAccess();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [reservation, setReservation] = useState<ServerRestartReservation | null>(null);
|
||||||
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
||||||
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
||||||
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
||||||
|
const [schedulingReservation, setSchedulingReservation] = useState(false);
|
||||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||||
@@ -127,17 +234,59 @@ export function ServerCommandPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadReservation = async (options?: { silent?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const nextReservation = await fetchServerRestartReservation();
|
||||||
|
setReservation(nextReservation);
|
||||||
|
return nextReservation;
|
||||||
|
} catch (error) {
|
||||||
|
if (!options?.silent) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '재기동 예약 상태를 불러오지 못했습니다.');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
setReservation(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadItems();
|
void Promise.all([
|
||||||
|
loadItems(),
|
||||||
|
loadReservation({ silent: true }),
|
||||||
|
]);
|
||||||
}, [hasAccess]);
|
}, [hasAccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasAccess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPoll =
|
||||||
|
reservation?.enabled
|
||||||
|
|| reservation?.status === 'recovering'
|
||||||
|
|| reservation?.autoFix.enabled
|
||||||
|
|| restartingKey === 'test'
|
||||||
|
|| restartingKey === 'work-server';
|
||||||
|
|
||||||
|
if (!shouldPoll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerId = window.setInterval(() => {
|
||||||
|
void loadReservation({ silent: true });
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
};
|
||||||
|
}, [hasAccess, reservation, restartingKey]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
return items.reduce(
|
return items.reduce(
|
||||||
(result, item) => {
|
(result, item) => {
|
||||||
@@ -156,6 +305,7 @@ export function ServerCommandPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await restartServerCommand(key);
|
const result = await restartServerCommand(key);
|
||||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||||
|
void loadReservation({ silent: true });
|
||||||
setLastActionByKey((previous) => ({
|
setLastActionByKey((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
[result.item.key]: {
|
[result.item.key]: {
|
||||||
@@ -169,6 +319,11 @@ export function ServerCommandPage() {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
||||||
|
if (error instanceof ServerCommandApiError && error.status === 409 && (key === 'test' || key === 'work-server')) {
|
||||||
|
setRestartErrorInfo(buildRestartReservationInfo(targetLabel, error.workloadSummary, error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||||
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -193,6 +348,26 @@ export function ServerCommandPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScheduleReservation = async () => {
|
||||||
|
if (schedulingReservation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSchedulingReservation(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scheduleServerRestartReservation();
|
||||||
|
setRestartErrorInfo(null);
|
||||||
|
await loadReservation({ silent: true });
|
||||||
|
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
||||||
|
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
||||||
|
} finally {
|
||||||
|
setSchedulingReservation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return (
|
return (
|
||||||
<Card className="server-command-page__card" bordered={false}>
|
<Card className="server-command-page__card" bordered={false}>
|
||||||
@@ -229,7 +404,16 @@ export function ServerCommandPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
void Promise.all([
|
||||||
|
loadItems(),
|
||||||
|
loadReservation({ silent: true }),
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -239,8 +423,8 @@ export function ServerCommandPage() {
|
|||||||
{restartErrorInfo ? (
|
{restartErrorInfo ? (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="error"
|
type={restartErrorInfo.tone}
|
||||||
message="재기동 에러"
|
message={restartErrorInfo.tone === 'warning' ? '재기동 예약 필요' : '재기동 에러'}
|
||||||
description={
|
description={
|
||||||
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
||||||
<Text strong>{restartErrorInfo.title}</Text>
|
<Text strong>{restartErrorInfo.title}</Text>
|
||||||
@@ -253,20 +437,129 @@ export function ServerCommandPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Space size={4}>
|
||||||
type="text"
|
{restartErrorInfo.canScheduleReservation ? (
|
||||||
size="small"
|
<Button
|
||||||
icon={<CopyOutlined />}
|
size="small"
|
||||||
loading={copyingRestartError}
|
type="primary"
|
||||||
aria-label="에러 메시지 복사"
|
loading={schedulingReservation}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCopyRestartError();
|
void handleScheduleReservation();
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
재기동 예약
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
loading={copyingRestartError}
|
||||||
|
aria-label="에러 메시지 복사"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyRestartError();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||||
|
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Title level={5} className="server-command-page__server-title">
|
||||||
|
재기동 예약 상태
|
||||||
|
</Title>
|
||||||
|
{resolveReservationStatusTag(reservation)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Paragraph className="server-command-page__summary">
|
||||||
|
{reservation.waitingReason?.trim()
|
||||||
|
|| (reservation.status === 'completed'
|
||||||
|
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
|
||||||
|
: '예약 상태를 확인했습니다.')}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Descriptions
|
||||||
|
size="small"
|
||||||
|
column={1}
|
||||||
|
className="server-command-page__meta"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'requested-at',
|
||||||
|
label: '요청시각',
|
||||||
|
children: formatDateTime(reservation.requestedAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'auto-execute-at',
|
||||||
|
label: '자동실행',
|
||||||
|
children: formatDateTime(reservation.autoExecuteAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updated-at',
|
||||||
|
label: '마지막 갱신',
|
||||||
|
children: formatDateTime(reservation.updatedAt),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{reservation.workItems.length > 0 ? (
|
||||||
|
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
||||||
|
<Text strong>현재 진행 작업</Text>
|
||||||
|
{reservation.workItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`${item.kind}-${item.requestId ?? item.title}-${index}`}
|
||||||
|
className="server-command-page__work-item"
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
{formatReservationWorkItemTag(item)}
|
||||||
|
<Text strong>{item.title}</Text>
|
||||||
|
</Space>
|
||||||
|
{item.detail ? (
|
||||||
|
<Text type="secondary" className="server-command-page__work-detail">
|
||||||
|
{item.detail}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{reservation.autoFix.enabled ? (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type={resolveAutoFixTone(reservation.autoFix)}
|
||||||
|
message="Codex 자동 개선"
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={4} className="server-command-page__alert-body">
|
||||||
|
<Text strong>
|
||||||
|
{reservation.autoFix.summary?.trim() || '빌드 오류 자동 개선 상태를 추적 중입니다.'}
|
||||||
|
</Text>
|
||||||
|
{reservation.autoFix.detail ? (
|
||||||
|
<span className="server-command-page__alert-text">{reservation.autoFix.detail}</span>
|
||||||
|
) : null}
|
||||||
|
<Text type="secondary">
|
||||||
|
상태: {formatAutoFixStatusLabel(reservation.autoFix.status)}
|
||||||
|
{reservation.autoFix.targetKey ? ` · 대상 ${reservation.autoFix.targetKey.toUpperCase()}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{reservation.lastError ? (
|
||||||
|
<Text type="danger" className="server-command-page__preview">
|
||||||
|
{reservation.lastError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
||||||
) : errorMessage ? (
|
) : errorMessage ? (
|
||||||
@@ -275,7 +568,15 @@ export function ServerCommandPage() {
|
|||||||
title="서버 명령 메뉴를 불러오지 못했습니다."
|
title="서버 명령 메뉴를 불러오지 못했습니다."
|
||||||
description={errorMessage}
|
description={errorMessage}
|
||||||
actions={
|
actions={
|
||||||
<Button type="primary" onClick={() => void loadItems()}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void Promise.all([
|
||||||
|
loadItems(),
|
||||||
|
loadReservation({ silent: true }),
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||||
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
|
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
|
||||||
import type {
|
import type {
|
||||||
|
RestartReservationWorkloadSummary,
|
||||||
ServerCommandActionResult,
|
ServerCommandActionResult,
|
||||||
ServerCommandItem,
|
ServerCommandItem,
|
||||||
ServerCommandKey,
|
ServerCommandKey,
|
||||||
|
ServerRestartReservationAutoFix,
|
||||||
ServerRestartReservation,
|
ServerRestartReservation,
|
||||||
ServerRestartReservationStatus,
|
ServerRestartReservationStatus,
|
||||||
|
ServerRestartReservationWorkItem,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ServerCommandApiError extends Error {
|
export class ServerCommandApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
|
workloadSummary: RestartReservationWorkloadSummary | null;
|
||||||
|
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number, workloadSummary: RestartReservationWorkloadSummary | null = null) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ServerCommandApiError';
|
this.name = 'ServerCommandApiError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.workloadSummary = workloadSummary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +136,30 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
let payload: { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> } | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(text) as { message?: string };
|
payload = JSON.parse(text) as { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> };
|
||||||
throw new ServerCommandApiError(payload.message || '서버 명령 요청에 실패했습니다.', response.status);
|
} catch {}
|
||||||
} catch {
|
|
||||||
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
if (payload) {
|
||||||
|
const workloadSummary =
|
||||||
|
payload.workloadSummary && typeof payload.workloadSummary === 'object'
|
||||||
|
? {
|
||||||
|
codexRunningCount: Number(payload.workloadSummary.codexRunningCount ?? 0),
|
||||||
|
codexQueuedCount: Number(payload.workloadSummary.codexQueuedCount ?? 0),
|
||||||
|
automationRunningCount: Number(payload.workloadSummary.automationRunningCount ?? 0),
|
||||||
|
automationQueuedCount: Number(payload.workloadSummary.automationQueuedCount ?? 0),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
throw new ServerCommandApiError(
|
||||||
|
payload.message || '서버 명령 요청에 실패했습니다.',
|
||||||
|
response.status,
|
||||||
|
workloadSummary,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
@@ -281,6 +303,7 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
|
|||||||
return value === 'waiting'
|
return value === 'waiting'
|
||||||
|| value === 'ready'
|
|| value === 'ready'
|
||||||
|| value === 'executing'
|
|| value === 'executing'
|
||||||
|
|| value === 'recovering'
|
||||||
|| value === 'completed'
|
|| value === 'completed'
|
||||||
|| value === 'cancelled'
|
|| value === 'cancelled'
|
||||||
|| value === 'failed'
|
|| value === 'failed'
|
||||||
@@ -288,6 +311,83 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
|
|||||||
: 'idle';
|
: 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeServerRestartReservationTarget(value: unknown): ServerRestartReservation['target'] {
|
||||||
|
return value === 'test' || value === 'work-server' ? value : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerRestartReservationWorkItems(value: unknown): ServerRestartReservationWorkItem[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = item as Partial<ServerRestartReservationWorkItem>;
|
||||||
|
const kind = candidate.kind === 'automation' ? 'automation' : candidate.kind === 'codex' ? 'codex' : null;
|
||||||
|
const status =
|
||||||
|
candidate.status === 'running' || candidate.status === 'queued' || candidate.status === 'waiting'
|
||||||
|
? candidate.status
|
||||||
|
: null;
|
||||||
|
const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
|
||||||
|
|
||||||
|
if (!kind || !status || !title) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||||
|
requestId: typeof candidate.requestId === 'string' ? candidate.requestId : null,
|
||||||
|
sessionId: typeof candidate.sessionId === 'string' ? candidate.sessionId : null,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerRestartReservationAutoFix(value: unknown): ServerRestartReservationAutoFix {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
targetKey: null,
|
||||||
|
requestId: null,
|
||||||
|
sessionId: null,
|
||||||
|
status: 'idle',
|
||||||
|
summary: null,
|
||||||
|
detail: null,
|
||||||
|
requestedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
failedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<ServerRestartReservationAutoFix>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: candidate.enabled === true,
|
||||||
|
targetKey: candidate.targetKey === 'test' || candidate.targetKey === 'work-server' ? candidate.targetKey : null,
|
||||||
|
requestId: typeof candidate.requestId === 'string' ? candidate.requestId : null,
|
||||||
|
sessionId: typeof candidate.sessionId === 'string' ? candidate.sessionId : null,
|
||||||
|
status:
|
||||||
|
candidate.status === 'queued'
|
||||||
|
|| candidate.status === 'running'
|
||||||
|
|| candidate.status === 'completed'
|
||||||
|
|| candidate.status === 'failed'
|
||||||
|
? candidate.status
|
||||||
|
: 'idle',
|
||||||
|
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||||
|
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||||
|
requestedAt: typeof candidate.requestedAt === 'string' ? candidate.requestedAt : null,
|
||||||
|
startedAt: typeof candidate.startedAt === 'string' ? candidate.startedAt : null,
|
||||||
|
completedAt: typeof candidate.completedAt === 'string' ? candidate.completedAt : null,
|
||||||
|
failedAt: typeof candidate.failedAt === 'string' ? candidate.failedAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function extractServerRestartReservation(response: unknown): ServerRestartReservation {
|
function extractServerRestartReservation(response: unknown): ServerRestartReservation {
|
||||||
if (!response || typeof response !== 'object') {
|
if (!response || typeof response !== 'object') {
|
||||||
throw new Error('재기동 예약 응답 형식이 올바르지 않습니다.');
|
throw new Error('재기동 예약 응답 형식이 올바르지 않습니다.');
|
||||||
@@ -309,12 +409,12 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
|||||||
const reservation = item as Partial<ServerRestartReservation>;
|
const reservation = item as Partial<ServerRestartReservation>;
|
||||||
const workloadSummary =
|
const workloadSummary =
|
||||||
reservation.workloadSummary && typeof reservation.workloadSummary === 'object'
|
reservation.workloadSummary && typeof reservation.workloadSummary === 'object'
|
||||||
? reservation.workloadSummary
|
? (reservation.workloadSummary as Partial<RestartReservationWorkloadSummary>)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: reservation.enabled === true,
|
enabled: reservation.enabled === true,
|
||||||
target: reservation.target === 'all' ? 'all' : 'all',
|
target: normalizeServerRestartReservationTarget(reservation.target),
|
||||||
status: normalizeServerRestartReservationStatus(reservation.status),
|
status: normalizeServerRestartReservationStatus(reservation.status),
|
||||||
requestedAt: typeof reservation.requestedAt === 'string' ? reservation.requestedAt : null,
|
requestedAt: typeof reservation.requestedAt === 'string' ? reservation.requestedAt : null,
|
||||||
requestedByClientId: typeof reservation.requestedByClientId === 'string' ? reservation.requestedByClientId : null,
|
requestedByClientId: typeof reservation.requestedByClientId === 'string' ? reservation.requestedByClientId : null,
|
||||||
@@ -338,6 +438,8 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
|||||||
autoExecuteAt: typeof reservation.autoExecuteAt === 'string' ? reservation.autoExecuteAt : null,
|
autoExecuteAt: typeof reservation.autoExecuteAt === 'string' ? reservation.autoExecuteAt : null,
|
||||||
autoExecuteDelaySeconds: Number(reservation.autoExecuteDelaySeconds ?? 10),
|
autoExecuteDelaySeconds: Number(reservation.autoExecuteDelaySeconds ?? 10),
|
||||||
updatedAt: typeof reservation.updatedAt === 'string' ? reservation.updatedAt : null,
|
updatedAt: typeof reservation.updatedAt === 'string' ? reservation.updatedAt : null,
|
||||||
|
workItems: normalizeServerRestartReservationWorkItems(reservation.workItems),
|
||||||
|
autoFix: normalizeServerRestartReservationAutoFix(reservation.autoFix),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__reservation-card {
|
||||||
|
border: 1px solid #d6e4ff;
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__server-card {
|
.server-command-page__server-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,21 @@
|
|||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__work-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__work-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f7faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__work-detail.ant-typography {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__meta .ant-descriptions-item-label {
|
.server-command-page__meta .ant-descriptions-item-label {
|
||||||
width: 104px;
|
width: 104px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
|
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
|
||||||
|
|
||||||
|
export type RestartReservationWorkloadSummary = {
|
||||||
|
codexRunningCount: number;
|
||||||
|
codexQueuedCount: number;
|
||||||
|
automationRunningCount: number;
|
||||||
|
automationQueuedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerCommandItem = {
|
export type ServerCommandItem = {
|
||||||
key: ServerCommandKey;
|
key: ServerCommandKey;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -44,25 +51,44 @@ export type ServerRestartReservationStatus =
|
|||||||
| 'waiting'
|
| 'waiting'
|
||||||
| 'ready'
|
| 'ready'
|
||||||
| 'executing'
|
| 'executing'
|
||||||
|
| 'recovering'
|
||||||
| 'completed'
|
| 'completed'
|
||||||
| 'cancelled'
|
| 'cancelled'
|
||||||
| 'failed';
|
| 'failed';
|
||||||
|
|
||||||
|
export type ServerRestartReservationWorkItem = {
|
||||||
|
kind: 'codex' | 'automation';
|
||||||
|
status: 'running' | 'queued' | 'waiting';
|
||||||
|
title: string;
|
||||||
|
detail: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerRestartReservationAutoFix = {
|
||||||
|
enabled: boolean;
|
||||||
|
targetKey: 'test' | 'work-server' | null;
|
||||||
|
requestId: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
status: 'idle' | 'queued' | 'running' | 'completed' | 'failed';
|
||||||
|
summary: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
requestedAt: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
failedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerRestartReservation = {
|
export type ServerRestartReservation = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
target: 'all';
|
target: 'all' | 'test' | 'work-server';
|
||||||
status: ServerRestartReservationStatus;
|
status: ServerRestartReservationStatus;
|
||||||
requestedAt: string | null;
|
requestedAt: string | null;
|
||||||
requestedByClientId: string | null;
|
requestedByClientId: string | null;
|
||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
nextCheckAt: string | null;
|
nextCheckAt: string | null;
|
||||||
waitingReason: string | null;
|
waitingReason: string | null;
|
||||||
workloadSummary: {
|
workloadSummary: RestartReservationWorkloadSummary;
|
||||||
codexRunningCount: number;
|
|
||||||
codexQueuedCount: number;
|
|
||||||
automationRunningCount: number;
|
|
||||||
automationQueuedCount: number;
|
|
||||||
};
|
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
@@ -73,4 +99,6 @@ export type ServerRestartReservation = {
|
|||||||
autoExecuteAt: string | null;
|
autoExecuteAt: string | null;
|
||||||
autoExecuteDelaySeconds: number;
|
autoExecuteDelaySeconds: number;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
workItems: ServerRestartReservationWorkItem[];
|
||||||
|
autoFix: ServerRestartReservationAutoFix;
|
||||||
};
|
};
|
||||||
|
|||||||
40
src/sw.js
40
src/sw.js
@@ -7,17 +7,22 @@ import { NavigationRoute, registerRoute } from 'workbox-routing';
|
|||||||
clientsClaim();
|
clientsClaim();
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
const navigationFallbackDenylist = [
|
||||||
|
/^\/api\/chat\/resources(?:\/|$)/,
|
||||||
|
/^\/(?:public\/)?\.codex_chat(?:\/|$)/,
|
||||||
|
];
|
||||||
|
|
||||||
const manifest = self.__WB_MANIFEST;
|
const manifest = self.__WB_MANIFEST;
|
||||||
|
|
||||||
if (Array.isArray(manifest) && manifest.length > 0) {
|
if (Array.isArray(manifest) && manifest.length > 0) {
|
||||||
precacheAndRoute(manifest);
|
precacheAndRoute(manifest);
|
||||||
const navigationHandler = createHandlerBoundToURL('/index.html');
|
const navigationHandler = createHandlerBoundToURL('/index.html');
|
||||||
registerRoute(new NavigationRoute(navigationHandler));
|
registerRoute(new NavigationRoute(navigationHandler, { denylist: navigationFallbackDenylist }));
|
||||||
} else {
|
} else {
|
||||||
registerRoute(
|
registerRoute(
|
||||||
new NavigationRoute(({ request }) => {
|
new NavigationRoute(({ request }) => {
|
||||||
return fetch(request);
|
return fetch(request);
|
||||||
}),
|
}, { denylist: navigationFallbackDenylist }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +57,6 @@ function isChatNotificationPayload(payload) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractNotificationSessionId(payload) {
|
|
||||||
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
|
|
||||||
return normalizeNotificationValue(data.sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVisibleAppClient(client) {
|
function isVisibleAppClient(client) {
|
||||||
if (!client || typeof client.url !== 'string') {
|
if (!client || typeof client.url !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
@@ -72,37 +72,13 @@ function isVisibleAppClient(client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleChatClientForSession(client, sessionId) {
|
|
||||||
if (!isVisibleAppClient(client)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const clientUrl = new URL(client.url);
|
|
||||||
const clientSessionId = normalizeNotificationValue(clientUrl.searchParams.get('sessionId'));
|
|
||||||
const clientPathname = normalizeNotificationValue(clientUrl.pathname);
|
|
||||||
|
|
||||||
return clientPathname === '/chat/live' && clientSessionId === sessionId;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSuppressChatNotificationForVisibleApp(payload) {
|
function shouldSuppressChatNotificationForVisibleApp(payload) {
|
||||||
if (!isChatNotificationPayload(payload)) {
|
if (!isChatNotificationPayload(payload)) {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationSessionId = extractNotificationSessionId(payload);
|
|
||||||
|
|
||||||
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) =>
|
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) =>
|
||||||
clientList.some((client) => {
|
clientList.some((client) => isVisibleAppClient(client)),
|
||||||
if (notificationSessionId && isVisibleChatClientForSession(client, notificationSessionId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isVisibleAppClient(client);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
src/utils/clipboard.ts
Normal file
117
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
type SelectionSnapshot = {
|
||||||
|
activeElement: Element | null;
|
||||||
|
ranges: Range[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function captureSelectionSnapshot(): SelectionSnapshot | null {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const ranges: Range[] = [];
|
||||||
|
|
||||||
|
if (selection) {
|
||||||
|
for (let index = 0; index < selection.rangeCount; index += 1) {
|
||||||
|
ranges.push(selection.getRangeAt(index).cloneRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeElement: document.activeElement,
|
||||||
|
ranges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSelectionSnapshot(snapshot: SelectionSnapshot | null) {
|
||||||
|
if (!snapshot || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
snapshot.ranges.forEach((range) => selection?.addRange(range));
|
||||||
|
|
||||||
|
const target = snapshot.activeElement;
|
||||||
|
|
||||||
|
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
||||||
|
try {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
target.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUsingTextareaFallback(text: string) {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
typeof document === 'undefined' ||
|
||||||
|
typeof document.execCommand !== 'function' ||
|
||||||
|
!document.body
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = captureSelectionSnapshot();
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.tabIndex = -1;
|
||||||
|
textarea.readOnly = true;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.setAttribute('aria-hidden', 'true');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
textarea.style.left = '0';
|
||||||
|
textarea.style.width = '1px';
|
||||||
|
textarea.style.height = '1px';
|
||||||
|
textarea.style.padding = '0';
|
||||||
|
textarea.style.border = '0';
|
||||||
|
textarea.style.outline = '0';
|
||||||
|
textarea.style.boxShadow = 'none';
|
||||||
|
textarea.style.background = 'transparent';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
textarea.style.fontSize = '16px';
|
||||||
|
textarea.style.whiteSpace = 'pre';
|
||||||
|
textarea.style.userSelect = 'text';
|
||||||
|
textarea.style.webkitUserSelect = 'text';
|
||||||
|
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
textarea.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.select();
|
||||||
|
textarea.selectionStart = 0;
|
||||||
|
textarea.selectionEnd = text.length;
|
||||||
|
textarea.setSelectionRange(0, text.length);
|
||||||
|
return document.execCommand('copy');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
textarea.remove();
|
||||||
|
restoreSelectionSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall back when the browser exposes the API but rejects the write.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyUsingTextareaFallback(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('브라우저가 클립보드 복사를 차단했습니다.');
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@ function formatPercent(value: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSubjectQuestionCount(examId: string, subjectId: string) {
|
function getSubjectQuestionCount(examId: string, subjectId: string) {
|
||||||
return CBT_QUESTIONS.filter((question) => question.examId === examId && question.subjectId === subjectId).length;
|
return CBT_QUESTIONS.filter((question) => question.isActive && question.examId === examId && question.subjectId === subjectId).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSubjectQuestionSetCount(examId: string, subjectId: string) {
|
function getSubjectQuestionSetCount(examId: string, subjectId: string) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '타당성 검토에서 기술적 타당성을 확인할 때 가장 먼저 보는 관점은 무엇인가요?',
|
body: '타당성 검토에서 기술적 타당성을 확인할 때 가장 먼저 보는 관점은 무엇인가요?',
|
||||||
answerValue: '3',
|
answerValue: '3',
|
||||||
explanation: '현 기술 스택과 인력으로 요구 기능을 구현 가능한지 확인하는 것이 기술적 타당성의 핵심입니다.',
|
explanation: '현 기술 스택과 인력으로 요구 기능을 구현 가능한지 확인하는 것이 기술적 타당성의 핵심입니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['타당성', '분석'],
|
tags: ['타당성', '분석'],
|
||||||
correctRate: 0.71,
|
correctRate: 0.71,
|
||||||
choices: ['사무실 좌석 배치를 바꾸는 비용', '광고 문구의 완성도', '현재 기술과 인력으로 구현 가능한지 여부', '배경 이미지 해상도'],
|
choices: ['사무실 좌석 배치를 바꾸는 비용', '광고 문구의 완성도', '현재 기술과 인력으로 구현 가능한지 여부', '배경 이미지 해상도'],
|
||||||
@@ -105,7 +105,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: 'DevOps 도입의 직접적인 목표로 가장 적절한 것은 무엇인가요?',
|
body: 'DevOps 도입의 직접적인 목표로 가장 적절한 것은 무엇인가요?',
|
||||||
answerValue: '4',
|
answerValue: '4',
|
||||||
explanation: '개발과 운영 협업을 강화해 배포 속도와 안정성을 함께 높이는 것이 목적입니다.',
|
explanation: '개발과 운영 협업을 강화해 배포 속도와 안정성을 함께 높이는 것이 목적입니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['DevOps', '협업'],
|
tags: ['DevOps', '협업'],
|
||||||
correctRate: 0.74,
|
correctRate: 0.74,
|
||||||
choices: ['운영팀을 없앤다', '테스트를 생략한다', '문서를 금지한다', '개발과 운영의 피드백 주기를 단축한다'],
|
choices: ['운영팀을 없앤다', '테스트를 생략한다', '문서를 금지한다', '개발과 운영의 피드백 주기를 단축한다'],
|
||||||
@@ -117,7 +117,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '기존 기능 수정 후 주변 기능이 깨지지 않았는지 확인하는 테스트는 무엇인가요?',
|
body: '기존 기능 수정 후 주변 기능이 깨지지 않았는지 확인하는 테스트는 무엇인가요?',
|
||||||
answerValue: '2',
|
answerValue: '2',
|
||||||
explanation: '회귀 테스트는 변경 이후 기존 기능의 정상 동작을 다시 확인하는 테스트입니다.',
|
explanation: '회귀 테스트는 변경 이후 기존 기능의 정상 동작을 다시 확인하는 테스트입니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['테스트', '회귀'],
|
tags: ['테스트', '회귀'],
|
||||||
correctRate: 0.77,
|
correctRate: 0.77,
|
||||||
choices: ['인수 테스트', '회귀 테스트', '알파 테스트', '베타 테스트'],
|
choices: ['인수 테스트', '회귀 테스트', '알파 테스트', '베타 테스트'],
|
||||||
@@ -153,7 +153,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '시맨틱 버저닝에서 `2.4.1`의 마지막 숫자가 증가하는 일반적인 경우는 무엇인가요?',
|
body: '시맨틱 버저닝에서 `2.4.1`의 마지막 숫자가 증가하는 일반적인 경우는 무엇인가요?',
|
||||||
answerValue: '4',
|
answerValue: '4',
|
||||||
explanation: '패치 버전은 하위 호환 가능한 버그 수정이 있을 때 증가합니다.',
|
explanation: '패치 버전은 하위 호환 가능한 버그 수정이 있을 때 증가합니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['배포', '버전관리'],
|
tags: ['배포', '버전관리'],
|
||||||
correctRate: 0.72,
|
correctRate: 0.72,
|
||||||
choices: ['대규모 구조 개편이 있을 때', '하위 호환이 깨지는 변경일 때', '새로운 주요 기능 묶음을 추가할 때', '하위 호환 가능한 버그 수정일 때'],
|
choices: ['대규모 구조 개편이 있을 때', '하위 호환이 깨지는 변경일 때', '새로운 주요 기능 묶음을 추가할 때', '하위 호환 가능한 버그 수정일 때'],
|
||||||
@@ -189,7 +189,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '집계 결과에 조건을 적용할 때 `WHERE` 대신 주로 사용하는 절은 무엇인가요?',
|
body: '집계 결과에 조건을 적용할 때 `WHERE` 대신 주로 사용하는 절은 무엇인가요?',
|
||||||
answerValue: '4',
|
answerValue: '4',
|
||||||
explanation: '집계 이후의 그룹 조건은 HAVING 절에서 처리합니다.',
|
explanation: '집계 이후의 그룹 조건은 HAVING 절에서 처리합니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['SQL', '집계'],
|
tags: ['SQL', '집계'],
|
||||||
correctRate: 0.79,
|
correctRate: 0.79,
|
||||||
choices: ['ORDER BY', 'LIMIT', 'GROUP SETS', 'HAVING'],
|
choices: ['ORDER BY', 'LIMIT', 'GROUP SETS', 'HAVING'],
|
||||||
@@ -201,7 +201,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '공통 키가 있는 행만 결과로 가져오려면 어떤 조인을 사용해야 하나요?',
|
body: '공통 키가 있는 행만 결과로 가져오려면 어떤 조인을 사용해야 하나요?',
|
||||||
answerValue: '1',
|
answerValue: '1',
|
||||||
explanation: 'INNER JOIN은 양쪽 테이블에서 조건이 일치하는 행만 반환합니다.',
|
explanation: 'INNER JOIN은 양쪽 테이블에서 조건이 일치하는 행만 반환합니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['SQL', '조인'],
|
tags: ['SQL', '조인'],
|
||||||
correctRate: 0.81,
|
correctRate: 0.81,
|
||||||
choices: ['INNER JOIN', 'LEFT OUTER JOIN', 'CROSS JOIN', 'SELF JOIN'],
|
choices: ['INNER JOIN', 'LEFT OUTER JOIN', 'CROSS JOIN', 'SELF JOIN'],
|
||||||
@@ -237,7 +237,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '재귀 함수가 무한 호출되지 않도록 반드시 갖춰야 하는 요소는 무엇인가요?',
|
body: '재귀 함수가 무한 호출되지 않도록 반드시 갖춰야 하는 요소는 무엇인가요?',
|
||||||
answerValue: '1',
|
answerValue: '1',
|
||||||
explanation: '재귀 종료 조건(base case)이 있어야 반복 호출이 멈춥니다.',
|
explanation: '재귀 종료 조건(base case)이 있어야 반복 호출이 멈춥니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['기초', '재귀'],
|
tags: ['기초', '재귀'],
|
||||||
correctRate: 0.82,
|
correctRate: 0.82,
|
||||||
choices: ['종료 조건', '전역 변수', '배열 정렬', 'GUI 이벤트'],
|
choices: ['종료 조건', '전역 변수', '배열 정렬', 'GUI 이벤트'],
|
||||||
@@ -273,7 +273,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '데이터와 메서드를 하나의 단위로 묶고 외부 접근을 제한하는 개념은 무엇인가요?',
|
body: '데이터와 메서드를 하나의 단위로 묶고 외부 접근을 제한하는 개념은 무엇인가요?',
|
||||||
answerValue: '3',
|
answerValue: '3',
|
||||||
explanation: '캡슐화는 내부 구현을 숨기고 필요한 인터페이스만 노출합니다.',
|
explanation: '캡슐화는 내부 구현을 숨기고 필요한 인터페이스만 노출합니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['객체지향', '캡슐화'],
|
tags: ['객체지향', '캡슐화'],
|
||||||
correctRate: 0.78,
|
correctRate: 0.78,
|
||||||
choices: ['오버로딩', '추상화', '캡슐화', '가비지 컬렉션'],
|
choices: ['오버로딩', '추상화', '캡슐화', '가비지 컬렉션'],
|
||||||
@@ -285,7 +285,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '선입선출(FIFO) 구조로 동작하는 자료구조는 무엇인가요?',
|
body: '선입선출(FIFO) 구조로 동작하는 자료구조는 무엇인가요?',
|
||||||
answerValue: '2',
|
answerValue: '2',
|
||||||
explanation: 'Queue는 먼저 들어온 데이터가 먼저 나가는 FIFO 구조입니다.',
|
explanation: 'Queue는 먼저 들어온 데이터가 먼저 나가는 FIFO 구조입니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['자료구조', '큐'],
|
tags: ['자료구조', '큐'],
|
||||||
correctRate: 0.8,
|
correctRate: 0.8,
|
||||||
choices: ['Stack', 'Queue', 'Tree', 'Graph'],
|
choices: ['Stack', 'Queue', 'Tree', 'Graph'],
|
||||||
@@ -309,7 +309,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '변경관리(Change Management)의 핵심 목적은 무엇인가요?',
|
body: '변경관리(Change Management)의 핵심 목적은 무엇인가요?',
|
||||||
answerValue: '4',
|
answerValue: '4',
|
||||||
explanation: '변경 요청을 통제해 서비스 영향과 위험을 줄이려는 것이 핵심입니다.',
|
explanation: '변경 요청을 통제해 서비스 영향과 위험을 줄이려는 것이 핵심입니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['운영', '변경관리'],
|
tags: ['운영', '변경관리'],
|
||||||
correctRate: 0.73,
|
correctRate: 0.73,
|
||||||
choices: ['문서 작성을 금지한다', '모든 변경을 즉시 반영한다', '개발 서버만 유지한다', '변경 영향과 승인 절차를 관리한다'],
|
choices: ['문서 작성을 금지한다', '모든 변경을 즉시 반영한다', '개발 서버만 유지한다', '변경 영향과 승인 절차를 관리한다'],
|
||||||
@@ -333,7 +333,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
|||||||
body: '최소 권한 원칙(Principle of Least Privilege)의 설명으로 가장 적절한 것은 무엇인가요?',
|
body: '최소 권한 원칙(Principle of Least Privilege)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||||
answerValue: '1',
|
answerValue: '1',
|
||||||
explanation: '업무 수행에 필요한 최소한의 권한만 부여해야 보안 위험을 줄일 수 있습니다.',
|
explanation: '업무 수행에 필요한 최소한의 권한만 부여해야 보안 위험을 줄일 수 있습니다.',
|
||||||
difficulty: 'easy',
|
difficulty: 'medium',
|
||||||
tags: ['보안', '권한'],
|
tags: ['보안', '권한'],
|
||||||
correctRate: 0.76,
|
correctRate: 0.76,
|
||||||
choices: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],
|
choices: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ExamCategory, QuestionRecord, QuestionSet, Subject } from './cbtTypes';
|
import type { ExamCategory, QuestionRecord, QuestionSet, Subject } from './cbtTypes';
|
||||||
import { CBT_BONUS_QUESTION_SEEDS } from './cbtBonusQuestionSeeds';
|
import { CBT_BONUS_QUESTION_SEEDS } from './cbtBonusQuestionSeeds';
|
||||||
|
import { CBT_SUBJECT_EXPANSION_QUESTION_SEEDS } from './cbtSubjectExpansionSeeds';
|
||||||
|
|
||||||
type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' | 'type' | 'isActive'> & {
|
type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' | 'type' | 'isActive'> & {
|
||||||
examId?: string;
|
examId?: string;
|
||||||
@@ -10,6 +11,7 @@ type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' |
|
|||||||
const ENGINEER_SOURCE_CORE = '비공식 재구성 문제집';
|
const ENGINEER_SOURCE_CORE = '비공식 재구성 문제집';
|
||||||
const ENGINEER_SOURCE_PRACTICE = '비공식 실전형 문제집';
|
const ENGINEER_SOURCE_PRACTICE = '비공식 실전형 문제집';
|
||||||
const WEB_SOURCE = '공개 허용 샘플 형식';
|
const WEB_SOURCE = '공개 허용 샘플 형식';
|
||||||
|
const WEB_SOURCE_ADVANCED = '공개 허용 심화 샘플 형식';
|
||||||
|
|
||||||
export const CBT_EXAMS: ExamCategory[] = [
|
export const CBT_EXAMS: ExamCategory[] = [
|
||||||
{ id: 'engineer-info', label: '정보처리기사' },
|
{ id: 'engineer-info', label: '정보처리기사' },
|
||||||
@@ -60,9 +62,26 @@ export const CBT_QUESTION_SETS: QuestionSet[] = [
|
|||||||
{ id: 'system-security', examId: 'engineer-info', subjectId: 'system', label: '보안/위험 문제집', sourceLabel: ENGINEER_SOURCE_PRACTICE },
|
{ id: 'system-security', examId: 'engineer-info', subjectId: 'system', label: '보안/위험 문제집', sourceLabel: ENGINEER_SOURCE_PRACTICE },
|
||||||
{ id: 'system-infra', examId: 'engineer-info', subjectId: 'system', label: '인프라/운영 문제집', sourceLabel: ENGINEER_SOURCE_PRACTICE },
|
{ id: 'system-infra', examId: 'engineer-info', subjectId: 'system', label: '인프라/운영 문제집', sourceLabel: ENGINEER_SOURCE_PRACTICE },
|
||||||
{ id: 'web-core', examId: 'web-general', subjectId: 'html-css', label: '입문 문제집', sourceLabel: WEB_SOURCE },
|
{ id: 'web-core', examId: 'web-general', subjectId: 'html-css', label: '입문 문제집', sourceLabel: WEB_SOURCE },
|
||||||
|
{ id: 'web-layout', examId: 'web-general', subjectId: 'html-css', label: '레이아웃 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||||
|
{ id: 'web-accessibility', examId: 'web-general', subjectId: 'html-css', label: '접근성 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||||
|
{ id: 'web-responsive', examId: 'web-general', subjectId: 'html-css', label: '반응형 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||||
|
{ id: 'web-performance', examId: 'web-general', subjectId: 'html-css', label: '성능 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||||
|
{ id: 'web-browser', examId: 'web-general', subjectId: 'html-css', label: '브라우저/모바일 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||||
];
|
];
|
||||||
|
|
||||||
const QUESTION_SET_SOURCE_MAP = new Map(CBT_QUESTION_SETS.map((item) => [item.id, item.sourceLabel]));
|
const QUESTION_SET_SOURCE_MAP = new Map(CBT_QUESTION_SETS.map((item) => [item.id, item.sourceLabel]));
|
||||||
|
const INACTIVE_QUESTION_IDS = new Set([
|
||||||
|
'dev-core-2',
|
||||||
|
'dev-core-2-v1',
|
||||||
|
'dev-core-2-v2',
|
||||||
|
'dev-core-2-v3',
|
||||||
|
'programming-core-2',
|
||||||
|
'programming-core-2-v1',
|
||||||
|
'programming-core-2-v2',
|
||||||
|
'programming-core-2-v3',
|
||||||
|
'web-core-1',
|
||||||
|
'web-core-3',
|
||||||
|
]);
|
||||||
|
|
||||||
function buildQuestion(seed: QuestionSeed): QuestionRecord {
|
function buildQuestion(seed: QuestionSeed): QuestionRecord {
|
||||||
return {
|
return {
|
||||||
@@ -78,7 +97,7 @@ function buildQuestion(seed: QuestionSeed): QuestionRecord {
|
|||||||
sourceLabel: seed.sourceLabel ?? QUESTION_SET_SOURCE_MAP.get(seed.setId) ?? ENGINEER_SOURCE_PRACTICE,
|
sourceLabel: seed.sourceLabel ?? QUESTION_SET_SOURCE_MAP.get(seed.setId) ?? ENGINEER_SOURCE_PRACTICE,
|
||||||
tags: seed.tags,
|
tags: seed.tags,
|
||||||
correctRate: seed.correctRate,
|
correctRate: seed.correctRate,
|
||||||
isActive: true,
|
isActive: !INACTIVE_QUESTION_IDS.has(seed.id),
|
||||||
choices: seed.choices.map((label, index) => ({
|
choices: seed.choices.map((label, index) => ({
|
||||||
value: String(index + 1),
|
value: String(index + 1),
|
||||||
label,
|
label,
|
||||||
@@ -1098,6 +1117,7 @@ export const CBT_QUESTIONS: QuestionRecord[] = [
|
|||||||
...QUESTION_SEEDS,
|
...QUESTION_SEEDS,
|
||||||
...ENGINEER_VARIANT_QUESTION_SEEDS,
|
...ENGINEER_VARIANT_QUESTION_SEEDS,
|
||||||
...CBT_BONUS_QUESTION_SEEDS,
|
...CBT_BONUS_QUESTION_SEEDS,
|
||||||
|
...CBT_SUBJECT_EXPANSION_QUESTION_SEEDS,
|
||||||
].map(buildQuestion);
|
].map(buildQuestion);
|
||||||
|
|
||||||
export const QUICK_QUESTION_COUNTS = [10, 20, 40, 60, 80];
|
export const QUICK_QUESTION_COUNTS = [10, 20, 40, 60, 80];
|
||||||
|
|||||||
655
src/views/play/apps/cbt/cbtSubjectExpansionSeeds.ts
Normal file
655
src/views/play/apps/cbt/cbtSubjectExpansionSeeds.ts
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
type BonusQuestionSeed = {
|
||||||
|
id: string;
|
||||||
|
examId?: string;
|
||||||
|
subjectId: string;
|
||||||
|
setId: string;
|
||||||
|
body: string;
|
||||||
|
answerValue: string;
|
||||||
|
explanation: string;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
|
tags: string[];
|
||||||
|
correctRate: number;
|
||||||
|
choices: [string, string, string, string];
|
||||||
|
sourceLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CBT_SUBJECT_EXPANSION_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||||
|
{
|
||||||
|
id: 'algo-core-9',
|
||||||
|
subjectId: 'algo',
|
||||||
|
setId: 'algo-core',
|
||||||
|
body: '요구사항 검증 회의에서 모호한 표현을 우선 수정해야 하는 가장 직접적인 이유는 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '모호한 요구사항은 구현과 테스트 기준을 흐리게 만들어 해석 차이를 키웁니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['요구사항', '검증'],
|
||||||
|
correctRate: 0.62,
|
||||||
|
choices: ['화면 수를 줄이기 위해', '구현자마다 다른 해석이 생기지 않게 하려고', '배포 속도를 높이기 위해', 'DB 정규화를 생략하려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'algo-core-10',
|
||||||
|
subjectId: 'algo',
|
||||||
|
setId: 'algo-core',
|
||||||
|
body: '현행 업무 분석에서 AS-IS와 TO-BE를 함께 정리하는 이유로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '현재 상태와 목표 상태를 나란히 봐야 개선 범위와 변경 포인트를 명확히 잡을 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['현행분석', '요구사항'],
|
||||||
|
correctRate: 0.58,
|
||||||
|
choices: ['소스 코드를 자동 생성하려고', '운영 서버를 줄이려고', '테스트를 생략하려고', '개선 대상과 전환 범위를 비교하려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'algo-pattern-9',
|
||||||
|
subjectId: 'algo',
|
||||||
|
setId: 'algo-pattern',
|
||||||
|
body: '객체 생성 절차가 복잡하고 생성 단계 조합이 많을 때 적용하기 좋은 패턴은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: 'Builder 패턴은 복잡한 생성 절차를 단계별로 분리해 조합하기 좋습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['패턴', '생성'],
|
||||||
|
correctRate: 0.56,
|
||||||
|
choices: ['State', 'Facade', 'Builder', 'Observer'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'algo-quality-9',
|
||||||
|
subjectId: 'algo',
|
||||||
|
setId: 'algo-quality',
|
||||||
|
body: '마이크로서비스 간 결합이 과도할 때 주로 나타나는 문제로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '서비스 간 의존이 강하면 한 서비스 변경이 연쇄 장애나 배포 제약으로 이어집니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['아키텍처', '품질'],
|
||||||
|
correctRate: 0.43,
|
||||||
|
choices: ['하나의 변경이 여러 서비스 배포를 동시에 요구한다', '정적 파일 캐시 효율이 높아진다', '데이터 모델이 항상 단순해진다', '문서 작성량이 자동 감소한다'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'dev-core-9',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-core',
|
||||||
|
body: '점증적(Incremental) 개발 모델을 적용할 때 기대 효과로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '작은 단위로 기능을 나눠 제공하면 사용자 피드백을 더 빠르게 반영할 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['개발방법론', '점증적'],
|
||||||
|
correctRate: 0.6,
|
||||||
|
choices: ['모든 기능을 마지막에만 검증한다', '우선순위 높은 기능부터 점진적으로 제공한다', '요구사항 변경을 금지한다', '배포를 한 번만 수행한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-core-10',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-core',
|
||||||
|
body: '사용자 스토리(User Story)에 수용 기준(Acceptance Criteria)을 함께 적는 이유는 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '수용 기준이 있어야 완료 판단 기준과 테스트 관점을 구체화할 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['애자일', '요구사항'],
|
||||||
|
correctRate: 0.64,
|
||||||
|
choices: ['회의 시간을 늘리려고', '디자인 시안을 줄이려고', '완료 조건과 검증 기준을 명확히 하려고', '소스 저장소를 분리하려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-test-9',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-test',
|
||||||
|
body: '동등 분할 기법의 주된 목적은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '유사하게 동작할 것으로 기대되는 입력 그룹을 나눠 대표값으로 효율적으로 테스트합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['테스트', '블랙박스'],
|
||||||
|
correctRate: 0.63,
|
||||||
|
choices: ['유사 입력군을 대표값으로 묶어 테스트 효율을 높인다', '코드 실행 경로를 모두 시각화한다', '배포 파이프라인을 생략한다', '운영 로그를 삭제한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-test-10',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-test',
|
||||||
|
body: '상태 전이 테스트가 특히 유용한 시스템 사례로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '로그인 잠금, 주문 상태 전환처럼 상태와 이벤트 조합이 많은 기능에서 효과적입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['테스트', '상태전이'],
|
||||||
|
correctRate: 0.57,
|
||||||
|
choices: ['정적 소개 페이지', '이미지 파일 압축 작업', '컬러 팔레트 정리', '주문 상태가 단계별로 바뀌는 업무 흐름'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-release-9',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-release',
|
||||||
|
body: '지속적 전달(CD) 환경에서 배포 승인을 별도 단계로 두는 이유는 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '자동 검증을 통과해도 운영 반영 시점과 위험도를 사람이 최종 판단할 수 있어야 합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['배포', 'CD'],
|
||||||
|
correctRate: 0.59,
|
||||||
|
choices: ['브랜치 이름을 짧게 만들려고', '운영 반영 타이밍과 위험을 통제하려고', '테스트 데이터를 삭제하려고', '형상 관리를 중단하려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-release-10',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-release',
|
||||||
|
body: '릴리즈 전 체크리스트에 데이터 마이그레이션 검증을 넣어야 하는 이유는 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '스키마나 데이터 구조 변경은 기능 이상보다 더 치명적인 운영 장애로 이어질 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['배포', '데이터'],
|
||||||
|
correctRate: 0.46,
|
||||||
|
choices: ['운영 데이터 손상과 롤백 위험을 줄이기 위해', '화면 색상을 맞추기 위해', '개발자 수를 줄이기 위해', '문서 버전을 숨기기 위해'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-release-11',
|
||||||
|
subjectId: 'dev',
|
||||||
|
setId: 'dev-release',
|
||||||
|
body: '형상 식별(Configuration Identification)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '어떤 산출물을 관리 대상으로 삼고 버전과 구성 단위를 구분할지 정하는 단계입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['형상관리', '구성관리'],
|
||||||
|
correctRate: 0.55,
|
||||||
|
choices: ['모든 문서를 폐기하는 절차', '배포 후 로그만 남기는 절차', '관리 대상 산출물과 버전 기준을 정의하는 활동', '테스트를 운영에서만 수행하는 방식'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'db-core-9',
|
||||||
|
subjectId: 'db',
|
||||||
|
setId: 'db-core',
|
||||||
|
body: '후보키(Candidate Key)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '후보키는 유일성과 최소성을 만족하는 키 후보이며 이 중 하나가 기본키가 됩니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['키', '모델링'],
|
||||||
|
correctRate: 0.6,
|
||||||
|
choices: ['반드시 외래키를 포함하는 키', '정렬용으로만 쓰는 컬럼', 'NULL이 허용되는 식별자', '유일성과 최소성을 만족하는 식별자 후보'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-core-10',
|
||||||
|
subjectId: 'db',
|
||||||
|
setId: 'db-core',
|
||||||
|
body: '이행 함수 종속을 제거하는 정규화 단계는 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '제3정규형(3NF)은 이행 함수 종속을 제거하는 단계입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['정규화', '모델링'],
|
||||||
|
correctRate: 0.57,
|
||||||
|
choices: ['제1정규형', '제3정규형', 'BCNF', '제5정규형'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-sql-9',
|
||||||
|
subjectId: 'db',
|
||||||
|
setId: 'db-sql',
|
||||||
|
body: '윈도우 함수 `ROW_NUMBER()`를 주로 사용하는 상황으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '그룹 내 순번 계산이나 상위 N건 추출처럼 행 순서를 함께 다뤄야 할 때 유용합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['SQL', '윈도우함수'],
|
||||||
|
correctRate: 0.54,
|
||||||
|
choices: ['정렬 기준에 따라 행 순번을 계산할 때', '테이블을 삭제할 때', '인덱스를 비활성화할 때', '트랜잭션을 커밋할 때'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-sql-10',
|
||||||
|
subjectId: 'db',
|
||||||
|
setId: 'db-sql',
|
||||||
|
body: '실행 계획을 확인하는 가장 직접적인 이유는 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '실제 접근 경로와 비용이 어떻게 계산됐는지 봐야 느린 쿼리의 원인을 찾을 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['SQL', '튜닝'],
|
||||||
|
correctRate: 0.42,
|
||||||
|
choices: ['UI 레이아웃을 조정하려고', '샘플 데이터를 지우려고', '조회 경로와 병목 구간을 분석하려고', '백업 주기를 바꾸려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-ops-9',
|
||||||
|
subjectId: 'db',
|
||||||
|
setId: 'db-ops',
|
||||||
|
body: 'Repeatable Read 격리 수준이 주로 방지하려는 현상은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '같은 행을 두 번 읽을 때 값이 바뀌는 비반복 읽기 현상을 막는 데 초점이 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['트랜잭션', '격리수준'],
|
||||||
|
correctRate: 0.53,
|
||||||
|
choices: ['Dirty Write', 'Non-repeatable Read', 'Hash Collision', 'Full Scan'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db-ops-10',
|
||||||
|
subjectId: 'db',
|
||||||
|
setId: 'db-ops',
|
||||||
|
body: '포인트 인 타임 복구(PITR)가 필요한 상황으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '잘못된 대량 삭제처럼 특정 시점 이전으로 복구해야 할 때 PITR이 유용합니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['백업', '복구'],
|
||||||
|
correctRate: 0.45,
|
||||||
|
choices: ['화면 정렬 순서를 바꿀 때', '정적 파일을 압축할 때', '새 테마를 적용할 때', '특정 시점 직전 상태로 데이터를 되돌려야 할 때'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'programming-core-9',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-core',
|
||||||
|
body: '포인터 연산을 잘못 사용했을 때 발생하기 쉬운 문제로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '잘못된 주소 접근은 메모리 오류와 비정상 종료를 유발할 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['C', '포인터'],
|
||||||
|
correctRate: 0.57,
|
||||||
|
choices: ['의도하지 않은 메모리 영역 접근', '컴파일러 업데이트 자동 수행', '정렬 알고리즘 단순화', '함수 수 감소'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programming-core-10',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-core',
|
||||||
|
body: 'Call by Reference 방식의 특징으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '참조를 전달하므로 함수 내부 변경이 원본 데이터에 반영될 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['기초', '함수'],
|
||||||
|
correctRate: 0.63,
|
||||||
|
choices: ['항상 복사본만 바뀐다', '지역 변수를 만들 수 없다', '원본 데이터가 직접 수정될 수 있다', '배열을 전달할 수 없다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programming-oo-9',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-oo',
|
||||||
|
body: '인터페이스를 우선 설계하는 이유로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '구현보다 계약을 먼저 고정하면 교체 가능성과 테스트 용이성이 높아집니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['객체지향', '인터페이스'],
|
||||||
|
correctRate: 0.61,
|
||||||
|
choices: ['상속 구조를 없애려고', '구현체 교체와 의존성 분리를 쉽게 하려고', '예외 처리를 금지하려고', '모든 메서드를 static으로 만들려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programming-oo-10',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-oo',
|
||||||
|
body: '추상화가 잘된 설계의 효과로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '핵심 개념과 계약만 드러내고 세부 구현을 숨겨 변경 영향을 줄일 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['객체지향', '추상화'],
|
||||||
|
correctRate: 0.45,
|
||||||
|
choices: ['모든 필드를 public으로 노출한다', '런타임 비용을 항상 0으로 만든다', '소스 파일 수를 절반으로 줄인다', '세부 구현 변경이 외부 사용처에 덜 번지게 한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programming-script-9',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-script',
|
||||||
|
body: '이진 탐색의 전제 조건으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '이진 탐색은 탐색 대상이 정렬돼 있어야 절반씩 범위를 줄일 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['알고리즘', '탐색'],
|
||||||
|
correctRate: 0.62,
|
||||||
|
choices: ['데이터가 정렬돼 있어야 한다', '항상 연결 리스트여야 한다', '재귀를 사용할 수 없다', '중복 데이터가 없어야만 한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programming-script-10',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-script',
|
||||||
|
body: '깊이 우선 탐색(DFS)에 대한 설명으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '가능한 한 깊게 내려간 뒤 더 갈 곳이 없으면 되돌아오는 탐색입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['알고리즘', '그래프'],
|
||||||
|
correctRate: 0.6,
|
||||||
|
choices: ['항상 최단 경로를 보장한다', '한 경로를 끝까지 탐색한 뒤 되돌아온다', '큐만 사용해야 한다', '정렬된 배열에서만 동작한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programming-script-11',
|
||||||
|
subjectId: 'programming',
|
||||||
|
setId: 'programming-script',
|
||||||
|
body: '해시 함수 품질이 낮을 때 생기기 쉬운 문제로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '충돌이 많아지면 평균 접근 성능이 떨어지고 해시 테이블 장점이 약해집니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['자료구조', '해시'],
|
||||||
|
correctRate: 0.44,
|
||||||
|
choices: ['정렬이 자동으로 보장된다', '메모리 사용량이 0이 된다', '재귀 호출이 불가능해진다', '충돌 증가로 탐색 성능이 저하된다'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'system-core-9',
|
||||||
|
subjectId: 'system',
|
||||||
|
setId: 'system-core',
|
||||||
|
body: '리스크 등록부(Risk Register)에 우선 포함해야 할 항목으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '위험 내용, 영향도, 대응 계획, 담당자 같은 정보가 있어야 관리가 가능합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['위험관리', '프로젝트관리'],
|
||||||
|
correctRate: 0.61,
|
||||||
|
choices: ['디자인 시안 배경색', '팀 점심 메뉴', '위험 항목과 대응 전략', '브라우저 북마크 목록'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system-core-10',
|
||||||
|
subjectId: 'system',
|
||||||
|
setId: 'system-core',
|
||||||
|
body: '변경 영향도 분석을 생략했을 때 발생하기 쉬운 문제는 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '연관 시스템과 운영 절차를 빠뜨리면 배포 후 예상치 못한 장애가 발생하기 쉽습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['변경관리', '운영'],
|
||||||
|
correctRate: 0.47,
|
||||||
|
choices: ['연쇄 영향 범위를 놓쳐 장애를 키울 수 있다', '정규화 단계가 자동 감소한다', '테스트 케이스가 자동 작성된다', '모든 승인 절차가 단축된다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system-security-9',
|
||||||
|
subjectId: 'system',
|
||||||
|
setId: 'system-security',
|
||||||
|
body: '다중 인증(MFA)의 직접적인 효과로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '비밀번호가 노출돼도 추가 인증 수단이 있어 계정 탈취 위험을 낮출 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['보안', '인증'],
|
||||||
|
correctRate: 0.67,
|
||||||
|
choices: ['세션 저장소를 없앤다', '단일 인증 정보 유출만으로는 로그인하기 어렵게 만든다', '암호화를 대체한다', '방화벽 설정을 불필요하게 만든다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system-security-10',
|
||||||
|
subjectId: 'system',
|
||||||
|
setId: 'system-security',
|
||||||
|
body: '입력값 검증이 특히 중요한 이유로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '입력 검증은 SQL Injection, XSS 같은 공격 표면을 줄이는 기본 통제입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['보안', '입력검증'],
|
||||||
|
correctRate: 0.64,
|
||||||
|
choices: ['서버 시간을 고정하려고', '디자인 토큰을 통일하려고', '문서 용량을 줄이려고', '악의적 입력으로 인한 취약점 노출을 막으려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system-infra-9',
|
||||||
|
subjectId: 'system',
|
||||||
|
setId: 'system-infra',
|
||||||
|
body: '관측 가능성(Observability)을 높이는 데 필요한 축으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '로그, 메트릭, 트레이스를 함께 봐야 분산 환경의 원인 분석이 쉬워집니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['인프라', '관측성'],
|
||||||
|
correctRate: 0.57,
|
||||||
|
choices: ['테마, 배경, 아이콘', '문서, 회의록, 공지', '로그, 메트릭, 트레이스', 'CPU, 키보드, 마우스'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system-infra-10',
|
||||||
|
subjectId: 'system',
|
||||||
|
setId: 'system-infra',
|
||||||
|
body: '무중단 배포를 설계할 때 세션 저장소 외부화가 중요한 이유는 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '인스턴스 교체 시 세션이 서버 로컬에 있으면 사용자 연결 상태가 쉽게 끊길 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['인프라', '배포'],
|
||||||
|
correctRate: 0.46,
|
||||||
|
choices: ['서버 교체 중에도 사용자 세션을 유지하기 쉬워진다', '정규화 단계를 줄일 수 있다', '모든 장애를 예방한다', '쿼리 튜닝이 자동 완료된다'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'web-layout-1',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-layout',
|
||||||
|
body: 'Flex 레이아웃에서 세로 축 정렬을 제어하는 속성으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '기본 방향이 row일 때 교차 축은 세로 방향이며 `align-items`가 이를 제어합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', 'Flexbox'],
|
||||||
|
correctRate: 0.66,
|
||||||
|
choices: ['justify-content', 'align-items', 'flex-basis', 'order'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-layout-2',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-layout',
|
||||||
|
body: 'CSS Grid에서 열 반복 구성을 정의할 때 가장 자주 사용하는 속성은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '`grid-template-columns`로 열 개수와 너비 패턴을 지정합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', 'Grid'],
|
||||||
|
correctRate: 0.62,
|
||||||
|
choices: ['grid-auto-flow', 'place-content', 'grid-template-columns', 'grid-column-end'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-layout-3',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-layout',
|
||||||
|
body: '모바일 카드 UI에서 긴 텍스트가 레이아웃을 깨지 않게 처리하는 방법으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '줄 수 제한이나 줄바꿈 정책을 명확히 둬야 작은 화면에서도 카드 높이 폭주를 막을 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', '모바일'],
|
||||||
|
correctRate: 0.64,
|
||||||
|
choices: ['모든 텍스트를 절대 위치로 바꾼다', '폰트 크기를 8px로 고정한다', '스크롤을 막는다', 'line-clamp 또는 적절한 word-break 규칙을 적용한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-layout-4',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-layout',
|
||||||
|
body: '`position: sticky`가 기대대로 동작하지 않는 대표 원인으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '상위 스크롤 컨테이너와 overflow 조건이 맞지 않으면 sticky 기준이 깨집니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['CSS', '레이아웃'],
|
||||||
|
correctRate: 0.45,
|
||||||
|
choices: ['부모의 overflow 조건 때문에 기준 스크롤 영역이 달라진다', 'HTML에 section 태그가 없기 때문이다', '이미지 개수가 많기 때문이다', 'font-weight가 bold이기 때문이다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-accessibility-1',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-accessibility',
|
||||||
|
body: '폼 입력과 라벨을 명시적으로 연결하는 가장 기본적인 방법은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '`label`의 `for`와 입력 요소의 `id`를 연결하면 스크린리더와 클릭 영역 모두 개선됩니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['HTML', '접근성'],
|
||||||
|
correctRate: 0.71,
|
||||||
|
choices: ['placeholder만 넣는다', '`label for`와 `input id`를 연결한다', 'div로만 감싼다', '색상 대비만 높인다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-accessibility-2',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-accessibility',
|
||||||
|
body: '장식용 이미지를 스크린리더가 무시하게 하는 방법으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '의미 없는 장식 이미지는 빈 대체 텍스트로 처리해 불필요한 읽기를 막습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['HTML', '접근성'],
|
||||||
|
correctRate: 0.67,
|
||||||
|
choices: ['title만 넣는다', 'role을 article로 준다', 'alt=\"\"로 둔다', 'font-size를 줄인다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-accessibility-3',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-accessibility',
|
||||||
|
body: '모달이 열렸을 때 키보드 포커스를 모달 내부에 가둬야 하는 이유는 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '배경 UI로 포커스가 빠지면 키보드 사용자와 보조기기 사용자의 맥락이 무너집니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['접근성', 'UI'],
|
||||||
|
correctRate: 0.48,
|
||||||
|
choices: ['애니메이션 속도를 올리려고', '스크롤을 완전히 막으려고', '이미지 로딩을 줄이려고', '활성 대화 상자의 상호작용 범위를 명확히 유지하려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-accessibility-4',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-accessibility',
|
||||||
|
body: '버튼처럼 동작하는 요소를 `div` 대신 `button`으로 만드는 주된 이유는 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '기본 키보드 동작, 포커스, 접근성 의미가 이미 제공되기 때문입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['HTML', '접근성'],
|
||||||
|
correctRate: 0.73,
|
||||||
|
choices: ['기본 상호작용 의미와 키보드 지원을 바로 얻을 수 있어서', 'CSS 파일 개수를 줄여서', '이미지 최적화를 위해서', '로컬스토리지를 쓰기 위해서'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-accessibility-5',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-accessibility',
|
||||||
|
body: '색상만으로 오류 상태를 표시하면 안 되는 이유로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '색각 이상 사용자나 보조기기 사용자에게 정보가 전달되지 않을 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['접근성', '폼'],
|
||||||
|
correctRate: 0.69,
|
||||||
|
choices: ['브라우저가 자동 종료되기 때문에', '색상을 구분하지 못하는 사용자에게 의미가 전달되지 않을 수 있어서', 'HTML 파서 오류가 발생해서', '네트워크 요청이 느려져서'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-responsive-1',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-responsive',
|
||||||
|
body: '반응형 웹에서 `meta viewport` 설정이 필요한 가장 직접적인 이유는 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '모바일 브라우저가 레이아웃 폭을 기기 너비 기준으로 해석하게 해야 의도한 반응형이 동작합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['HTML', '반응형'],
|
||||||
|
correctRate: 0.68,
|
||||||
|
choices: ['캐시를 비우기 위해', '애니메이션을 끄기 위해', '기기 너비 기준 초기 배율과 레이아웃 폭을 맞추기 위해', '쿠키를 막기 위해'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-responsive-2',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-responsive',
|
||||||
|
body: '작은 화면에서 카드가 너무 촘촘할 때 우선 검토할 CSS 전략으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '간격, 컬럼 수, 최소 너비를 브레이크포인트별로 조정해야 모바일 가독성이 확보됩니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', '반응형'],
|
||||||
|
correctRate: 0.61,
|
||||||
|
choices: ['모든 카드 높이를 고정한다', '이미지를 삭제한다', '폰트를 임의로 랜덤 변경한다', '브레이크포인트에서 gap과 컬럼 구성을 줄인다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-responsive-3',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-responsive',
|
||||||
|
body: '`clamp()` 함수를 타이포그래피에 사용하는 장점으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '최소값과 최대값을 두고 뷰포트에 따라 유연하게 크기를 조절할 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['CSS', '타이포그래피'],
|
||||||
|
correctRate: 0.47,
|
||||||
|
choices: ['너무 작거나 큰 값을 막으면서 유동 크기를 줄 수 있다', '이미지를 자동 압축한다', '스크린리더를 비활성화한다', 'DOM 트리를 줄인다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-responsive-4',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-responsive',
|
||||||
|
body: '모바일 브라우저에서 100vh 사용 시 생길 수 있는 대표 문제는 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '주소창 표시/숨김에 따라 실제 보이는 높이와 계산된 vh가 달라 하단 잘림이 생길 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['CSS', '모바일'],
|
||||||
|
correctRate: 0.44,
|
||||||
|
choices: ['글자 수가 줄어든다', '브라우저 UI 높이 변화로 화면이 잘릴 수 있다', '쿠키 저장이 안 된다', '이미지가 모두 고정된다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-performance-1',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-performance',
|
||||||
|
body: '웹 폰트 로딩으로 인한 렌더링 지연을 줄일 때 검토할 속성으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: '`font-display`를 적절히 설정하면 초기 텍스트 표시 전략을 제어할 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', '성능'],
|
||||||
|
correctRate: 0.6,
|
||||||
|
choices: ['object-fit', 'tabindex', 'font-display', 'z-index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-performance-2',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-performance',
|
||||||
|
body: '이미지 지연 로딩(lazy loading)의 직접적인 효과로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '초기 뷰포트 밖 이미지를 나중에 불러와 첫 화면 로딩 부담을 줄일 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['HTML', '성능'],
|
||||||
|
correctRate: 0.72,
|
||||||
|
choices: ['서버 시간을 고정한다', '모든 이미지를 더 크게 만든다', '스크린리더를 끈다', '초기 네트워크 요청량을 줄여 첫 렌더를 가볍게 한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-performance-3',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-performance',
|
||||||
|
body: 'CLS(Cumulative Layout Shift)를 줄이기 위한 방법으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '이미지와 광고 영역에 미리 크기를 확보해 두면 렌더 후 갑작스러운 밀림을 줄일 수 있습니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['성능', 'CLS'],
|
||||||
|
correctRate: 0.46,
|
||||||
|
choices: ['이미지와 동적 영역의 크기를 미리 예약한다', '애니메이션을 모두 제거한다', 'HTML 파일을 여러 개로 분리한다', '모든 버튼을 absolute로 둔다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-performance-4',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-performance',
|
||||||
|
body: '크리티컬 CSS를 인라인으로 우선 제공하는 목적은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '첫 화면 렌더에 필요한 최소 스타일을 먼저 적용해 초기 표시 속도를 높이기 위함입니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['CSS', '성능'],
|
||||||
|
correctRate: 0.43,
|
||||||
|
choices: ['자바스크립트를 금지하려고', '첫 화면 렌더에 필요한 스타일 적용을 앞당기려고', '모든 스타일 파일을 삭제하려고', '폰트 종류를 하나로 제한하려고'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-browser-1',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-browser',
|
||||||
|
body: '브라우저 기본 스타일 차이로 인한 오차를 줄이는 일반적인 접근으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '3',
|
||||||
|
explanation: 'reset 또는 normalize 스타일을 적용해 기본 마진과 요소 표현 차이를 줄입니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', '브라우저호환성'],
|
||||||
|
correctRate: 0.65,
|
||||||
|
choices: ['모든 태그를 span으로 바꾼다', '이미지 파일명을 짧게 만든다', 'reset/normalize 스타일을 적용한다', '스크롤을 비활성화한다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-browser-2',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-browser',
|
||||||
|
body: '특정 CSS 기능 사용 전 브라우저 지원 범위를 확인해야 하는 이유는 무엇인가요?',
|
||||||
|
answerValue: '4',
|
||||||
|
explanation: '지원하지 않는 환경에서는 레이아웃이 깨지거나 대체 스타일이 필요할 수 있습니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['CSS', '호환성'],
|
||||||
|
correctRate: 0.66,
|
||||||
|
choices: ['폰트 파일 수를 늘리려고', 'DOM 깊이를 줄이려고', '테스트 케이스를 삭제하려고', '일부 사용자 환경에서 기능이 동작하지 않을 수 있어서'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-browser-3',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-browser',
|
||||||
|
body: '입력 폼 자동완성 스타일이 브라우저별로 다를 때 우선 고려할 점으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '1',
|
||||||
|
explanation: '브라우저 기본 UI는 완전한 통제가 어려우므로 사용성 저하 없이 허용 범위를 정하는 접근이 필요합니다.',
|
||||||
|
difficulty: 'hard',
|
||||||
|
tags: ['HTML', '브라우저호환성'],
|
||||||
|
correctRate: 0.42,
|
||||||
|
choices: ['브라우저 기본 UI를 모두 제거하려 하기보다 허용 범위를 정한다', 'label 태그를 없앤다', 'submit 버튼을 숨긴다', '모든 입력을 textarea로 바꾼다'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-browser-4',
|
||||||
|
examId: 'web-general',
|
||||||
|
subjectId: 'html-css',
|
||||||
|
setId: 'web-browser',
|
||||||
|
body: '모바일 Safari에서 터치 영역이 작게 느껴질 때 우선 검토할 항목으로 가장 적절한 것은 무엇인가요?',
|
||||||
|
answerValue: '2',
|
||||||
|
explanation: '시각적 크기보다 실제 패딩과 line-height, 최소 터치 크기 확보가 중요합니다.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
tags: ['모바일', 'UX'],
|
||||||
|
correctRate: 0.7,
|
||||||
|
choices: ['배경색만 바꾼다', '버튼 패딩과 실제 클릭 영역 크기를 늘린다', '스크롤을 잠근다', '폰트를 모두 소문자로 바꾼다'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,57 +1,26 @@
|
|||||||
# Widgets Package Guide
|
# Widgets
|
||||||
|
|
||||||
`src/widgets`는 여러 공통 컴포넌트를 묶어 하나의 카드형 샘플 또는 기능 단위 블록으로 제공하는 패키지입니다. 위젯은 앱의 `APIs / Widgets` 영역과 샘플 레이아웃에서 직접 소비됩니다.
|
`src/widgets`는 공통 컴포넌트를 묶은 카드형 기능 블록입니다.
|
||||||
|
|
||||||
## 목적
|
## 구조
|
||||||
|
|
||||||
- 여러 공통 컴포넌트를 묶어 재사용 가능한 기능 블록을 제공합니다.
|
|
||||||
- 샘플, 문서, 기능 데모에서 같은 위젯 구성을 반복 사용합니다.
|
|
||||||
- 위젯 메타데이터와 표시 규칙을 registry 기반으로 일관되게 관리합니다.
|
|
||||||
|
|
||||||
## 공통 설계 원칙
|
|
||||||
|
|
||||||
- 샘플(`samples`)을 제외한 위젯에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
|
|
||||||
- 위젯 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
|
|
||||||
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
|
||||||
- 공통 위젯은 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
|
|
||||||
|
|
||||||
## 현재 하위 구조
|
|
||||||
|
|
||||||
- `core`: 위젯 공통 셸, feature registry, 타입
|
|
||||||
- `ag-grid-widget`
|
|
||||||
- `api-sample-card`
|
|
||||||
- `dashboard-report-card`
|
|
||||||
- `gps-sample-card`
|
|
||||||
- `text-memo-widget`
|
|
||||||
- `registry.ts`: 위젯 목록과 메타데이터 등록점
|
|
||||||
|
|
||||||
## 폴더 구성 규약
|
|
||||||
|
|
||||||
위젯 패키지는 가능하면 아래 구조를 따릅니다.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
widget-name/
|
src/widgets
|
||||||
├─ WidgetName.tsx
|
├─ core
|
||||||
├─ WidgetName.css
|
├─ ag-grid-widget
|
||||||
└─ index.ts
|
├─ api-sample-card
|
||||||
|
├─ dashboard-report-card
|
||||||
|
├─ gps-sample-card
|
||||||
|
├─ text-memo-widget
|
||||||
|
└─ registry.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- 위젯 진입점은 각 폴더의 `index.ts`입니다.
|
- `core`: 공통 셸, 타입, registry 보조 코드
|
||||||
- 카드형 레이아웃이 필요하면 `WidgetShell`을 우선 사용합니다.
|
- 각 위젯 폴더: 실제 위젯 구현
|
||||||
- 위젯 관련 공통 타입과 feature 정의는 `src/widgets/core`에 둡니다.
|
- `registry.ts`: 위젯 메타데이터 등록점
|
||||||
- 위젯 메타데이터는 `src/widgets/registry.ts`에 등록합니다.
|
|
||||||
|
|
||||||
## 구현 규약
|
## 기준
|
||||||
|
|
||||||
- 위젯 ID는 `registry.ts`의 `id`와 컴포넌트 폴더명을 동일하게 맞춥니다.
|
- 위젯은 카드형 조합 단위로 유지합니다.
|
||||||
- 위젯 제목과 설명은 registry를 단일 기준으로 관리하고, 화면 문자열을 위젯 내부에 중복 선언하지 않습니다.
|
- 제목, 설명, 기능 태그는 registry를 단일 기준으로 관리합니다.
|
||||||
- 기능 태그는 `WidgetFeatureKey` 범위 안에서만 사용하고, 새 태그가 필요하면 `core/types/widget.ts`와 `core/registry/widget-features.ts`를 함께 수정합니다.
|
- 프로젝트 전용 로직이 강해지면 `src/features`로 이동을 우선 검토합니다.
|
||||||
- 스크롤 이동이나 포커스 제어가 필요한 위젯은 `WidgetHandle` 계약을 따릅니다.
|
|
||||||
- 위젯이 공통 컴포넌트를 조합해도 프로젝트 전용 비즈니스 로직이 강하면 `src/features`로 이동할지 먼저 검토합니다.
|
|
||||||
|
|
||||||
## 문서 및 샘플 규약
|
|
||||||
|
|
||||||
- 위젯은 문서보다 동작 예제가 중요하므로 registry 설명과 샘플 화면에서 바로 이해될 수 있게 유지합니다.
|
|
||||||
- 컴포넌트 문서와 직접 연결되는 위젯은 `features`에 `docs` 또는 `component-sample` 태그를 넣어 의도를 드러냅니다.
|
|
||||||
- API 연동 위젯은 데이터 소스, 실패 상태, 저장 동작을 설명하는 문서를 기능 문서 또는 관련 컴포넌트 문서에 남깁니다.
|
|
||||||
- 위젯 구조나 공통 계약이 바뀌면 이 문서와 `registry.ts` 설명을 함께 갱신합니다.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user