feat: refine codex live chat context flows

This commit is contained in:
2026-05-08 21:15:51 +09:00
parent 82c0d8a197
commit 442879313f
92 changed files with 14815 additions and 7314 deletions

View File

@@ -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
src/components
├─ markdownPreview
├─ navigation
├─ previewer
├─ search
├─ status-badge
└─ window
src/
docs/
etc/
public/
scripts/
```
공통 플러그인 타입과 유틸은 `src/types/component-plugin.ts` 에서 관리합니다.
- `src`: 메인 프런트엔드 소스
- `docs`: 작업 템플릿과 작업일지 같은 보조 문서
- `etc`: work-server, DB, 운영 보조 리소스
- `public`: 정적 파일과 채팅 세션 리소스
- `scripts`: 개발/운영 스크립트
패키지 기준 안내 문서:
## 프런트엔드 구조
- `src/components/README.md`: 공통 컴포넌트 패키지 목적, 구조, export 규약
- `src/widgets/README.md`: 공통 위젯 패키지 목적, registry, feature 규약
```text
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. 프로젝트 종속 레이아웃
- 위치: `src/features/layout`
- 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃
- 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, 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` 참고
-`Docs` 메뉴는 구조 확인용 문서만 노출합니다.
- 작업일지, 템플릿, 과거 설계 메모는 저장소에 남길 수 있어도 기본 문서 목록에서는 제외합니다.
- 채팅 유형 context와 자동화 유형 context는 공용 문서가 아니라 각 관리 데이터에서 직접 관리합니다.

View File

@@ -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, 적용 위치, 확장 포인트를 함께 기록

View File

@@ -1 +0,0 @@
테스트MD자동 생성 입니다.

View File

@@ -1,8 +1,8 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
getAppConfig,
getChatContextSettingsConfig,
getAppConfigSnapshot,
getChatTypesConfig,
normalizeAppConfigSnapshot,
upsertAppConfig,
@@ -52,20 +52,20 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async (request) => {
const appOrigin = getRequestAppOrigin(request);
const config = await getAppConfig(appOrigin);
const config = await getAppConfigSnapshot(appOrigin);
return {
ok: true,
config: normalizeAppConfigSnapshot(config),
config,
};
});
app.get('/api/chat-types', async (request) => {
const chatTypes = await getChatTypesConfig(getRequestAppOrigin(request));
const chatTypeConfig = await getChatTypesConfig(getRequestAppOrigin(request));
return {
ok: true,
chatTypes,
...chatTypeConfig,
};
});
@@ -108,17 +108,21 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
}
const parsed = z.object({
chatTypes: z.array(z.unknown()),
}).parse(payload ?? {});
const parsed = z
.object({
chatTypes: z.array(z.unknown()).optional(),
customChatTypes: z.array(z.unknown()).optional(),
})
.parse(payload ?? {});
const appOrigin = getRequestAppOrigin(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 {
ok: true,
chatTypes: savedChatTypes,
...savedChatTypeConfig,
};
} catch (error) {
return reply.code(409).send({

View 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');
});

View File

@@ -10,6 +10,7 @@ import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRunt
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
clearChatConversationData,
createChatConversation,
deleteUnansweredChatConversationRequest,
deleteChatConversation,
@@ -22,13 +23,14 @@ import {
updateChatConversationContext,
} from '../services/chat-room-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_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
function resolveStaticContentType(filePath: string) {
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
@@ -40,10 +42,12 @@ function resolveStaticContentType(filePath: string) {
case '.cjs':
case '.json':
case '.css':
case '.html':
case '.txt':
case '.diff':
return 'text/plain; charset=utf-8';
case '.html':
case '.htm':
return 'text/html; charset=utf-8';
case '.md':
case '.markdown':
return 'text/markdown; charset=utf-8';
@@ -139,7 +143,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
}
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> }) {
@@ -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, {
limit: messageLimit,
beforeMessageId: query.beforeMessageId ?? null,
@@ -562,4 +566,34 @@ export async function registerChatRoutes(app: FastifyInstance) {
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,
};
});
}

View File

@@ -6,6 +6,7 @@ import {
cancelServerRestartReservation,
confirmServerRestartReservation,
getRestartReservationWorkloadSummary,
requestImmediateRestartRecovery,
getServerRestartReservation,
scheduleServerRestartReservation,
} 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 {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
};
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
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) => {

View File

@@ -1,6 +1,14 @@
import test from 'node:test';
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', () => {
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 === 'api-request-template'));
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'));
});
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', () => {
const resolved = resolveAppConfigByOrigin(
{
@@ -112,3 +185,149 @@ test('resolveAppConfigByOrigin falls back to legacy global config when scoped co
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',
},
});
});

View File

@@ -1,5 +1,10 @@
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';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
@@ -25,6 +30,14 @@ type ChatTypeRecord = {
updatedAt: string;
};
const LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID = 'plan-checklist-execution';
export type ChatTypesConfigSnapshot = {
builtInChatTypes: ChatTypeRecord[];
customChatTypes: ChatTypeRecord[];
chatTypes: ChatTypeRecord[];
};
type ChatDefaultContextRecord = {
id: string;
title: string;
@@ -53,25 +66,6 @@ export type ChatContextSettingsSnapshot = {
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() {
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]);
}
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) {
const normalizedAppOrigin = normalizeAppOrigin(appOrigin);
@@ -229,6 +299,26 @@ export async function getAppConfig(appOrigin?: string | null) {
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) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
@@ -299,7 +389,7 @@ function sanitizeDefaultContexts(items: unknown) {
const byId = new Map<string, ChatDefaultContextRecord>();
const sourceItems = Array.isArray(items) ? items : [];
[...sourceItems, ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
sourceItems
.map((item) => normalizeDefaultContextRecord(item))
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
.forEach((item) => {
@@ -420,6 +510,14 @@ function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
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 }) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
@@ -473,6 +571,56 @@ export function mergeDefaultChatTypes(items: unknown[]) {
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[]) {
if (left.length !== right.length) {
return false;
@@ -585,7 +733,18 @@ export function normalizeAppConfigSnapshot(value: unknown): 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(
@@ -626,42 +785,83 @@ export async function upsertAppConfig(
return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin);
}
export async function getChatTypesConfig(appOrigin?: string | null) {
const config = await getAppConfig(appOrigin);
const normalized = normalizeConfigRecord(config);
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
if (chatTypes == null) {
return null;
}
export async function getChatTypesConfig(appOrigin?: string | null): Promise<ChatTypesConfigSnapshot> {
const rawConfig = await getRawAppConfigRecord();
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
const builtInChatTypes = sanitizeChatTypes(DEFAULT_CHAT_TYPES);
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
const customChatTypes = stripBuiltInChatTypes(migratedChatTypeList);
const mergedChatTypes = mergeDefaultChatTypes(customChatTypes);
const migratedSettings = migrateLegacyChatTypeContexts(
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
canonicalChatTypes ?? [],
);
const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
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({
[CHAT_TYPES_CONFIG_KEY]: mergedChatTypes,
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
}, 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) {
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
const customChatTypes = stripBuiltInChatTypes(chatTypes);
const nextConfig = {
...current,
[CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
};
await upsertAppConfig(nextConfig, appOrigin, appDomain);
return resolvedChatTypes;
return {
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes,
chatTypes: mergeDefaultChatTypes(customChatTypes),
};
}
export async function getChatContextSettingsConfig(appOrigin?: string | null) {
const config = await getAppConfig(appOrigin);
const normalized = normalizeConfigRecord(config);
return sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
const rawConfig = await getRawAppConfigRecord();
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin) ?? [];
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(
@@ -669,13 +869,17 @@ export async function upsertChatContextSettingsConfig(
appOrigin?: string | null,
appDomain?: string | null,
) {
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
const current = await getRawAppConfigRecord();
const nextSettings = sanitizeChatContextSettings(settings);
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(current);
const nextConfig = {
...current,
[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;
}

View File

@@ -4,12 +4,79 @@ export type ChatMessagePart =
title: string;
url: string;
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 PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
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) {
return String(value ?? '').trim();
@@ -27,6 +94,25 @@ function normalizeUrl(value: string) {
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)) {
return normalized;
}
@@ -34,6 +120,114 @@ function normalizeUrl(value: string) {
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) {
try {
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) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
@@ -151,7 +405,38 @@ export function extractChatMessageParts(text: string) {
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)) {
return true;
@@ -163,6 +448,15 @@ export function extractChatMessageParts(text: string) {
};
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);
if (!matched) {
@@ -196,7 +490,7 @@ export function extractChatMessageParts(text: string) {
}
const latestPart = parts.at(-1);
if (latestPart && isInternalResourceUrl(latestPart.url)) {
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
parts.pop();
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
keptLines.push(latestPart.url);
@@ -222,24 +516,29 @@ export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
}
const record = item as Record<string, unknown>;
if (record.type !== 'link_card') {
return null;
if (record.type === 'link_card') {
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);
const url = normalizeUrl(String(record.url ?? ''));
const actionLabel = normalizeText(record.actionLabel) || null;
if (!title || !url) {
return null;
if (record.type === 'prompt') {
const promptPart = buildPromptPart(JSON.stringify(record));
return promptPart;
}
return {
type: 'link_card' as const,
title,
url,
actionLabel,
};
return null;
})
.filter(Boolean) as ChatMessagePart[];
}

View File

@@ -50,6 +50,8 @@ export type ChatConversationItem = {
currentJobMessage: string | null;
currentQueueSize: number;
currentStatusUpdatedAt: string | null;
isPendingWork: boolean;
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
lastRequestPreview: string;
lastMessagePreview: string;
lastResponsePreview: string;
@@ -173,6 +175,160 @@ function createPreview(text: string) {
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 = [
/\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),
currentQueueSize: Number(row.current_queue_size ?? 0),
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
isPendingWork: false,
pendingWorkReason: null,
lastRequestPreview: '',
lastMessagePreview: String(row.last_message_preview ?? ''),
lastResponsePreview: '',
@@ -876,6 +1034,40 @@ async function getLatestResponseMessageIdMap(sessionIds: string[]) {
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) {
const responseMap = await getLatestResponseMessageIdMap([sessionId]);
return responseMap.get(sessionId.trim()) ?? null;
@@ -1444,17 +1636,26 @@ export async function listChatConversations(
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
rows.map((row) => String(row.session_id ?? '')),
);
const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap(
rows.map((row) => String(row.session_id ?? '')),
);
if (!normalizedUnreadStateClientId) {
return rows
.map((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 {
...resolveConversationPreviewOverride(
mapped,
latestPreviewMessageMap.get(mapped.sessionId),
latestRequestPreviewMap.get(mapped.sessionId),
),
...pendingWorkState,
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
hasUnreadResponse: false,
@@ -1489,6 +1690,11 @@ export async function listChatConversations(
const mapped = mapConversationRow(row);
const preference = preferenceMap.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 {
...resolveConversationPreviewOverride(
@@ -1496,6 +1702,7 @@ export async function listChatConversations(
latestPreviewMessage,
latestRequestPreviewMap.get(mapped.sessionId),
),
...pendingWorkState,
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
clientId: normalizedUnreadStateClientId,
@@ -1654,7 +1861,7 @@ export async function listChatConversationDetailPage(
): Promise<ChatConversationDetailPage> {
const normalizedSessionId = sessionId.trim();
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 =
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
? 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) {
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
.where({

View File

@@ -149,13 +149,17 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview: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, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
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 resourcePath = await ensureChatSessionReferenceResource({
@@ -182,13 +186,9 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
const firstContent = await readFile(absolutePath, 'utf8');
assert.match(firstContent, /# 채팅방 참고 리소스/);
assert.match(firstContent, /## 자동 갱신 문맥/);
assert.match(firstContent, /## 수동 메모/);
const manuallyEditedContent = firstContent.replace(
'- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.',
'- 유지 메모: 이 줄은 보존되어야 합니다.',
);
await writeFile(absolutePath, manuallyEditedContent, 'utf8');
assert.doesNotMatch(firstContent, /## 수동 메모/);
assert.doesNotMatch(firstContent, /## 최신 사용자 요청/);
assert.doesNotMatch(firstContent, /## 최근 대화 요약/);
await ensureChatSessionReferenceResource({
repoPath: tempDir,
@@ -210,9 +210,8 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
const updatedContent = await readFile(absolutePath, 'utf8');
assert.match(updatedContent, /request-2/);
assert.match(updatedContent, /둘째 요청/);
assert.match(updatedContent, /이전 1개 메시지는 제외되었습니다\./);
assert.match(updatedContent, /유지 메모: 이 줄은 보존되어야 합니다\./);
assert.doesNotMatch(updatedContent, /둘째 요청/);
assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/);
});
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: { ... }) {",
'<!-- codex-live:auto:end -->',
'',
'## 수동 메모',
'- 유지 메모',
'',
].join('\n'),
'utf8',
);
@@ -277,8 +273,8 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
const rebuiltContent = await readFile(absolutePath, 'utf8');
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/g) ?? []).length, 1);
assert.match(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', () => {
assert.deepEqual(
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', () => {
assert.deepEqual(
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),

View File

@@ -27,6 +27,7 @@ import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
findLatestPlanItem,
findPlanItemByPreviewUrl,
@@ -329,6 +330,26 @@ function createChatQuestionAnswerNotificationBody(args: {
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) {
const questionPreview = createChatNotificationPreview(questionText ?? '');
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
@@ -1584,9 +1605,6 @@ function buildChatSessionReferenceAutoSection(args: {
context: ChatContext | null;
sessionId: string;
requestId: string;
input: string;
recentHistoryLines: string[];
omittedHistoryCount: number;
}) {
const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청';
const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음';
@@ -1594,14 +1612,6 @@ function buildChatSessionReferenceAutoSection(args: {
const topMenu = args.context?.topMenu?.trim() || '없음';
const pageUrl = args.context?.pageUrl?.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 [
CHAT_SESSION_REFERENCE_AUTO_START,
@@ -1617,12 +1627,6 @@ function buildChatSessionReferenceAutoSection(args: {
'',
'## 현재 채팅 유형 context',
chatTypeDescription,
'',
'## 최신 사용자 요청',
args.input.trim() || '없음',
'',
'## 최근 대화 요약',
...historyLines,
CHAT_SESSION_REFERENCE_AUTO_END,
].join('\n');
}
@@ -1635,30 +1639,19 @@ function mergeChatSessionReferenceContent(existingContent: string, autoSection:
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
].join('\n');
const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n');
if (!trimmedExisting) {
return [
defaultHeader,
'',
autoSection,
'',
defaultManualSection,
'',
].join('\n');
return `${defaultHeader}\n\n${autoSection}\n`;
}
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) {
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 `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
}
export async function ensureChatSessionReferenceResource(args: {
@@ -1677,9 +1670,6 @@ export async function ensureChatSessionReferenceResource(args: {
context: args.context,
sessionId: args.sessionId,
requestId: args.requestId,
input: args.input,
recentHistoryLines: args.recentHistoryLines,
omittedHistoryCount: args.omittedHistoryCount,
});
let existingContent = '';
@@ -1765,7 +1755,7 @@ export function buildAgenticCodexPrompt(
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
'- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.',
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
...buildChatTypeInstructionBlock(context),
'',
@@ -1776,6 +1766,7 @@ export function buildAgenticCodexPrompt(
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[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 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
@@ -1947,7 +1938,7 @@ async function runAgenticCodexReply(
onActivity?: (line: string) => void,
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);
const appConfig = await getAppConfigSnapshot();
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
@@ -2856,19 +2847,33 @@ export class ChatService {
},
) {
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);
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope');
if (message.type === 'chat:message') {
this.persistConversationMessage(session, message.payload);
if (normalizedMessage.type === 'chat:message') {
this.persistConversationMessage(session, normalizedMessage.payload);
if (message.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationIfNeeded(session, message.payload).catch((error: unknown) => {
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationIfNeeded(session, normalizedMessage.payload).catch((error: unknown) => {
this.logger.error(error, 'failed to send offline chat notification');
});
}
@@ -2878,9 +2883,10 @@ export class ChatService {
}
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
const normalizedMessage = normalizeStructuredChatMessage(message);
const envelope = this.createSessionEnvelope(session, {
type: 'chat:message:update',
payload: message,
payload: normalizedMessage,
});
this.retainEnvelopeForReplay(session, envelope);
@@ -2889,8 +2895,8 @@ export class ChatService {
// 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
// can keep a finished request looking "running" until every intermediate update flushes.
if (shouldPersistMessageUpdate(message)) {
this.persistConversationMessage(session, message);
if (shouldPersistMessageUpdate(normalizedMessage)) {
this.persistConversationMessage(session, normalizedMessage);
}
return envelope;
@@ -3465,6 +3471,26 @@ export class ChatService {
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) {
try {
const message = JSON.parse(raw.toString()) as ChatInboundMessage;

View File

@@ -1,14 +1,31 @@
"use strict";
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 = [
{
id: 'general-request',
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'],
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',

View File

@@ -7,15 +7,38 @@ export type DefaultChatTypeRecord = {
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[] = [
{
id: 'general-request',
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에서는 별도 요청이 없는 한 참조하지 마세요.',
'## 기본 처리\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'],
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',

View File

@@ -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];
}

View File

@@ -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');
});

View File

@@ -86,7 +86,7 @@ const TEXT_FILE_EXTENSIONS = new Set([
'.diff',
]);
function resolveStaticContentType(filePath: string) {
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
@@ -98,7 +98,6 @@ function resolveStaticContentType(filePath: string) {
case '.cjs':
case '.json':
case '.css':
case '.html':
case '.txt':
case '.diff':
case '.log':
@@ -107,6 +106,9 @@ function resolveStaticContentType(filePath: string) {
case '.yml':
case '.xml':
return 'text/plain; charset=utf-8';
case '.html':
case '.htm':
return 'text/html; charset=utf-8';
case '.md':
case '.markdown':
return 'text/markdown; charset=utf-8';

View File

@@ -5,6 +5,7 @@ import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { env } from '../config/env.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
getRuntimeWorkServerBuildInfo,
readLatestWorkServerBuildInfo,
@@ -243,7 +244,7 @@ async function findLatestSourceChangeInPath(rootPath: string, targetPath: string
}
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;
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
@@ -575,7 +576,7 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
function getServerDefinitions(): ServerDefinition[] {
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
const mainProjectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT);
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
return [
{

View File

@@ -1,14 +1,18 @@
import type { FastifyBaseLogger } from 'fastify';
import path from 'node:path';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
import { getAppConfigSnapshot } from './app-config-service.js';
import { listBoardPosts, type BoardPostItem, type BoardPostRequestItem } from './board-service.js';
import { getActiveChatService } from './chat-service.js';
import { chatRuntimeService, type ChatRuntimeJobItem } from './chat-runtime-service.js';
import { createNotificationMessage, deleteOlderNotificationMessagesBySource } from './notification-message-service.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
listServerCommands,
restartServerCommand,
type ServerCommandSnapshot,
type ServerCommandKey,
} from './server-command-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 RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
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 RestartReservationTarget = 'all';
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'recovering' | 'completed' | 'cancelled' | 'failed';
type RestartReservationTarget = 'all' | 'test' | 'work-server';
type RestartReservationWorkloadSummary = {
codexRunningCount: number;
@@ -30,6 +37,31 @@ type RestartReservationWorkloadSummary = {
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 = {
id: number;
enabled: boolean;
@@ -50,6 +82,7 @@ type RestartReservationRow = {
auto_execute_at: string | null;
auto_execute_delay_seconds: number | null;
updated_at: string | null;
auto_fix_json: RestartReservationAutoFix | string | null;
};
export type ServerRestartReservationSnapshot = {
@@ -72,6 +105,8 @@ export type ServerRestartReservationSnapshot = {
autoExecuteAt: string | null;
autoExecuteDelaySeconds: number;
updatedAt: string | null;
workItems: RestartReservationWorkItem[];
autoFix: RestartReservationAutoFix;
};
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'>) {
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();
}
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;
let workloadSummary = getDefaultWorkloadSummary();
@@ -193,6 +285,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
};
}
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
return {
enabled: Boolean(row?.enabled),
target: row?.target === 'all' ? 'all' : 'all',
@@ -213,6 +307,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
autoExecuteAt: row?.auto_execute_at ?? null,
autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)),
updatedAt: row?.updated_at ?? null,
workItems: options?.workItems ?? [],
autoFix,
};
}
@@ -239,6 +335,7 @@ async function ensureServerRestartReservationTable() {
table.string('app_origin', 255).nullable();
table.timestamp('auto_execute_at', { useTz: true }).nullable();
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());
});
}
@@ -247,6 +344,7 @@ async function ensureServerRestartReservationTable() {
['app_origin', (table) => table.string('app_origin', 255).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_fix_json', (table) => table.jsonb('auto_fix_json').notNullable().defaultTo('{}')],
];
for (const [columnName, addColumn] of requiredColumns) {
@@ -268,6 +366,7 @@ async function ensureServerRestartReservationTable() {
status: 'idle',
workload_summary_json: getDefaultWorkloadSummary(),
active_client_count: 0,
auto_fix_json: getDefaultAutoFixState(),
updated_at: db.fn.now(),
});
}
@@ -295,6 +394,115 @@ async function countPendingAutomationWork() {
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) {
const reasons: string[] = [];
@@ -312,6 +520,224 @@ function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
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() {
await ensureVisitorHistoryTables();
const visitors = await listVisitorClients(50);
@@ -452,6 +878,129 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
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) {
const activeClients = await listActiveClients();
await updateReservationRow({
@@ -494,7 +1043,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
'Executing reserved restart',
);
await restartServerCommand('test');
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
await updateReservationRow({
@@ -504,11 +1053,21 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
last_checked_at: db.fn.now(),
});
await restartServerCommand('work-server');
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
}
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?: {
@@ -535,6 +1094,7 @@ export async function scheduleServerRestartReservation(options?: {
app_origin: options?.appOrigin?.trim() || null,
auto_execute_at: null,
auto_execute_delay_seconds: autoExecuteDelaySeconds,
auto_fix_json: getDefaultAutoFixState(),
});
return mapReservationRow(row);
@@ -550,6 +1110,7 @@ export async function cancelServerRestartReservation() {
active_client_count: 0,
last_error: null,
auto_execute_at: null,
auto_fix_json: getDefaultAutoFixState(),
});
return mapReservationRow(row);
@@ -580,6 +1141,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.',
last_error: null,
auto_execute_at: null,
auto_fix_json: getDefaultAutoFixState(),
});
if (!nextRow) {
@@ -638,6 +1200,10 @@ export class ServerRestartReservationWorker {
return;
}
if (row.status === 'recovering') {
return;
}
if (row.status === 'executing' && row.started_at) {
await finalizeReservedRestart(row);
return;

File diff suppressed because it is too large Load Diff

View File

@@ -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[] = [
{
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,
minVolumeIncreasePercent: 300,
@@ -315,7 +368,8 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
currentVolume: 400000,
previousVolume: 100000,
volumeIncreasePercent: 300,
volumeAmplificationPercent: 300,
recentMaxVolumeIncreasePercent: 60,
volumeAmplificationGrowthPercent: 400,
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,
minVolumeIncreasePercent: 50,
@@ -373,13 +446,14 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
currentVolume: 350000,
previousVolume: 200000,
volumeIncreasePercent: 75,
volumeAmplificationPercent: 50,
recentMaxVolumeIncreasePercent: 50,
volumeAmplificationGrowthPercent: 50,
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[] = [
{
id: '290550',
@@ -411,24 +485,35 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day
},
];
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(items, new Map(), {
thresholdPercent: 3,
minVolumeIncreasePercent: 300,
});
assert.deepEqual(candidates, [
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(
items,
new Map(),
new Map([
[
'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',
stockName: '디케이티',
currentPrice: 26500,
changeRate: 11.11,
currentVolume: 400000,
previousVolume: 100000,
volumeIncreasePercent: 300,
volumeAmplificationPercent: 300,
quotedAt: '2026-05-06T00:30:00.000Z',
thresholdPercent: 3,
minVolumeIncreasePercent: 300,
},
]);
);
assert.deepEqual(candidates, []);
});
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {

View File

@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
export const STOCK_ALERT_TABLE = 'stock_alerts';
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_TYPE_OPTIONS = [
@@ -73,6 +74,32 @@ export type StockAlertVolumeSnapshot = {
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 = {
stockCode: string;
stockName: string;
@@ -81,7 +108,8 @@ export type StockAlertVolumeSpikeCandidate = {
currentVolume: number | null;
previousVolume: number | null;
volumeIncreasePercent: number | null;
volumeAmplificationPercent: number | null;
recentMaxVolumeIncreasePercent: number | null;
volumeAmplificationGrowthPercent: number | null;
quotedAt: string | null;
};
@@ -345,50 +373,17 @@ function calculateVolumeIncreasePercent(currentVolume: number | null, previousVo
return ((currentVolume - previousVolume) / previousVolume) * 100;
}
function calculateVolumeAmplificationPercent(
currentVolume: number | null,
previousSnapshot: StockAlertVolumeSnapshot | null,
fallbackPercent: number | null,
) {
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
const previousCurrentVolume = normalizeNonNegativeVolume(previousSnapshot?.currentVolume);
const previousBaselineVolume = normalizeNonNegativeVolume(previousSnapshot?.previousVolume);
function calculateVolumeAmplificationGrowthPercent(currentIncreasePercent: number | null, recentMaxIncreasePercent: number | null) {
if (
normalizedCurrentVolume === null
|| previousCurrentVolume === null
|| previousBaselineVolume === null
|| previousCurrentVolume <= previousBaselineVolume
|| normalizedCurrentVolume < previousCurrentVolume
!isFiniteNumber(currentIncreasePercent)
|| !isFiniteNumber(recentMaxIncreasePercent)
|| recentMaxIncreasePercent <= 0
|| currentIncreasePercent < recentMaxIncreasePercent
) {
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;
}
const baseline = currentVolume / (volumeRate5d / 100);
if (!Number.isFinite(baseline) || baseline <= 0) {
return null;
}
return Math.max(1, Math.round(baseline));
return ((currentIncreasePercent - recentMaxIncreasePercent) / recentMaxIncreasePercent) * 100;
}
function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) {
@@ -398,7 +393,7 @@ function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot:
return snapshotBaseline;
}
return deriveVolumeBaselineFromRate5d(item);
return null;
}
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(
item: StockAlertItem,
currentVolume: number | null,
@@ -451,6 +461,30 @@ function buildStockAlertVolumeSnapshotRecord(
} 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) {
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) {
const response = await fetch(url, {
...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(
items: StockAlertItem[],
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(
items: StockAlertItem[],
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
recentHistories: Map<string, StockAlertVolumeHistory[]>,
options: {
thresholdPercent: number;
minVolumeIncreasePercent: number;
@@ -1541,16 +1650,23 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null);
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume);
const volumeAmplificationPercent = calculateVolumeAmplificationPercent(
currentVolume,
previousSnapshot ?? null,
const recentMaxVolumeIncreasePercent = Math.max(
...((recentHistories.get(item.stockCode) ?? [])
.map((history) => history.volumeIncreasePercent)
.filter((value): value is number => isFiniteNumber(value))),
);
const normalizedRecentMaxVolumeIncreasePercent = Number.isFinite(recentMaxVolumeIncreasePercent)
? recentMaxVolumeIncreasePercent
: null;
const volumeAmplificationGrowthPercent = calculateVolumeAmplificationGrowthPercent(
volumeIncreasePercent,
normalizedRecentMaxVolumeIncreasePercent,
);
if (
volumeAmplificationPercent === null ||
volumeAmplificationGrowthPercent === null ||
Math.abs(item.changeRate ?? 0) < options.thresholdPercent ||
volumeAmplificationPercent < options.minVolumeIncreasePercent
volumeAmplificationGrowthPercent < options.minVolumeIncreasePercent
) {
return [];
}
@@ -1564,7 +1680,8 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
currentVolume,
previousVolume,
volumeIncreasePercent,
volumeAmplificationPercent,
recentMaxVolumeIncreasePercent: normalizedRecentMaxVolumeIncreasePercent,
volumeAmplificationGrowthPercent,
quotedAt: item.quotedAt,
} satisfies StockAlertVolumeSpikeCandidate,
];
@@ -1576,7 +1693,7 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
return changeRateGap;
}
const volumeGap = (right.volumeAmplificationPercent ?? 0) - (left.volumeAmplificationPercent ?? 0);
const volumeGap = (right.volumeAmplificationGrowthPercent ?? 0) - (left.volumeAmplificationGrowthPercent ?? 0);
if (volumeGap !== 0) {
return volumeGap;
@@ -1589,12 +1706,13 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
export function buildChangeRateAndVolumeSpikeStockAlertLines(
items: StockAlertItem[],
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
recentHistories: Map<string, StockAlertVolumeHistory[]>,
options: {
thresholdPercent: 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)}`,
);
}
@@ -1664,12 +1782,14 @@ export async function sendManagedStockAlertWebPush(options: {
const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all');
const previousSnapshots =
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 =
options.mode === 'price'
? buildCurrentPriceStockAlertLines(items)
: options.mode === 'change-threshold'
? buildChangeRateThresholdStockAlertLines(items, thresholdPercent)
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, {
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, {
thresholdPercent,
minVolumeIncreasePercent,
});
@@ -1681,6 +1801,12 @@ export async function sendManagedStockAlertWebPush(options: {
const previousSnapshot = previousSnapshots.get(item.stockCode);
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 =
options.mode === 'price'
? hasRegisteredTargets
@@ -1692,13 +1818,16 @@ export async function sendManagedStockAlertWebPush(options: {
: '등록된 종목이 없습니다.'
: !hasRegisteredTargets
? '등록된 종목이 없습니다.'
: !hasComparableVolumeBaseline
? '이전 거래량 또는 5영업일 평균 거래량 비교 기준이 없어 스냅샷만 갱신했습니다.'
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 직전 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
: !hasComparableVolumeBaseline
? '이전 배치 실행 row 기준 거래량이 없어 스냅샷만 갱신했습니다.'
: !hasRecentVolumeHistory
? '배치 실행 거래량 히스토리 row 5건 비교 기준이 없어 히스토리만 적재했습니다.'
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 최근 배치 실행 row 5건 최대값 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
const skippedResult = createSkippedNotificationResult(skippedReason);
if (options.mode === 'change-threshold-volume-spike') {
await upsertStockAlertVolumeSnapshots(items, previousSnapshots);
await insertStockAlertVolumeHistories(items, previousSnapshots);
}
if (!lines.length) {
@@ -1810,7 +1939,7 @@ export async function updateStockAlertLayoutFeatureDescription() {
'알림유형의 경우 멀티선택 가능하게 해주세요.',
].join('\n');
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) {
changed = true;

View File

@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from '../config/env.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
export type WorkServerBuildInfo = {
version: string;
@@ -26,7 +27,7 @@ function normalizeRootPath(value: string | null | undefined) {
function resolveSourceTargetRoots() {
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) {
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 configuredDistDir = String(options?.configuredDistDir ?? env.WORK_SERVER_DIST_DIR ?? 'dist').trim() || 'dist';
const mainProjectRoot = normalizeRootPath(
options?.mainProjectRoot ?? env.SERVER_COMMAND_MAIN_PROJECT_ROOT ?? env.SERVER_COMMAND_PROJECT_ROOT,
options?.mainProjectRoot ?? resolveMainProjectRoot(),
);
const candidates = [
path.join(resolveBuildInfoDirectoryPath(workServerRootPath, configuredDistDir), 'build-info.json'),

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@
<!DOCTYPE html><body dir="ltr"><span style="caret-color: rgb(24, 34, 48); color: rgb(24, 34, 48); font-family: &quot;SUIT Variable&quot;, &quot;Pretendard Variable&quot;, 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>

View File

@@ -1 +0,0 @@
/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f

View File

@@ -1 +0,0 @@

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -0,0 +1 @@
@import './ManagementPage.shared.css';

View File

@@ -9,7 +9,7 @@ import {
useAutomationContextRegistry,
} from './automationContextAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
import './AutomationContextManagementPage.css';
const { Text, Title } = Typography;

View File

@@ -0,0 +1 @@
@import './ManagementPage.shared.css';

View File

@@ -18,7 +18,7 @@ import {
type AutomationTypeRecord,
} from './automationTypeAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
import './AutomationTypeManagementPage.css';
const { Text, Title } = Typography;

View File

@@ -0,0 +1 @@
@import './ManagementPage.shared.css';

View File

@@ -19,7 +19,7 @@ import {
type ChatDefaultContextRecord,
} from './chatContextSettingsAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
import './ChatDefaultContextManagementPage.css';
const { Text, Title } = Typography;
@@ -55,7 +55,13 @@ export function ChatDefaultContextManagementPage() {
defaultContexts,
chatTypeDefaults,
roomContexts,
isLoading,
hasLoadedFromServer,
storeSource,
lastLoadedAt,
lastFailedAt,
errorMessage: contextSettingsErrorMessage,
reload,
setStore,
} = useChatContextSettingsRegistry();
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 [isCreating, setIsCreating] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [isReloading, setIsReloading] = useState(false);
const [form] = Form.useForm<ChatDefaultContextFormValue>();
const selectedContext = useMemo(
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
[defaultContexts, selectedContextId],
);
const shouldRenderServerList = hasLoadedFromServer && !contextSettingsErrorMessage;
const isServerDataReadyForEditing = hasLoadedFromServer && !contextSettingsErrorMessage && storeSource === 'server';
useEffect(() => {
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
@@ -181,58 +190,113 @@ export function ChatDefaultContextManagementPage() {
title="기본 유형 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
<Button icon={<PlusOutlined />} onClick={openCreateForm} disabled={!isServerDataReadyForEditing}>
</Button>
}
>
<div className="chat-type-management-page__list">
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
<Text type="secondary">{`${defaultContexts.length}`}</Text>
</div>
{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>
<div className="chat-type-management-page__list-scroll">
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{contextSettingsErrorMessage ? (
<Alert
showIcon
type="error"
message="기본 유형 목록을 서버에서 불러오지 못했습니다."
description={
<Space direction="vertical" size={8}>
<Text>{contextSettingsErrorMessage}</Text>
<Space size={8} wrap>
<Button
onClick={() => {
setIsReloading(true);
void reload()
.catch(() => undefined)
.finally(() => {
setIsReloading(false);
});
}}
loading={isReloading}
>
</Button>
{lastLoadedAt ? (
<Text type="secondary">{`마지막 정상 동기화: ${new Date(lastLoadedAt).toLocaleString()}`}</Text>
) : (
<Text type="secondary"> .</Text>
)}
{lastFailedAt ? (
<Text type="secondary">{`마지막 실패: ${new Date(lastFailedAt).toLocaleString()}`}</Text>
) : null}
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
</Space>
}
/>
) : 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>
</List.Item>
)}
/>
) : (
<Empty description="등록된 기본 유형이 없습니다." />
)}
</List.Item>
)}
/>
) : isLoading && !hasLoadedFromServer ? (
<Alert showIcon type="info" message="기본 유형 목록을 서버에서 불러오는 중입니다." />
) : (
<Empty
description={
contextSettingsErrorMessage
? '서버 동기화 실패 상태입니다. 재조회 후 다시 확인해 주세요.'
: hasLoadedFromServer
? '등록된 기본 유형이 없습니다.'
: '서버 기준 기본 유형을 아직 확인하지 못했습니다.'
}
/>
)}
</div>
</div>
</Card>
) : (
@@ -248,17 +312,25 @@ export function ChatDefaultContextManagementPage() {
shape="circle"
icon={<SaveOutlined />}
aria-label={isCreating ? '등록' : '수정 저장'}
disabled={!isServerDataReadyForEditing}
onClick={() => {
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 ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
aria-label="삭제"
disabled={!isServerDataReadyForEditing}
onClick={() => {
void handleDelete();
}}
@@ -271,6 +343,14 @@ export function ChatDefaultContextManagementPage() {
<div className="chat-type-management-page__editor">
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
{!isServerDataReadyForEditing ? (
<Alert
showIcon
type="warning"
message="서버 최신 기본 유형을 확인한 뒤에만 수정할 수 있습니다."
description="부분 목록이나 오래된 상태로 저장해 서버 값이 덮어써지는 경로를 막기 위해, 서버 동기화가 완료되기 전에는 저장을 제한합니다."
/>
) : null}
<Form
className="chat-type-management-page__editor-form"
layout="vertical"

653
src/app/main/ChatTypeManagementPage.css Executable file → Normal file
View File

@@ -1,652 +1 @@
.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 .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;
}
}
@import './ManagementPage.shared.css';

View File

@@ -4,6 +4,7 @@ import {
EditOutlined,
SaveOutlined,
PlusOutlined,
ReloadOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
@@ -61,7 +62,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const { chatTypes, builtInChatTypes, customChatTypes, setChatTypes, isLoading, errorMessage, reload } = useChatTypeRegistry();
const {
defaultContexts,
chatTypeDefaults,
@@ -76,6 +77,7 @@ export function ChatTypeManagementPage() {
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
const [form] = Form.useForm<ChatTypeFormValue>();
@@ -83,17 +85,17 @@ export function ChatTypeManagementPage() {
const isPaneMaximized = maximizedPane !== 'none';
const selectedChatType = useMemo(
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
[chatTypes, selectedChatTypeId],
() => customChatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
[customChatTypes, selectedChatTypeId],
);
useEffect(() => {
if (selectedChatTypeId && chatTypes.some((item) => item.id === selectedChatTypeId)) {
if (selectedChatTypeId && customChatTypes.some((item) => item.id === selectedChatTypeId)) {
return;
}
setSelectedChatTypeId(chatTypes[0]?.id ?? null);
}, [chatTypes, selectedChatTypeId]);
setSelectedChatTypeId(customChatTypes[0]?.id ?? null);
}, [customChatTypes, selectedChatTypeId]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -198,8 +200,8 @@ export function ChatTypeManagementPage() {
setSaveErrorMessage('');
try {
const savedChatTypes = await setChatTypes(nextChatTypes);
setSelectedChatTypeId(savedChatTypes[0]?.id ?? null);
const savedSnapshot = await setChatTypes(nextChatTypes);
setSelectedChatTypeId(savedSnapshot.customChatTypes[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
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 = (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
@@ -259,12 +274,12 @@ export function ChatTypeManagementPage() {
if (!hasAccess) {
return (
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
<Card title="채팅유형 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 컨텍스트와 권한을 관리하세요."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요."
/>
</Card>
);
@@ -277,94 +292,150 @@ export function ChatTypeManagementPage() {
}`}
>
{detailMode === 'list' ? (
<Card
title="컨텍스트 권한 관리"
<Card
title="채팅유형 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
</Button>
}
extra={!isMobileViewport ? (
<Space size={8} wrap>
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
</Button>
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
</Button>
</Space>
) : null}
>
<div className="chat-type-management-page__list">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${chatTypes.length}`}</Text>
<Title level={5}> </Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${customChatTypes.length}`}</Text>
</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 ? (
<List
dataSource={chatTypes}
renderItem={(item) => {
const isCurrentUserAllowed = canUseChatType(item, userRoles);
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
.filter((context): context is NonNullable<typeof context> => Boolean(context));
const itemClassName =
item.id === selectedChatTypeId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item';
<div className="chat-type-management-page__list-scroll">
{builtInChatTypes.length > 0 ? (
<>
<Alert
showIcon
type="info"
message="내장 기본 채팅유형은 코드 기준 고정값입니다."
description="이 목록은 참조용이며 여기서 수정·삭제되지 않습니다. 추가/삭제 가능한 대상은 아래 사용자 채팅유형입니다."
/>
<List
dataSource={builtInChatTypes}
renderItem={(item) => {
const isCurrentUserAllowed = canUseChatType(item, userRoles);
return (
<List.Item
className={itemClassName}
onClick={() => {
openDetail(item.id);
}}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.description ? (
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
) : (
'기본 문맥 설명 없음'
)}
</div>
<Space size={[6, 6]} wrap>
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
))}
{linkedDefaultContexts.map((context) => (
<Tag key={`${item.id}-${context.id}`} color="gold">
{context.title}
return (
<List.Item className="chat-type-management-page__item">
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag color="gold"> </Tag>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.description ? (
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
) : (
'기본 문맥 설명 없음'
)}
</div>
</div>
</List.Item>
);
}}
/>
</>
) : null}
{customChatTypes.length > 0 ? (
<List
dataSource={customChatTypes}
renderItem={(item) => {
const isCurrentUserAllowed = canUseChatType(item, userRoles);
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
.filter((context): context is NonNullable<typeof context> => Boolean(context));
const itemClassName =
item.id === selectedChatTypeId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item';
return (
<List.Item
className={itemClassName}
onClick={() => {
openDetail(item.id);
}}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
</Tag>
))}
</Space>
</div>
</List.Item>
);
}}
/>
) : (
<Empty description="등록된 컨텍스트가 없습니다." />
)}
</Space>
<div className="chat-type-management-page__item-description">
{item.description ? (
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
) : (
'기본 문맥 설명 없음'
)}
</div>
<Space size={[6, 6]} wrap>
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
))}
{linkedDefaultContexts.map((context) => (
<Tag key={`${item.id}-${context.id}`} color="gold">
{context.title}
</Tag>
))}
</Space>
</div>
</List.Item>
);
}}
/>
) : (
<Empty description="등록된 사용자 채팅유형이 없습니다." />
)}
</div>
</div>
</Card>
) : (
<Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
title={isCreating ? '채팅유형 등록' : '채팅유형 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
>
@@ -384,8 +455,10 @@ export function ChatTypeManagementPage() {
setSaveErrorMessage('');
try {
const savedChatTypes = await setChatTypes(nextChatTypes);
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
const savedSnapshot = await setChatTypes(nextChatTypes);
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
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
: chatTypeDefaults;
@@ -413,9 +486,9 @@ export function ChatTypeManagementPage() {
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
rules={[{ required: true, message: '채팅유형명을 입력하세요.' }]}
>
<Input placeholder="예: 운영 문의" />
<Input placeholder="예: 운영 문의 전용" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ import {
DeleteOutlined,
WarningOutlined,
} 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 { TextAreaRef } from 'antd/es/input/TextArea';
import {
@@ -66,7 +66,12 @@ import {
type ChatDefaultContextRecord,
} from './chatContextSettingsAccess';
import { renderModalWithEnterConfirm } from './modalKeyboard';
import { createNotificationMessage } from './notificationApi';
import {
createNotificationMessage,
sendClientNotification,
shouldFallbackToLocalNotification,
showLocalClientNotification,
} from './notificationApi';
import { useTokenAccess } from './tokenAccess';
import {
ChatConversationView,
@@ -174,6 +179,14 @@ const CHAT_RESTART_EXCLUSION_PATTERNS = [
] 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() {
if (typeof window === 'undefined') {
return false;
@@ -299,6 +312,56 @@ function buildChatSessionLink(sessionId: string) {
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) {
const normalizedSessionId = sessionId.trim();
@@ -389,6 +452,8 @@ function buildOptimisticConversationSummary(args: {
currentJobMessage: null,
currentQueueSize: 0,
currentStatusUpdatedAt: null,
isPendingWork: false,
pendingWorkReason: null,
lastRequestPreview: '',
lastMessagePreview: '',
lastResponsePreview: '',
@@ -711,6 +776,106 @@ function resolveConversationListPreviewText(preview: string) {
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) {
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}`;
}
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[]) {
if (attachments.length === 0) {
return '';
@@ -1689,6 +1880,8 @@ function mergeConversationSummaryPreservingChatType(
generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName),
contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.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() || '',
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
@@ -1824,6 +2017,14 @@ function applyRuntimeSnapshotToConversationItems(
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) {
if (!snapshot) {
return null;
@@ -1859,7 +2060,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const { chatTypes, setChatTypes } = useChatTypeRegistry();
const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } =
useChatContextSettingsRegistry();
const [draft, setDraft] = useState('');
const draftRef = useRef('');
const [draftSeed, setDraftSeed] = useState({ value: '', version: 0 });
const [composerAttachments, setComposerAttachments] = useState<ChatComposerAttachment[]>([]);
const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false);
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
@@ -1889,6 +2091,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState<string | null>(null);
const [editingChatTypeDescription, setEditingChatTypeDescription] = useState('');
const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false);
const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState('');
const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState('');
const [mobileConversationSectionOpen, setMobileConversationSectionOpen] =
@@ -1945,6 +2148,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
const [messageApi, messageContextHolder] = message.useMessage();
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
const [pendingClearConversationSessionId, setPendingClearConversationSessionId] = useState<string | null>(null);
const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now());
const viewportRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<TextAreaRef | null>(null);
@@ -1975,9 +2179,27 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const isClosingConversationRef = useRef(false);
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
const notifiedChatPushKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
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[]>) => {
setRequestItemsState((previous) => {
const safePrevious = Array.isArray(previous) ? previous : [];
@@ -2019,10 +2241,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
setDraftValue(draftRef.current.trim() ? draftRef.current : queuedImportedDraft);
composerRef.current?.focus({ cursor: 'end' });
setQueuedImportedDraft('');
}, [activeSessionId, queuedImportedDraft]);
}, [activeSessionId, queuedImportedDraft, setDraftValue]);
const {
conversationItems,
@@ -2036,6 +2258,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
enabled: activeView === 'chat',
});
const conversationItemsRef = useRef<ChatConversationSummary[]>(conversationItems);
const activeConversation = useMemo(
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
[activeSessionId, conversationItems],
);
useEffect(() => {
setConversationItems((previous) => {
const storedSectionNameMap = readStoredGeneralSectionNameMap();
@@ -2293,7 +2519,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
null;
setEditingRoomChatTypeId(nextChatTypeId);
setEditingChatTypeDescription(nextChatType?.description ?? '');
setEditingRoomDefaultContextIds(effectiveDefaultContextIds);
setEditingRoomDefaultContextIds(activeRoomContextSettings?.defaultContextIds ?? resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
setIsEditingRoomDefaultContextsDirty(false);
setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle);
setEditingRoomCustomContextContent(effectiveRoomCustomContextContent);
setContextDrawerTabKey('chat-type');
@@ -2327,32 +2554,47 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
permissions: nextChatType.permissions,
enabled: nextChatType.enabled,
});
const savedChatTypes = await setChatTypes(nextChatTypes);
nextChatType = savedChatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
const savedSnapshot = await setChatTypes(nextChatTypes);
nextChatType = savedSnapshot.chatTypes.find((item) => item.id === targetChatTypeId) ?? 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, {
sessionId: activeConversation.sessionId,
defaultContextIds: editingRoomDefaultContextIds,
customContextTitle: editingRoomCustomContextTitle,
customContextContent: editingRoomCustomContextContent,
});
const nextRoomContexts =
shouldPersistRoomDefaultContextIds || shouldPersistRoomCustomContext
? upsertChatRoomContextSettings(roomContexts, {
sessionId: activeConversation.sessionId,
defaultContextIds: normalizedDefaultContextIds,
customContextTitle: nextCustomContextTitle,
customContextContent: nextCustomContextContent,
})
: roomContexts.filter((item) => item.sessionId !== activeConversation.sessionId);
const nextDescription = normalizeConversationContextDescription(
resolveComposedChatTypeDescription(resolvedChatType, {
sessionId: activeConversation.sessionId,
defaultContextIds: editingRoomDefaultContextIds,
customContextTitle: editingRoomCustomContextTitle,
customContextContent: editingRoomCustomContextContent,
defaultContextIds: normalizedDefaultContextIds,
customContextTitle: nextCustomContextTitle,
customContextContent: nextCustomContextContent,
}),
);
void setChatContextSettingsStore({
await setChatContextSettingsStore({
defaultContexts,
chatTypeDefaults,
roomContexts: nextRoomContexts,
}).catch(() => {});
});
setConversationItems((previous) =>
previous.map((entry) =>
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)));
setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id);
setIsEditingRoomDefaultContextsDirty(false);
setIsContextDrawerOpen(false);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.');
@@ -2396,7 +2639,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
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);
if (!nextPreview) {
@@ -2413,6 +2666,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
lastMessagePreview: nextPreview,
lastMessageAt: 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,
),
@@ -2486,18 +2759,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
const syncToken = ++conversationDetailSyncTokenRef.current;
const activeSessionRequestCount = requestItemsRef.current.filter(
(item) => item.sessionId === normalizedSessionId,
).length;
const activeSessionVisibleRequestCount =
const visibleMessages =
normalizedSessionId === activeSessionId
? requestItemsRef.current.filter(
(item) => item.sessionId === normalizedSessionId && item.status !== 'removed',
).length
: 0;
? messagesRef.current
: getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId);
const visibleRequestCount = countVisibleConversationRequests(
visibleMessages,
requestItemsRef.current,
normalizedSessionId,
);
const detailLimit = Math.min(
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) {
@@ -2824,6 +3097,78 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
) {
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(
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))),
@@ -2836,10 +3181,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
[messages],
);
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
const activeConversation = useMemo(
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
[activeSessionId, conversationItems],
);
const activeConversationHasLocalActivity =
chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId);
const persistedActiveChatTypeId =
@@ -3266,6 +3607,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]);
const pendingDeleteConversation =
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
const pendingClearConversation =
conversationItems.find((item) => item.sessionId === pendingClearConversationSessionId) ?? null;
const editingGeneralSectionConversation =
conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null;
const availableGeneralSectionNames = useMemo(
@@ -3280,7 +3623,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
[conversationItems],
);
useEffect(() => {
if (!pendingContextConfirm && !pendingDeleteConversation) {
if (!pendingContextConfirm && !pendingDeleteConversation && !pendingClearConversation) {
return undefined;
}
@@ -3313,7 +3656,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return () => {
window.removeEventListener('keydown', handleEnterConfirm, true);
};
}, [pendingContextConfirm, pendingDeleteConversation]);
}, [pendingClearConversation, pendingContextConfirm, pendingDeleteConversation]);
const {
activePreview,
isPreviewLoading,
@@ -3643,6 +3986,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
{jobStatusLabel}
</span>
) : null}
{isConversationPendingWork(item) ? (
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
</span>
) : null}
{isUnread ? (
<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,
currentQueueSize: 0,
currentStatusUpdatedAt: null,
isPendingWork: false,
pendingWorkReason: null,
lastRequestPreview: '',
lastMessagePreview: '',
lastResponsePreview: '',
@@ -4026,7 +4376,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
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);
setIsPreviewModalOpen(false);
setActiveSystemStatus(null);
@@ -4201,6 +4559,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const {
cancelPendingRequest,
handleClearConversation,
deleteStoredRequest,
handleDeleteConversation,
handleRenameConversation,
@@ -4239,6 +4598,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
replaceChatSessionInUrl,
messageApi,
});
const openClearConversationDataModal = useCallback(() => {
if (!activeConversation) {
return;
}
setPendingClearConversationSessionId(activeConversation.sessionId);
}, [activeConversation]);
useEffect(() => {
if (connectionState !== 'connected') {
@@ -4269,13 +4635,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
updatePendingMessageStatus(request.requestId, null, request.retryCount);
return [];
} catch {
const nextRetryCount = request.retryCount + 1;
if (nextRetryCount >= CHAT_MAX_RETRY_ATTEMPTS) {
updatePendingMessageStatus(request.requestId, 'failed', nextRetryCount);
return [{ ...request, retryCount: nextRetryCount, failed: true }];
}
const nextRetryCount = Math.min(request.retryCount + 1, CHAT_MAX_RETRY_ATTEMPTS);
updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount);
return [{ ...request, retryCount: nextRetryCount }];
}
@@ -4787,7 +5147,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} = useConversationComposerController({
activeSessionId,
appConfigChat: appConfig.chat,
draft,
getDraft: () => draftRef.current,
composerAttachments,
isComposerAttachmentUploading,
selectedChatType: effectiveChatType
@@ -4802,7 +5162,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
messagesRef,
pendingRequestsRef,
shouldStickToBottomRef,
setDraft,
setDraft: setDraftValue,
setComposerAttachments,
setIsComposerAttachmentUploading,
setMessages,
@@ -4865,7 +5225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
setPendingImportedDraftRequest(null);
setDraft('');
setDraftValue('');
executeSendMessage({
mode: pendingImportedDraftRequest.sendMode,
text: pendingImportedDraftRequest.text,
@@ -4886,7 +5246,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
pendingImportedDraftRequest,
requestedSessionId,
selectedChatType,
setDraft,
setDraftValue,
setSelectedChatTypeId,
]);
@@ -4905,7 +5265,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
description: resolveComposedChatTypeDescription(selectedChatType),
}
: (availableChatTypes[0] ?? null));
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
const trimmed = buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments).trim();
if (!trimmed) {
return;
@@ -4944,7 +5304,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
buildOutgoingMessageText,
composerAttachments,
createLocalMessage,
draft,
draftRef,
effectiveChatType,
executeSendMessage,
isComposerAttachmentUploading,
@@ -4981,6 +5341,44 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
[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) => {
await copyText(message.text);
setCopiedMessageId(message.id);
@@ -5187,6 +5585,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}}
/>
</Tooltip>
<Tooltip title="채팅방 데이터 초기화">
<Button
size="small"
danger
icon={<DeleteOutlined />}
aria-label="채팅방 데이터 초기화"
onClick={() => {
setIsMobileActionGroupOpen(false);
openClearConversationDataModal();
}}
/>
</Tooltip>
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
<Button
size="small"
@@ -5249,6 +5659,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}}
/>
</Tooltip>
<Tooltip title="채팅방 데이터 초기화">
<Button
size="small"
danger
icon={<DeleteOutlined />}
aria-label="채팅방 데이터 초기화"
onClick={openClearConversationDataModal}
/>
</Tooltip>
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
<Button
size="small"
@@ -5347,7 +5766,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
isSystemStatusPending={isSystemStatusPending}
showScrollToBottom={showScrollToBottom}
copiedMessageId={copiedMessageId}
draft={draft}
draft={draftSeed.value}
draftVersion={draftSeed.version}
composerAttachments={composerAttachments}
isConversationLoading={isConversationContentLoading}
conversationLoadingLabel={conversationLoadingLabel}
@@ -5395,7 +5815,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setIsSendWithoutContextEnabled((current) => !current);
}}
onClearDraft={() => {
setDraft('');
setDraftValue('');
}}
onToggleResourceStrip={() => {
setIsResourceStripOpen((current) => !current);
@@ -5431,6 +5851,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
void deleteStoredRequest(message.clientRequestId);
}}
onRemoveQueuedRequest={removeQueuedComposerRequest}
onSubmitPrompt={handlePromptSubmit}
/>
) : (
<div className="app-chat-panel__conversation-empty">
@@ -5508,15 +5929,35 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
children: (
<div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
<div className="app-chat-panel__context-drawer-section-head">
<Text strong> </Text>
<Text type="secondary"> , .</Text>
<Text strong></Text>
<Text type="secondary"> , .</Text>
</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 ? (
<>
<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>
<Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
.
.
</Text>
</div>
<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"
value={editingRoomDefaultContextIds}
onChange={(checkedValues) => {
setIsEditingRoomDefaultContextsDirty(true);
setEditingRoomDefaultContextIds(
checkedValues
.map((value) => String(value).trim())
@@ -5894,6 +6336,34 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
) : null}
</div>
</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
open={Boolean(pendingDeleteConversation)}
title="대화방을 삭제할까요?"

View File

@@ -32,6 +32,7 @@ import {
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { fetchPlanItems } from '../../features/planBoard/api';
import { copyTextToClipboard } from '../../utils/clipboard';
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
import {
cancelServerRestartReservation,
@@ -819,6 +820,32 @@ function getServerRestartReservationOverlayState(
}
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 workServerRestartAt = Number.isFinite(startedTimestamp)
? 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) {
return {
title: '재기동 완료 처리 중',
@@ -954,7 +1008,7 @@ function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: Serve
function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) {
if (!previous || !next) {
return Boolean(next) && !next.buildRequired && !next.updateAvailable;
return next ? !next.buildRequired && !next.updateAvailable : false;
}
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({
activeTopMenu,
sidebarCollapsed,
@@ -1571,6 +1604,8 @@ export function MainHeader({
? '대기 중'
: serverRestartReservation.status === 'ready'
? '자동 실행 대기'
: serverRestartReservation.status === 'recovering'
? 'Codex 자동 개선 중'
: serverRestartReservation.status === 'executing'
? '실행 중'
: serverRestartReservation.status === 'completed'
@@ -1599,6 +1634,10 @@ export function MainHeader({
? serverRestartReservationPendingSummary
: serverRestartReservation?.status === 'ready'
? serverRestartReservationPendingSummary
: serverRestartReservation?.status === 'recovering'
? serverRestartReservation.autoFix.summary
?? serverRestartReservation.waitingReason
?? '빌드 오류 자동 개선을 진행 중입니다.'
: serverRestartReservation?.status === 'executing'
? serverRestartReservationTimingLabel
: serverRestartReservation?.status === 'completed'
@@ -3050,9 +3089,12 @@ export function MainHeader({
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(feedback.message)
void copyTextToClipboard(feedback.message)
.then(() => {
setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
setCopyFeedback({
tone: 'success',
message: '메시지를 복사했습니다.',
});
})
.catch(() => {
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
@@ -3247,9 +3289,12 @@ export function MainHeader({
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(appConfigFeedback.message)
void copyTextToClipboard(appConfigFeedback.message)
.then(() => {
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
setAppConfigCopyFeedback({
tone: 'success',
message: '메시지를 복사했습니다.',
});
})
.catch(() => {
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
@@ -4008,9 +4053,12 @@ export function MainHeader({
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(appConfigFeedback.message)
void copyTextToClipboard(appConfigFeedback.message)
.then(() => {
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
setAppConfigCopyFeedback({
tone: 'success',
message: '메시지를 복사했습니다.',
});
})
.catch(() => {
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
@@ -4090,9 +4138,12 @@ export function MainHeader({
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(appConfigFeedback.message)
void copyTextToClipboard(appConfigFeedback.message)
.then(() => {
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
setAppConfigCopyFeedback({
tone: 'success',
message: '메시지를 복사했습니다.',
});
})
.catch(() => {
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });

View 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;
}
}

View File

@@ -1,8 +1,10 @@
.resource-management-page {
position: relative;
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;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
overflow: hidden;
@@ -17,6 +19,9 @@
.resource-management-page__sidebar,
.resource-management-page__content,
.resource-management-page__preview-card {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
border-radius: 22px;
overflow: clip;
@@ -39,6 +44,19 @@
.resource-management-page__content .ant-card-head,
.resource-management-page__preview-card .ant-card-head {
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,
@@ -53,6 +71,29 @@
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 {
display: block;
}
@@ -62,6 +103,7 @@
min-height: 0;
overflow: auto;
padding-right: 4px;
scrollbar-gutter: stable;
}
.resource-management-page__tree .ant-tree-node-content-wrapper {
@@ -77,12 +119,18 @@
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__tree-title .ant-btn {
flex: 0 0 auto;
}
.resource-management-page__content {
min-height: 0;
}
@@ -105,6 +153,7 @@
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.resource-management-page__toolbar-main,
@@ -115,6 +164,25 @@
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 {
margin: 0;
}
@@ -138,7 +206,7 @@
.resource-management-page__list-header,
.resource-management-page__list-row {
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;
align-items: center;
}
@@ -155,6 +223,7 @@
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.resource-management-page__list-row {
@@ -184,13 +253,22 @@
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.resource-management-page__list-meta {
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 {
overflow: hidden;
text-overflow: ellipsis;
@@ -206,6 +284,96 @@
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 {
width: 100%;
min-height: 0;
@@ -217,12 +385,36 @@
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 {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
scrollbar-gutter: stable;
border: 0;
border-radius: 16px;
background: #fff;
@@ -236,6 +428,124 @@
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 {
width: 100%;
max-height: 100%;
@@ -447,13 +757,13 @@
.resource-management-page {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.resource-management-page--mobile {
min-height: 0;
padding-inline: 1px;
padding-bottom: max(10px, calc(env(safe-area-inset-bottom, 0px) + 6px));
padding-inline: 0;
padding-bottom: max(6px, calc(env(safe-area-inset-bottom, 0px) + 2px));
box-sizing: border-box;
}
@@ -464,29 +774,85 @@
}
.resource-management-page__mobile-nav {
position: sticky;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
gap: 6px;
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 {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.resource-management-page__mobile-card > .ant-card {
flex: 1 1 auto;
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__content .ant-card-body,
.resource-management-page__preview-card .ant-card-body {
padding: 14px;
padding-bottom: 15px;
padding-bottom: 14px;
}
.resource-management-page__list-header {
@@ -527,6 +893,20 @@
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 {
align-items: stretch;
}
@@ -536,14 +916,40 @@
width: 100%;
}
.resource-management-page__toolbar-path {
min-width: 100%;
}
.resource-management-page__toolbar-actions .ant-btn {
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__image-preview,
.resource-management-page__text-preview {
min-height: 220px;
.resource-management-page__text-preview,
.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 {
@@ -561,7 +967,7 @@
.resource-management-page__preview-card .ant-tabs-content-holder {
overflow: hidden;
padding-bottom: 1px;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
box-sizing: border-box;
}
@@ -570,10 +976,26 @@
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 {
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 {
width: 100vw !important;
max-width: 100vw;

View File

@@ -26,10 +26,16 @@ import {
useMemo,
useRef,
useState,
type ReactNode,
type TouchEvent as ReactTouchEvent,
type MouseEvent as ReactMouseEvent,
} 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 {
copyResourceManagerItem,
createResourceManagerDirectory,
@@ -47,6 +53,7 @@ import {
type ResourceManagerTreeNode,
type ResourceManagerTreeRoot,
} from './resourceManagerApi';
import { ChatDataTablePreview, resolveTabularPreviewModel } from './mainChatPanel/ChatDataTablePreview';
import './ResourceManagementPage.css';
const { Paragraph, Text } = Typography;
@@ -81,6 +88,8 @@ type PreviewOffset = {
y: number;
};
type HtmlPreviewMode = 'browser' | 'source';
type CreateEntryModalState =
| {
type: 'file' | 'folder';
@@ -150,6 +159,10 @@ function isPdfFile(file: ResourceManagerFileDetail | null) {
return file?.mimeType === 'application/pdf';
}
function normalizeFileExtension(file: ResourceManagerFileDetail | null) {
return file?.extension?.replace(/^\./, '').trim().toLowerCase() ?? '';
}
function isHtmlFile(file: ResourceManagerFileDetail | null) {
if (!file) {
return false;
@@ -158,12 +171,121 @@ function isHtmlFile(file: ResourceManagerFileDetail | null) {
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, '&quot;');
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) {
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) {
@@ -317,6 +439,15 @@ function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: stri
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() {
const { message } = App.useApp();
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -335,6 +466,7 @@ export function ResourceManagementPage() {
const [selectedFile, setSelectedFile] = useState<ResourceManagerFileDetail | null>(null);
const [editorContent, setEditorContent] = useState('');
const [activePreviewTab, setActivePreviewTab] = useState('preview');
const [htmlPreviewMode, setHtmlPreviewMode] = useState<HtmlPreviewMode>('browser');
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobilePanel, setMobilePanel] = useState<MobilePanel>('list');
@@ -470,6 +602,7 @@ export function ResourceManagementPage() {
};
const directoryTargets = useMemo(() => collectDirectoryTargets(treeRoot), [treeRoot]);
const currentDirectoryLabel = formatPathLabel(selectedDirectoryPath);
const updateViewportHeight = () => {
if (typeof window === 'undefined') {
@@ -518,6 +651,7 @@ export function ResourceManagementPage() {
setPreviewZoom(1);
setPreviewOffset({ x: 0, y: 0 });
previewTouchGestureRef.current = null;
setHtmlPreviewMode('browser');
}, [selectedFile?.path, isPreviewMaximized]);
const cancelLongPress = () => {
@@ -1006,7 +1140,7 @@ export function ResourceManagementPage() {
};
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;
setContextMenu((current) => ({ ...current, open: false, target: null }));
@@ -1049,6 +1183,16 @@ export function ResourceManagementPage() {
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') {
openCopyMoveModal('move', target.entry);
return;
@@ -1065,7 +1209,7 @@ export function ResourceManagementPage() {
};
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>) => {
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) => {
if (!selectedFile) {
return null;
}
const fileContent = selectedFile.content ?? '';
if (isImageFile(selectedFile)) {
if (!maximized) {
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" />;
}
if (isTextPreviewFile(selectedFile)) {
return <pre className="resource-management-page__text-preview">{selectedFile.content ?? ''}</pre>;
if (isMarkdownFile(selectedFile)) {
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) {
return (
if (isHtmlFile(selectedFile)) {
const previewDocument = buildHtmlPreviewDocument(selectedFile);
const browserPreview = maximized ? (
<div
ref={previewShellRef}
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
title={selectedFile.name}
src={selectedFile.previewUrl}
srcDoc={previewDocument}
className="resource-management-page__preview-frame resource-management-page__preview-frame--zoomable"
style={{ width: '100%', height: '100%' }}
/>
</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>
</Space>
),
children: renderPreviewContent(),
children: <div className="resource-management-page__tab-panel">{renderPreviewContent()}</div>,
},
{
key: 'edit',
@@ -1162,22 +1364,21 @@ export function ResourceManagementPage() {
</Space>
),
children: selectedFile.isTextEditable ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div className="resource-management-page__tab-panel resource-management-page__editor-panel">
<TextArea
value={editorContent}
onChange={(event) => {
setEditorContent(event.target.value);
}}
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>
<Text type="secondary">{selectedFile.mimeType}</Text>
</Space>
</Space>
</div>
) : (
<Alert showIcon type="warning" message="이 파일은 텍스트 편집을 지원하지 않습니다." />
),
@@ -1276,7 +1477,7 @@ export function ResourceManagementPage() {
}, [treeRoot]);
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">
`resource/` .
</Text>
@@ -1300,7 +1501,7 @@ export function ResourceManagementPage() {
const renderListCard = () => (
<Card
title="폴더 목록"
title={renderCardTitle('폴더 목록', `${directoryItems.length}개 항목`)}
className="resource-management-page__content"
extra={
<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__toolbar">
<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 ? (
<Button
size="small"
@@ -1481,7 +1686,18 @@ export function ResourceManagementPage() {
const renderPreviewCard = () => (
<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"
extra={
selectedFile && activePreviewTab === 'preview' ? (
@@ -1504,11 +1720,36 @@ export function ResourceManagementPage() {
</div>
) : selectedFile ? (
<>
<Space size={[8, 8]} wrap>
<Text type="secondary">{formatPathLabel(selectedFile.path)}</Text>
<Space size={[8, 8]} wrap className="resource-management-page__preview-meta">
<Text type="secondary" ellipsis={{ tooltip: formatPathLabel(selectedFile.path) }}>
{formatPathLabel(selectedFile.path)}
</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>
<Tabs activeKey={activePreviewTab} onChange={setActivePreviewTab} items={previewTabItems} />
<Tabs
className="resource-management-page__preview-tabs"
activeKey={activePreviewTab}
onChange={setActivePreviewTab}
items={previewTabItems}
/>
</>
) : (
<Empty
@@ -1519,6 +1760,35 @@ export function ResourceManagementPage() {
</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 (
<div
ref={containerRef}
@@ -1535,7 +1805,7 @@ export function ResourceManagementPage() {
event.preventDefault();
}
}}
onSelectStartCapture={preventSelection}
onMouseDownCapture={preventSelection}
onDragStartCapture={preventSelection}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch' && !isNativeContextMenuBypassTarget(event.target)) {
@@ -1545,20 +1815,10 @@ export function ResourceManagementPage() {
>
{isMobileViewport ? (
<>
<div className="resource-management-page__mobile-nav">
<Button type={mobilePanel === 'tree' ? 'primary' : 'default'} onClick={() => setMobilePanel('tree')}>
</Button>
<Button type={mobilePanel === 'list' ? 'primary' : 'default'} onClick={() => setMobilePanel('list')}>
</Button>
<Button
type={mobilePanel === 'preview' ? 'primary' : 'default'}
disabled={!selectedFile}
onClick={() => setMobilePanel('preview')}
>
</Button>
<div className="resource-management-page__mobile-nav" role="tablist" aria-label="리소스 보기 전환">
{renderMobileNavButton('tree', '트리', <FolderOpenOutlined />)}
{renderMobileNavButton('list', '목록', <FileTextOutlined />)}
{renderMobileNavButton('preview', '미리보기', <EyeOutlined />, !selectedFile)}
</div>
<div className="resource-management-page__mobile-card">
{mobilePanel === 'tree' ? renderTreeCard() : null}
@@ -1600,6 +1860,12 @@ export function ResourceManagementPage() {
<CopyOutlined />
<span></span>
</button>
{contextMenu.target.entry.type === 'directory' ? (
<button type="button" {...bindContextMenuAction('copy-path')}>
<CopyOutlined />
<span> </span>
</button>
) : null}
<button type="button" {...bindContextMenuAction('move')}>
<ScissorOutlined />
<span></span>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useSyncExternalStore } from 'react';
import { appendClientIdHeader } from './clientIdentity';
export type ChatDefaultContextRecord = {
@@ -29,30 +29,12 @@ type ChatContextSettingsStore = {
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_API_PATH = '/chat-context-settings';
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) {
return value?.trim() ?? '';
}
@@ -94,7 +76,7 @@ function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefin
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
const byId = new Map<string, ChatDefaultContextRecord>();
[...(items ?? []), ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
(items ?? [])
.map((item) => normalizeDefaultContext(item))
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
.forEach((item) => {
@@ -176,23 +158,7 @@ function sanitizeStore(input: Partial<ChatContextSettingsStore> | null | undefin
};
}
function loadStore() {
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);
}
}
const EMPTY_CHAT_CONTEXT_SETTINGS_STORE = sanitizeStore(null);
function emitStoreChange() {
if (typeof window === 'undefined') {
@@ -202,17 +168,6 @@ function emitStoreChange() {
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() {
if (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) {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const normalizedMethod = init?.method?.toUpperCase() ?? 'GET';
const controller = new AbortController();
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')) {
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 {
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,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
cache: init?.cache ?? (normalizedMethod === 'GET' ? 'reload' : undefined),
});
if (!response.ok) {
@@ -315,6 +284,132 @@ async function saveStoreToServer(store: ChatContextSettingsStore) {
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(
selections: ChatTypeDefaultContextSelection[],
chatTypeId: string | null | undefined,
@@ -452,70 +547,71 @@ export function pruneChatRoomContextSettings(roomContexts: ChatRoomContextSettin
}
export function useChatContextSettingsRegistry() {
const [store, setStoreState] = useState<ChatContextSettingsStore>(() => loadStore());
const [errorMessage, setErrorMessage] = useState('');
const isMountedRef = useRef(true);
const snapshot = useSyncExternalStore(
subscribeChatContextSettingsRegistry,
getChatContextSettingsRegistrySnapshot,
getChatContextSettingsRegistrySnapshot,
);
useEffect(() => {
isMountedRef.current = true;
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);
};
ensureChatContextSettingsRegistryWindowEvents();
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
}, []);
return {
...store,
errorMessage,
...snapshot,
reload: reloadChatContextSettingsRegistryFromServer,
setStore: (
updater:
| ChatContextSettingsStore
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
) => {
const nextStore = typeof updater === 'function' ? updater(loadStore()) : updater;
const saved = saveStore(nextStore);
const currentStore: ChatContextSettingsStore = {
defaultContexts: snapshot.defaultContexts,
chatTypeDefaults: snapshot.chatTypeDefaults,
roomContexts: snapshot.roomContexts,
};
const nextStore = typeof updater === 'function' ? updater(currentStore) : updater;
const saved = sanitizeStore(nextStore);
if (isMountedRef.current) {
setStoreState(saved);
setErrorMessage('');
}
setChatContextSettingsRegistrySnapshot((current) => ({
...current,
...saved,
errorMessage: '',
lastFailedAt: null,
storeSource: 'optimistic',
}));
return saveStoreToServer(saved).then((serverSaved) => {
if (isMountedRef.current) {
setStoreState(serverSaved);
}
return saveStoreToServer(saved)
.then((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;
});
},
};
}

View File

@@ -13,6 +13,12 @@ export type ChatTypeRecord = {
updatedAt: string;
};
export type ChatTypeRegistrySnapshot = {
builtInChatTypes: ChatTypeRecord[];
customChatTypes: ChatTypeRecord[];
chatTypes: ChatTypeRecord[];
};
export type ChatTypeInput = {
id?: string;
name: string;
@@ -114,6 +120,11 @@ function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null |
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() {
if (typeof window === 'undefined') {
return;
@@ -200,26 +211,58 @@ async function requestChatTypes<T>(init?: RequestInit) {
}
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',
});
if (response.chatTypes == null) {
return mergeWithDefaultChatTypes(DEFAULT_CHAT_TYPES);
}
const builtInChatTypes =
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[]) {
const resolved = mergeWithDefaultChatTypes(chatTypes);
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] }>({
const customChatTypes = stripBuiltInChatTypes(chatTypes);
const response = await requestChatTypes<{
ok: boolean;
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
customChatTypes?: Partial<ChatTypeRecord>[] | null;
chatTypes?: Partial<ChatTypeRecord>[] | null;
}>({
method: 'PUT',
body: JSON.stringify({ chatTypes: resolved }),
body: JSON.stringify({ customChatTypes }),
});
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) {
@@ -264,9 +307,27 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
export function useChatTypeRegistry() {
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 [errorMessage, setErrorMessage] = useState('');
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(() => {
isMountedRef.current = true;
@@ -274,26 +335,39 @@ export function useChatTypeRegistry() {
const syncChatTypes = async () => {
setIsLoading(true);
setErrorMessage('');
let resolvedChatTypeSnapshot: ChatTypeRegistrySnapshot = {
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes: [],
chatTypes: DEFAULT_CHAT_TYPES,
};
try {
const serverChatTypes = await fetchChatTypesFromServer();
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
if (isMountedRef.current) {
setChatTypesState(resolvedChatTypes);
}
const serverChatTypeSnapshot = await fetchChatTypesFromServer();
resolvedChatTypeSnapshot = serverChatTypeSnapshot ?? resolvedChatTypeSnapshot;
applySnapshot(resolvedChatTypeSnapshot);
} catch (error) {
if (isMountedRef.current) {
setBuiltInChatTypesState(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
setCustomChatTypesState([]);
setChatTypesState(DEFAULT_CHAT_TYPES);
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
}
resolvedChatTypeSnapshot = {
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes: [],
chatTypes: DEFAULT_CHAT_TYPES,
};
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
return resolvedChatTypeSnapshot;
};
syncChatTypesRef.current = syncChatTypes;
void syncChatTypes();
const handleSync = () => {
@@ -310,14 +384,15 @@ export function useChatTypeRegistry() {
return {
chatTypes,
builtInChatTypes,
customChatTypes,
isLoading,
errorMessage,
reload: async () => syncChatTypesRef.current(),
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
const resolved = await saveChatTypesToServer(nextChatTypes);
if (isMountedRef.current) {
setChatTypesState(resolved);
setErrorMessage('');
}
await saveChatTypesToServer(nextChatTypes);
const resolved = await fetchChatTypesFromServer();
applySnapshot(resolved);
return resolved;
},
};

View File

@@ -10,7 +10,17 @@ export type DefaultChatTypeRecord = {
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
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_NAME = 'Layout editor 실행';
@@ -39,7 +49,23 @@ export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
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,

View File

@@ -47,6 +47,7 @@ export function ConversationRoomPane({
showScrollToBottom={false}
copiedMessageId={null}
draft=""
draftVersion={0}
composerAttachments={[]}
requestStateMap={requestStateMap}
isConversationLoading={isLoading}
@@ -85,6 +86,7 @@ export function ConversationRoomPane({
onCancelMessage={() => {}}
onDeleteRequest={() => {}}
onRemoveQueuedRequest={() => {}}
onSubmitPrompt={async () => false}
/>
);
}

View File

@@ -1,4 +1,5 @@
import {
clearChatConversationRoom,
createChatConversationRoom,
deleteChatConversationRequest,
deleteChatConversationRoom,
@@ -57,6 +58,7 @@ export type ChatGateway = {
>
>,
) => Promise<ChatConversationSummary>;
clearConversation: (sessionId: string) => Promise<ChatConversationSummary>;
deleteConversation: (sessionId: string) => Promise<void>;
deleteConversationRequest: (sessionId: string, requestId: string) => Promise<void>;
markConversationRead: (sessionId: string) => Promise<void>;
@@ -73,6 +75,7 @@ export const chatGateway: ChatGateway = {
createConversation: createChatConversationRoom,
renameConversation: renameChatConversationRoom,
updateConversation: updateChatConversationRoom,
clearConversation: clearChatConversationRoom,
deleteConversation: async (sessionId) => {
await deleteChatConversationRoom(sessionId);
},

View File

@@ -56,7 +56,7 @@ type UseConversationComposerControllerOptions = {
maxContextMessages: number;
maxContextChars: number;
};
draft: string;
getDraft: () => string;
composerAttachments: ChatComposerAttachment[];
isComposerAttachmentUploading: boolean;
selectedChatType: SelectedChatType;
@@ -74,7 +74,17 @@ type UseConversationComposerControllerOptions = {
setShowScrollToBottom: (value: boolean) => void;
setPendingContextConfirm: (value: PendingContextConfirm | null) => 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;
createLocalMessage: (text: string) => ChatMessage;
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
@@ -97,7 +107,7 @@ type SendMessageOptions = {
export function useConversationComposerController({
activeSessionId,
appConfigChat,
draft,
getDraft,
composerAttachments,
isComposerAttachmentUploading,
selectedChatType,
@@ -268,7 +278,12 @@ export function useConversationComposerController({
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(activeSessionId, text, queuedAt);
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
requestId,
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
@@ -304,6 +319,12 @@ export function useConversationComposerController({
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
requestId,
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
@@ -374,7 +395,7 @@ export function useConversationComposerController({
return;
}
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
if (!trimmed) {
return;
@@ -423,7 +444,7 @@ export function useConversationComposerController({
buildOutgoingMessageText,
composerAttachments,
createLocalMessage,
draft,
getDraft,
executeSendMessage,
isComposerAttachmentUploading,
messagesRef,

View File

@@ -48,6 +48,32 @@ function mergeConversationItemsPreservingRequestedSession(
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.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();

View File

@@ -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 {
cancelPendingRequest,
handleClearConversation,
deleteStoredRequest,
handleDeleteConversation,
handleRenameConversation,

View File

@@ -8,8 +8,8 @@ import type {
ChatMessage,
} from '../../mainChatPanel/types';
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8;
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8;
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
function mergeConversationRequests(

View File

@@ -54,6 +54,18 @@ export function appendClientIdHeader(headersInit?: HeadersInit) {
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;
}

View File

@@ -320,7 +320,7 @@ export function MainLayout() {
useGesturePageState('anyway');
useGestureLayer({
id: 'main-layout',
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
gestures: [
{
id: 'mobile-top-right-pull-alert',

View File

@@ -6,7 +6,7 @@ import { docsMarkdownEntries } from '../../manifests/docs.manifest';
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
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() {
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);

View 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>
);
}

View File

@@ -20,7 +20,6 @@ import {
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
startTransition,
useEffect,
useMemo,
useRef,
@@ -45,7 +44,9 @@ import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
import { ChatActivityChecklist } from './ChatActivityChecklist';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { buildPromptResponseText, ChatPromptCard, type PromptDraftSelection } from './ChatPromptCard';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
import { extractChatMessageParts } from './messageParts';
@@ -109,6 +110,11 @@ type PendingComposerUpload = {
reason?: string;
};
type PendingPromptSelection = PromptDraftSelection & {
promptTitle: string;
target: Extract<ChatMessagePart, { type: 'prompt' }>;
};
type PreviewFetchError = Error & {
status?: number;
};
@@ -128,6 +134,7 @@ type MessageRenderPayload = {
diffBlocks: string[];
rankedLinkTargets: RankedLinkPreviewTarget[];
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;
@@ -172,6 +179,11 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
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') {
if (typeof document === 'undefined') {
return;
@@ -453,20 +465,23 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
for (const matchedUrl of matches) {
const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => {
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
const kind = classifyInlinePreviewKind(normalizedUrl);
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)) {
continue;
return;
}
seen.add(normalizedUrl);
@@ -475,7 +490,14 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
label: buildInlinePreviewLabel(normalizedUrl),
kind,
});
}
};
extractAutoDetectedPreviewUrls(text).forEach((matchedUrl) => {
pushTarget(matchedUrl);
});
extractHiddenPreviewUrls(text).forEach((matchedUrl) => {
pushTarget(matchedUrl, { allowHtml: true });
});
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'),
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
const 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))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
@@ -572,6 +606,7 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
diffBlocks,
rankedLinkTargets,
linkCardTargets,
promptTargets,
};
}
@@ -977,6 +1012,7 @@ type ChatConversationViewProps = {
showScrollToBottom: boolean;
copiedMessageId: number | null;
draft: string;
draftVersion: number;
composerAttachments: ChatComposerAttachment[];
requestStateMap: Map<string, ChatConversationRequest>;
isConversationLoading: boolean;
@@ -1015,6 +1051,7 @@ type ChatConversationViewProps = {
onCancelMessage: (message: ChatMessage) => void;
onDeleteRequest: (message: ChatMessage) => void;
onRemoveQueuedRequest: (requestId: string) => void;
onSubmitPrompt: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
};
export function ChatConversationView({
@@ -1026,6 +1063,7 @@ export function ChatConversationView({
showScrollToBottom,
copiedMessageId,
draft,
draftVersion,
composerAttachments,
requestStateMap,
isConversationLoading,
@@ -1064,7 +1102,10 @@ export function ChatConversationView({
onCancelMessage,
onDeleteRequest,
onRemoveQueuedRequest,
onSubmitPrompt,
}: ChatConversationViewProps) {
const [composerDraft, setComposerDraft] = useState(draft);
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingPromptSelection>>({});
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
@@ -1073,37 +1114,41 @@ export function ChatConversationView({
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
const [composerDraft, setComposerDraft] = useState(draft);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
const lastReportedDraftRef = useRef(draft);
useEffect(() => {
if (draft === lastReportedDraftRef.current) {
return;
}
setComposerDraft(draft);
}, [draft]);
}, [draft, draftVersion]);
useEffect(() => {
if (composerDraft === lastReportedDraftRef.current) {
return;
const pendingPromptSelectionEntries = useMemo(
() => Object.entries(pendingPromptSelections).sort(([left], [right]) => left.localeCompare(right)),
[pendingPromptSelections],
);
const pendingPromptSelectionCount = pendingPromptSelectionEntries.length;
const buildComposerOutboundText = (draftText: string) => {
const trimmedDraftText = draftText.trim();
if (pendingPromptSelectionEntries.length === 0) {
return draftText;
}
const timeoutId = window.setTimeout(() => {
lastReportedDraftRef.current = composerDraft;
startTransition(() => {
onDraftChange(composerDraft);
});
}, 120);
const promptTexts = pendingPromptSelectionEntries.map(([, selection], index) => {
const mergedFreeText = [selection.freeText.trim(), index === pendingPromptSelectionEntries.length - 1 ? trimmedDraftText : '']
.filter(Boolean)
.join('\n\n');
return () => {
window.clearTimeout(timeoutId);
};
}, [composerDraft, onDraftChange]);
return buildPromptResponseText(selection.target, {
...selection,
freeText: mergedFreeText,
});
});
return promptTexts.join('\n\n');
};
const orderedMessages = useMemo(() => {
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
@@ -1178,6 +1223,39 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages];
}, [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 isChatTypeReadonly = isChatTypeSelectionLocked;
const visiblePreviewItems = useMemo(() => {
@@ -1546,8 +1624,12 @@ export function ChatConversationView({
const composerPlaceholder = isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: isMobileViewport
? '메시지를 입력하세요.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
? pendingPromptSelectionCount > 0
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다.'
: '메시지를 입력하세요.'
: pendingPromptSelectionCount > 0
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다. Ctrl+Enter로 바로 전송할 수 있습니다.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
const renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id);
@@ -1555,6 +1637,7 @@ export function ChatConversationView({
const lines = extractActivityLines(message);
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
const activityCountLabel = `${lines.length}개 로그`;
const request = requestStateMap.get(requestId);
return (
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
@@ -1601,9 +1684,12 @@ export function ChatConversationView({
</Button>
</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-activity-card__summary" aria-label="실시간 상태">
<span className="app-chat-activity-card__summary-label"> </span>
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
<div className="app-chat-activity-card__summary-grid">
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
<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>
{isExpanded ? (
@@ -1748,7 +1834,8 @@ export function ChatConversationView({
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
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
? getMissingRequestMessageText(message)
: isRecoveredExecutionFailure
@@ -1761,9 +1848,14 @@ export function ChatConversationView({
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
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 =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const isPromptReadOnly = message.id !== lastNonSystemMessageId;
const stackClassName = [
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
@@ -1909,6 +2001,42 @@ export function ChatConversationView({
{linkCardTargets.map((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) => (
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
@@ -2064,9 +2192,7 @@ export function ChatConversationView({
icon={<ThunderboltOutlined />}
aria-label="즉시 요청"
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSendImmediate(composerDraft);
onSendImmediate(buildComposerOutboundText(composerDraft));
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
@@ -2075,9 +2201,7 @@ export function ChatConversationView({
icon={<SendOutlined />}
aria-label="큐로 보내기"
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSend(composerDraft);
onSend(buildComposerOutboundText(composerDraft));
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
@@ -2096,6 +2220,27 @@ export function ChatConversationView({
{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
className={`app-chat-panel__composer-input-shell${
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
@@ -2138,7 +2283,9 @@ export function ChatConversationView({
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
setComposerDraft(event.target.value);
const nextValue = event.target.value;
setComposerDraft(nextValue);
onDraftChange(nextValue);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
@@ -2158,9 +2305,7 @@ export function ChatConversationView({
event.preventDefault();
event.stopPropagation();
lastReportedDraftRef.current = event.currentTarget.value;
onDraftChange(event.currentTarget.value);
onSend(event.currentTarget.value);
onSend(buildComposerOutboundText(composerDraft));
}}
/>
<Button

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
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) {
const normalized = String(value ?? '').trim();
@@ -11,13 +12,23 @@ function extractEmbeddedResourcePath(value: string) {
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
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) {
return normalized.slice(publicMarkerIndex);
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)}`;
}
return normalized;

View File

@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
import { reportClientError } from '../errorLogApi';
import { copyTextToClipboard } from '../../../utils/clipboard';
import type {
ChatActivityEvent,
ChatConversationActivityLog,
@@ -839,32 +840,7 @@ export async function diagnoseConnectionFailure(targetUrl: string, closeEvent?:
}
export 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('클립보드를 사용할 수 없습니다.');
}
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);
}
return copyTextToClipboard(text);
}
export type PreviewCopyResult = 'text' | 'image' | 'url';
@@ -1419,6 +1395,23 @@ export async function deleteChatConversationRoom(sessionId: string) {
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) {
const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>(
`/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`,
@@ -1450,13 +1443,16 @@ function areChatMessagesEquivalent(left: ChatMessage[], right: ChatMessage[]) {
return left.every((message, index) => {
const other = right[index];
const leftParts = JSON.stringify(message.parts ?? []);
const rightParts = JSON.stringify(other?.parts ?? []);
return (
other &&
message.id === other.id &&
message.author === other.author &&
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,
...incoming,
text: nextText,
parts: incoming.parts ?? existingMessage.parts ?? [],
deliveryStatus: null,
retryCount: 0,
};

View File

@@ -13,6 +13,7 @@ export {
createIntroMessage,
createLocalMessage,
cancelChatRuntimeJob,
clearChatConversationRoom,
deleteChatConversationRequest,
deleteChatConversationRoom,
fetchChatConversationDetail,

View File

@@ -1,9 +1,17 @@
import type { ChatMessagePart } from './types';
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_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 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) {
return String(value ?? '').trim();
@@ -21,6 +29,25 @@ function normalizeUrl(value: string) {
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)) {
return normalized;
}
@@ -28,6 +55,116 @@ function normalizeUrl(value: string) {
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) {
try {
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) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
@@ -145,7 +340,38 @@ export function extractChatMessageParts(text: string) {
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)) {
return true;
@@ -157,6 +383,15 @@ export function extractChatMessageParts(text: string) {
};
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);
if (!matched) {
@@ -190,7 +425,7 @@ export function extractChatMessageParts(text: string) {
}
const latestPart = parts.at(-1);
if (latestPart && isInternalResourceUrl(latestPart.url)) {
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
parts.pop();
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
keptLines.push(latestPart.url);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,64 @@ export type ChatMessagePart =
title: string;
url: string;
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 = {
@@ -58,6 +116,8 @@ export type ChatConversationSummary = {
currentJobMessage: string | null;
currentQueueSize: number;
currentStatusUpdatedAt: string | null;
isPendingWork: boolean;
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
lastRequestPreview: string;
lastMessagePreview: string;
lastResponsePreview: string;

View File

@@ -10,13 +10,10 @@ export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
error: '실패 (0)',
};
export const DOCS_DEFAULT_FOLDER = 'worklogs';
export const DOCS_DEFAULT_FOLDER = 'project';
export const DOCS_FOLDER_LABELS: Record<string, string> = {
worklogs: '작업일지',
features: '기능문서',
components: '컴포넌트문서',
templates: '문서템플릿',
project: '프로젝트 구조',
};
export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {

View File

@@ -22,16 +22,13 @@ export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'mana
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
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 PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
export const PLAN_GROUP_LABEL = '작업';
export const DOCS_FOLDER_LABELS: Record<string, string> = {
worklogs: '작업일지',
features: '기능문서',
components: '컴포넌트문서',
templates: '문서템플릿',
project: '프로젝트 구조',
};
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {

View File

@@ -10,26 +10,48 @@ const featureMarkdownModules = import.meta.glob('../../features/**/*.md', {
import: 'default',
}) 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>>,
entries: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>>,
): MarkdownDocumentEntry[] {
const sortedPaths = Object.keys(modules).sort((left, right) => {
const isLeftWorklog = left.includes('/docs/worklogs/');
const isRightWorklog = right.includes('/docs/worklogs/');
if (isLeftWorklog && isRightWorklog) {
return right.localeCompare(left);
}
return left.localeCompare(right);
});
return sortedPaths.map((path, index) => ({
path,
load: modules[path],
order: index,
}));
return entries
.filter((entry) => typeof modules[entry.path] === 'function')
.map((entry, index) => ({
...entry,
load: modules[entry.path],
order: index,
}));
}
export const docsMarkdownEntries = createMarkdownEntries(docsMarkdownModules);
export const featureMarkdownEntries = createMarkdownEntries(featureMarkdownModules);
function createFilteredMarkdownEntries(
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,
);

View File

@@ -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
component-name/
├─ ComponentName.tsx
├─ ComponentName.css
├─ index.ts
├─ types/ # 외부 노출 타입 또는 내부 분리 타입
├─ plugins/ # plugin factory 또는 preset
└─ samples/ # Docs/APIs 화면에서 쓰는 예제
src/components
├─ common
├─ inputs
├─ markdownPreview
├─ navigation
├─ previewer
└─ ...
```
- 진입점은 항상 해당 폴더의 `index.ts`로 둡니다.
- 외부에서 직접 import 해야 하는 타입은 `index.ts` 또는 `types/index.ts`를 통해 다시 export 합니다.
- CSS가 필요하면 컴포넌트 폴더 내부에 함께 둡니다.
- 복잡한 로직이 생기면 `types`, `plugins`, `samples`처럼 역할별 하위 폴더로 분리합니다.
- `common`: 범용 보조 UI
- `inputs`: 입력 계열 컴포넌트
- 그 외 폴더: 독립 재사용 컴포넌트 패키지
## 구현 규약
## 기준
- 공통 패키지에는 프로젝트 화면에 종속된 상태나 라우팅 의존을 넣지 않습니다.
- 컴포넌트 이름, 파일명, export 이름은 PascalCase를 유지합니다. 폴더명은 기존 저장소 스타일대로 kebab-case 또는 lowerCamelCase를 따릅니다.
- 라이브러리로 공개할 컴포넌트는 최종적으로 `src/index.ts`에서 다시 export 되어야 합니다.
- 샘플이 필요한 컴포넌트는 `samples/Sample.tsx`를 기본 진입 예제로 두고, 변형 예제는 같은 폴더에 추가합니다.
- plugin 확장형 컴포넌트는 `plugins/*.plugin.ts` 또는 `plugins/index.ts`에서 생성 함수를 모읍니다.
- 공통 타입은 컴포넌트 폴더 안에서 우선 관리하고, 여러 컴포넌트가 공유할 때만 상위 공통 타입으로 승격합니다.
## 문서 규약
- 화면 사용법과 제약은 `docs/components/*.md`에 문서화합니다.
- 새 컴포넌트를 추가하면 최소한 목적, 주요 props, 샘플 위치, plugin 여부를 문서에 남깁니다.
- 패키지 구조나 규약이 바뀌면 이 문서와 해당 컴포넌트 문서를 함께 갱신합니다.
- 화면 전용 상태와 비즈니스 로직은 넣지 않습니다.
- 외부 진입점은 각 폴더의 `index.ts`를 사용합니다.
- 복잡도가 커지면 `types`, `plugins`, `samples`로 분리합니다.

View 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>
);
}

View File

@@ -21,6 +21,7 @@ import type {
EvidenceAttachmentPreviewBodyProps,
EvidenceAttachmentStripProps,
} from './types';
import { copyTextToClipboard } from '../../utils/clipboard';
import './EvidenceAttachmentStrip.css';
const { Paragraph, Text } = Typography;
@@ -108,25 +109,7 @@ function getAttachmentTypeIcon(kind: EvidenceAttachmentKind): ReactNode {
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
const copyValue = attachment.copyValue ?? attachment.value;
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);
return copyTextToClipboard(copyValue);
}
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {

View File

@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons';
import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { copyTextToClipboard } from '../../utils/clipboard';
import {
CODEX_DIFF_STATUS_LABEL_MAP,
CodexDiffBlock,
@@ -100,30 +101,9 @@ export function CodexDiffPreviewer({
const canShowDiff = Boolean(diffText);
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) {
try {
await copyText(content);
await copyTextToClipboard(content);
messageApi.success('복사했습니다.');
} catch {
messageApi.error('복사에 실패했습니다.');

View File

@@ -169,17 +169,22 @@
padding: 1px 6px;
border-radius: 999px;
background: rgba(22, 93, 255, 0.08);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.previewer-ui__markdown pre code {
display: block;
padding: 0;
background: transparent;
white-space: pre;
}
.previewer-ui__markdown pre {
margin: 0;
padding: 14px;
max-width: 100%;
overflow: auto;
color: #dbe7ff;
background: linear-gradient(180deg, #0f172a 0%, #111f39 100%);
@@ -187,6 +192,10 @@
}
.previewer-ui__editor {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
border: 0;
@@ -235,6 +244,10 @@
}
.previewer-ui__editor-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 0;
}

View File

@@ -9,6 +9,7 @@ import { Button, Empty, Input, Select, message } from 'antd';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { InlineImage } from '../common/InlineImage';
import { copyTextToClipboard } from '../../utils/clipboard';
import { CodexDiffBlock } from './CodexDiffBlock';
import type { PreviewerUIProps } from './types';
import { inferCodeLanguage, renderEditorBlock } from './renderers';
@@ -121,27 +122,6 @@ function renderMarkdown(markdown: string) {
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') {
if (typeof document === 'undefined') {
throw new Error('다운로드를 사용할 수 없습니다.');
@@ -340,7 +320,7 @@ export function PreviewerUI({
}
try {
await copyText(resolvedCopyValue);
await copyTextToClipboard(resolvedCopyValue);
messageApi.success('복사했습니다.');
} catch {
messageApi.error('복사에 실패했습니다.');

View File

@@ -27,6 +27,7 @@ import {
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { copyTextToClipboard } from '../../utils/clipboard';
import {
createBoardPost,
deleteBoardPost,
@@ -123,23 +124,6 @@ function resolveBoardAttachmentSessionId(
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) {
if (!item) {
return false;
@@ -562,7 +546,7 @@ export function BoardPage() {
}
try {
await copyText(draft.content);
await copyTextToClipboard(draft.content);
messageApi.success('공통 메모를 복사했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '공통 메모 복사에 실패했습니다.');

View File

@@ -1,50 +1,16 @@
# Layout Feature
프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리합니다.
`src/features/layout`은 현재 프로젝트 전용 레이아웃 기능을 둡니다.
## 포함 항목
## 포함 범위
- 컴포넌트 샘플 레이아웃
- 위젯 샘플 레이아웃
- Markdown preview 리스트 레이아웃
- `Layout Editor`와 저장 레이아웃 흐름
- 문서 미리보기 레이아웃
- `Layout Editor`
## 규칙
## 기준
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
## Layout Editor 기준
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
용어 기준:
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
허용 범위:
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
구현 우선순위:
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
금지 해석:
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
- 현재 프로젝트 화면에만 의미가 있으면 여기 둡니다.
- 공통 재사용 가치가 높아지면 `src/components` 또는 `src/widgets`로 승격합니다.
- `Layout Editor`의 기능 명세는 위젯 스펙 문서가 아니라 현재 레이아웃 안에서의 역할 설명으로 취급합니다.

View File

@@ -1,9 +1,9 @@
# Features Overview
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
`src/features`는 프로젝트 전용 기능 영역입니다.
## 목적
## 구조 기준
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장
- 공통 UI로 분리하기 어려운 화면 로직은 `src/features`에 둡니다.
- 재사용 가능한 UI는 `src/components`, 카드형 조합은 `src/widgets`로 분리합니다.
- 레이아웃 전용 기능은 `src/features/layout`에서 관리합니다.

View File

@@ -1160,7 +1160,7 @@ export function PlanBoardPage({
}
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '작업 목록을 불러오지 못했습니다.');
setErrorMessage(error instanceof Error ? error.message : '자동화 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
@@ -1703,60 +1703,66 @@ export function PlanBoardPage({
? currentReleaseUsageSummaryByHistoryId.get(selectedSourceWork.id) ?? null
: null;
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 (
<div className="plan-board-page">
{contextHolder}
<Card className="plan-board-page__overview" bordered={false}>
<Flex justify="space-between" align="center" gap={12} wrap>
<div>
<Title level={4}></Title>
<Paragraph className="plan-board-page__intro">
, .
</Paragraph>
</div>
{isMobileAutomationLayout ? null : (
<Card className="plan-board-page__overview" bordered={false}>
<Flex justify="space-between" align="center" gap={12} wrap>
<div>
<Title level={4}></Title>
<Paragraph className="plan-board-page__intro">
, .
</Paragraph>
</div>
<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>
</Flex>
</Card>
{overviewActionContent}
</Flex>
</Card>
)}
{isRestrictedClient ? (
<Alert
@@ -1788,7 +1794,7 @@ export function PlanBoardPage({
showIcon
type="warning"
className="plan-board-page__alert"
message="작업 요청 메뉴를 아직 사용할 수 없습니다."
message="자동화 현황 메뉴를 아직 사용할 수 없습니다."
description={<ExpandableDetailText text={errorMessage} />}
action={
<Button
@@ -1804,107 +1810,128 @@ export function PlanBoardPage({
) : null}
<PlanListDetailLayout
listTitle={`작업 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
listTitle={`자동화 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
listExtra={<Text code>{filteredItems.length} items</Text>}
listContent={
<>
{quickFilter ? (
<Alert
showIcon
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
className="plan-board-page__alert"
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
description={
quickFilter === 'automation-failed'
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
: quickFilter === 'working'
? '현재 상태가 작업중인 항목만 추렸습니다.'
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
}
/>
) : null}
{selectedItem?.lastError ? (
<Alert
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}
<div className="plan-board-page__list-panel">
<div className="plan-board-page__list-controls">
{isMobileAutomationLayout ? (
<div className="plan-board-page__mobile-overview">
<Flex vertical gap={10}>
<div>
<Text strong className="plan-board-page__mobile-overview-title">
</Text>
<Paragraph className="plan-board-page__mobile-overview-description">
, .
</Paragraph>
</div>
{overviewActionContent}
</Flex>
</div>
) : null}
{quickFilter ? (
<Alert
showIcon
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
className="plan-board-page__alert"
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
description={
quickFilter === 'automation-failed'
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
: quickFilter === 'working'
? '현재 상태가 작업중인 항목만 추렸습니다.'
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
}
/>
) : null}
{selectedItem?.lastError ? (
<Alert
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
allowClear
value={searchKeyword}
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}
<Input.Search
allowClear
value={searchKeyword}
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
disabled={isRestrictedClient}
onChange={setWorkerStateFilter}
onChange={(event) => {
setSearchKeyword(event.target.value);
}}
/>
<Select
size="small"
value={releaseStateFilter}
options={RELEASE_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setReleaseStateFilter}
/>
<Select
size="small"
value={mainStateFilter}
options={MAIN_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setMainStateFilter}
/>
<Select
size="small"
value={issueStateFilter}
options={ISSUE_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setIssueStateFilter}
/>
<Select
size="small"
value={costStateFilter}
options={COST_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setCostStateFilter}
/>
</Flex>
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
<Select
size="small"
value={workerStateFilter}
options={WORKER_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setWorkerStateFilter}
/>
<Select
size="small"
value={releaseStateFilter}
options={RELEASE_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setReleaseStateFilter}
/>
<Select
size="small"
value={mainStateFilter}
options={MAIN_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setMainStateFilter}
/>
<Select
size="small"
value={issueStateFilter}
options={ISSUE_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setIssueStateFilter}
/>
<Select
size="small"
value={costStateFilter}
options={COST_STATE_FILTER_OPTIONS}
disabled={isRestrictedClient}
onChange={setCostStateFilter}
/>
</Flex>
</div>
<PlanItemList
activeDraftId={draft.id}
currentPage={currentListPage}
editorOpen={editorOpen}
hasAccess={hasAccess}
items={filteredItems}
jangsingProcessingSavingId={jangsingProcessingSavingId}
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
searchKeyword={searchKeyword}
usageSummaryByPlanId={usageSummaryByPlanId}
onChangePage={setCurrentListPage}
onChangeJangsingProcessing={handleJangsingProcessingChange}
onSelectItem={handleSelectItem}
/>
</>
<div className="plan-board-page__list-scroller">
<PlanItemList
activeDraftId={draft.id}
currentPage={currentListPage}
editorOpen={editorOpen}
hasAccess={hasAccess}
items={filteredItems}
jangsingProcessingSavingId={jangsingProcessingSavingId}
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
searchKeyword={searchKeyword}
usageSummaryByPlanId={usageSummaryByPlanId}
onChangePage={setCurrentListPage}
onChangeJangsingProcessing={handleJangsingProcessingChange}
onSelectItem={handleSelectItem}
/>
</div>
</div>
}
desktopDetailOpen={editorOpen}
mobileDetailOpen={editorOpen}
@@ -1934,7 +1961,7 @@ export function PlanBoardPage({
emptyDetailTitle="상세 보기"
detailContent={
!sourceViewerOpen ? (
<>
<div className="plan-board-page__detail-panel">
{selectedItem ? (
<Alert
type={selectedItem.hasOpenIssues ? 'warning' : 'info'}
@@ -2488,7 +2515,7 @@ export function PlanBoardPage({
</Card>
) : null}
</div>
</>
</div>
) : (
<div className="plan-board-page__overlay-body">
<Flex justify="space-between" align="start" gap={12} wrap>

View File

@@ -103,6 +103,7 @@ export function PlanListDetailLayout({
const showMobileDetail = mobileOverlayEnabled && mobileDetailOpen;
const showMobileOverlay = showMobileDetail && mobileLayoutMode === 'overlay';
const showMobileDetailOnly = showMobileDetail && mobileLayoutMode === 'detail-only';
const hideInlineDetailCardOnMobile = mobileOverlayEnabled && (!showMobileDetail || showMobileOverlay);
useBodyScrollLock(showMobileOverlay || showMobileDetailOnly);
@@ -118,7 +119,7 @@ export function PlanListDetailLayout({
showMobileDetailOnly ? ` ${classNamePrefix}__list-card--mobile-hidden` : ''
}`;
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` : ''}`;
const detailActionsClassName = `${classNamePrefix}__detail-actions`;
const detailEmptyClassName = `${classNamePrefix}__detail-empty`;

View File

@@ -136,6 +136,25 @@
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 {
display: flex;
flex: 1 1 auto;
@@ -148,6 +167,40 @@
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 {
margin: 12px 0 16px;
}
@@ -592,6 +645,12 @@
}
@media (max-width: 960px) {
.plan-board-page {
overflow: auto;
overscroll-behavior: auto;
-webkit-overflow-scrolling: touch;
}
.plan-board-page__split--mobile-detail-only {
gap: 0;
}
@@ -600,6 +659,65 @@
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__detail-card--mobile-hidden {
display: none;
@@ -911,6 +1029,10 @@
gap: 12px;
}
.plan-board-page__list-card .ant-card-head {
padding-inline: 14px;
}
.plan-board-page__form > div {
padding: 14px;
border-radius: 16px;
@@ -941,6 +1063,10 @@
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 {
align-items: flex-start;
flex-direction: column;

View File

@@ -4,16 +4,31 @@ import { useEffect, useMemo, useState } from 'react';
import { useTokenAccess } from '../../app/main/tokenAccess';
import { DataStatePanel } from '../../components/dataStatePanel';
import { copyText } from '../../app/main/mainChatPanel';
import { fetchServerCommands, restartServerCommand } from './api';
import type { ServerCommandItem, ServerCommandKey } from './types';
import {
ServerCommandApiError,
fetchServerCommands,
fetchServerRestartReservation,
restartServerCommand,
scheduleServerRestartReservation,
} from './api';
import type {
RestartReservationWorkloadSummary,
ServerCommandItem,
ServerCommandKey,
ServerRestartReservation,
ServerRestartReservationAutoFix,
ServerRestartReservationWorkItem,
} from './types';
import './serverCommand.css';
const { Paragraph, Text, Title } = Typography;
type RestartErrorInfo = {
tone: 'error' | 'warning';
title: string;
detail: string;
missingScriptPath: string | null;
canScheduleReservation: boolean;
};
type LastActionInfo = {
@@ -83,28 +98,120 @@ function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErro
const missingScriptPath = missingScriptMatch[1].trim();
return {
tone: 'error',
title: `${targetLabel} 재기동 실패`,
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
missingScriptPath,
canScheduleReservation: false,
};
}
return {
tone: 'error',
title: `${targetLabel} 재기동 실패`,
detail,
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() {
const { hasAccess } = useTokenAccess();
const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<ServerCommandItem[]>([]);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [reservation, setReservation] = useState<ServerRestartReservation | null>(null);
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
const [copyingRestartError, setCopyingRestartError] = useState(false);
const [schedulingReservation, setSchedulingReservation] = useState(false);
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
test: { 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(() => {
if (!hasAccess) {
setItems([]);
setLoading(false);
setErrorMessage(null);
setReservation(null);
return;
}
void loadItems();
void Promise.all([
loadItems(),
loadReservation({ silent: true }),
]);
}, [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(() => {
return items.reduce(
(result, item) => {
@@ -156,6 +305,7 @@ export function ServerCommandPage() {
try {
const result = await restartServerCommand(key);
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
void loadReservation({ silent: true });
setLastActionByKey((previous) => ({
...previous,
[result.item.key]: {
@@ -169,6 +319,11 @@ export function ServerCommandPage() {
);
} catch (error) {
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 : '서버 재기동에 실패했습니다.';
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
} 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) {
return (
<Card className="server-command-page__card" bordered={false}>
@@ -229,7 +404,16 @@ export function ServerCommandPage() {
</Col>
</Row>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
<Button
icon={<ReloadOutlined />}
onClick={() => {
void Promise.all([
loadItems(),
loadReservation({ silent: true }),
]);
}}
loading={loading}
>
</Button>
</Space>
@@ -239,8 +423,8 @@ export function ServerCommandPage() {
{restartErrorInfo ? (
<Alert
showIcon
type="error"
message="재기동 에러"
type={restartErrorInfo.tone}
message={restartErrorInfo.tone === 'warning' ? '재기동 예약 필요' : '재기동 에러'}
description={
<Space direction="vertical" size={8} className="server-command-page__alert-body">
<Text strong>{restartErrorInfo.title}</Text>
@@ -253,20 +437,129 @@ export function ServerCommandPage() {
</Space>
}
action={
<Button
type="text"
size="small"
icon={<CopyOutlined />}
loading={copyingRestartError}
aria-label="에러 메시지 복사"
onClick={() => {
void handleCopyRestartError();
}}
/>
<Space size={4}>
{restartErrorInfo.canScheduleReservation ? (
<Button
size="small"
type="primary"
loading={schedulingReservation}
onClick={() => {
void handleScheduleReservation();
}}
>
</Button>
) : null}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
loading={copyingRestartError}
aria-label="에러 메시지 복사"
onClick={() => {
void handleCopyRestartError();
}}
/>
</Space>
}
/>
) : 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 ? (
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
) : errorMessage ? (
@@ -275,7 +568,15 @@ export function ServerCommandPage() {
title="서버 명령 메뉴를 불러오지 못했습니다."
description={errorMessage}
actions={
<Button type="primary" onClick={() => void loadItems()}>
<Button
type="primary"
onClick={() => {
void Promise.all([
loadItems(),
loadReservation({ silent: true }),
]);
}}
>
</Button>
}

View File

@@ -1,20 +1,25 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
import type {
RestartReservationWorkloadSummary,
ServerCommandActionResult,
ServerCommandItem,
ServerCommandKey,
ServerRestartReservationAutoFix,
ServerRestartReservation,
ServerRestartReservationStatus,
ServerRestartReservationWorkItem,
} from './types';
class ServerCommandApiError extends Error {
export class ServerCommandApiError extends Error {
status: number;
workloadSummary: RestartReservationWorkloadSummary | null;
constructor(message: string, status: number) {
constructor(message: string, status: number, workloadSummary: RestartReservationWorkloadSummary | null = null) {
super(message);
this.name = 'ServerCommandApiError';
this.status = status;
this.workloadSummary = workloadSummary;
}
}
@@ -131,13 +136,30 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
if (!response.ok) {
const text = await response.text();
let payload: { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> } | null = null;
try {
const payload = JSON.parse(text) as { message?: string };
throw new ServerCommandApiError(payload.message || '서버 명령 요청에 실패했습니다.', response.status);
} catch {
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
payload = JSON.parse(text) as { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> };
} catch {}
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>;
@@ -281,6 +303,7 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
return value === 'waiting'
|| value === 'ready'
|| value === 'executing'
|| value === 'recovering'
|| value === 'completed'
|| value === 'cancelled'
|| value === 'failed'
@@ -288,6 +311,83 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
: '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 {
if (!response || typeof response !== 'object') {
throw new Error('재기동 예약 응답 형식이 올바르지 않습니다.');
@@ -309,12 +409,12 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
const reservation = item as Partial<ServerRestartReservation>;
const workloadSummary =
reservation.workloadSummary && typeof reservation.workloadSummary === 'object'
? reservation.workloadSummary
? (reservation.workloadSummary as Partial<RestartReservationWorkloadSummary>)
: {};
return {
enabled: reservation.enabled === true,
target: reservation.target === 'all' ? 'all' : 'all',
target: normalizeServerRestartReservationTarget(reservation.target),
status: normalizeServerRestartReservationStatus(reservation.status),
requestedAt: typeof reservation.requestedAt === 'string' ? reservation.requestedAt : 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,
autoExecuteDelaySeconds: Number(reservation.autoExecuteDelaySeconds ?? 10),
updatedAt: typeof reservation.updatedAt === 'string' ? reservation.updatedAt : null,
workItems: normalizeServerRestartReservationWorkItems(reservation.workItems),
autoFix: normalizeServerRestartReservationAutoFix(reservation.autoFix),
};
}

View File

@@ -43,6 +43,11 @@
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 {
min-width: 0;
}
@@ -107,6 +112,21 @@
-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 {
width: 104px;
}

View File

@@ -1,5 +1,12 @@
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
export type RestartReservationWorkloadSummary = {
codexRunningCount: number;
codexQueuedCount: number;
automationRunningCount: number;
automationQueuedCount: number;
};
export type ServerCommandItem = {
key: ServerCommandKey;
label: string;
@@ -44,25 +51,44 @@ export type ServerRestartReservationStatus =
| 'waiting'
| 'ready'
| 'executing'
| 'recovering'
| 'completed'
| 'cancelled'
| '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 = {
enabled: boolean;
target: 'all';
target: 'all' | 'test' | 'work-server';
status: ServerRestartReservationStatus;
requestedAt: string | null;
requestedByClientId: string | null;
lastCheckedAt: string | null;
nextCheckAt: string | null;
waitingReason: string | null;
workloadSummary: {
codexRunningCount: number;
codexQueuedCount: number;
automationRunningCount: number;
automationQueuedCount: number;
};
workloadSummary: RestartReservationWorkloadSummary;
startedAt: string | null;
completedAt: string | null;
cancelledAt: string | null;
@@ -73,4 +99,6 @@ export type ServerRestartReservation = {
autoExecuteAt: string | null;
autoExecuteDelaySeconds: number;
updatedAt: string | null;
workItems: ServerRestartReservationWorkItem[];
autoFix: ServerRestartReservationAutoFix;
};

View File

@@ -7,17 +7,22 @@ import { NavigationRoute, registerRoute } from 'workbox-routing';
clientsClaim();
cleanupOutdatedCaches();
const navigationFallbackDenylist = [
/^\/api\/chat\/resources(?:\/|$)/,
/^\/(?:public\/)?\.codex_chat(?:\/|$)/,
];
const manifest = self.__WB_MANIFEST;
if (Array.isArray(manifest) && manifest.length > 0) {
precacheAndRoute(manifest);
const navigationHandler = createHandlerBoundToURL('/index.html');
registerRoute(new NavigationRoute(navigationHandler));
registerRoute(new NavigationRoute(navigationHandler, { denylist: navigationFallbackDenylist }));
} else {
registerRoute(
new NavigationRoute(({ 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) {
if (!client || typeof client.url !== 'string') {
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) {
if (!isChatNotificationPayload(payload)) {
return Promise.resolve(false);
}
const notificationSessionId = extractNotificationSessionId(payload);
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) =>
clientList.some((client) => {
if (notificationSessionId && isVisibleChatClientForSession(client, notificationSessionId)) {
return true;
}
return isVisibleAppClient(client);
}),
clientList.some((client) => isVisibleAppClient(client)),
);
}

117
src/utils/clipboard.ts Normal file
View 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('브라우저가 클립보드 복사를 차단했습니다.');
}

View File

@@ -190,7 +190,7 @@ function formatPercent(value: number) {
}
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) {

View File

@@ -33,7 +33,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '타당성 검토에서 기술적 타당성을 확인할 때 가장 먼저 보는 관점은 무엇인가요?',
answerValue: '3',
explanation: '현 기술 스택과 인력으로 요구 기능을 구현 가능한지 확인하는 것이 기술적 타당성의 핵심입니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['타당성', '분석'],
correctRate: 0.71,
choices: ['사무실 좌석 배치를 바꾸는 비용', '광고 문구의 완성도', '현재 기술과 인력으로 구현 가능한지 여부', '배경 이미지 해상도'],
@@ -105,7 +105,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: 'DevOps 도입의 직접적인 목표로 가장 적절한 것은 무엇인가요?',
answerValue: '4',
explanation: '개발과 운영 협업을 강화해 배포 속도와 안정성을 함께 높이는 것이 목적입니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['DevOps', '협업'],
correctRate: 0.74,
choices: ['운영팀을 없앤다', '테스트를 생략한다', '문서를 금지한다', '개발과 운영의 피드백 주기를 단축한다'],
@@ -117,7 +117,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '기존 기능 수정 후 주변 기능이 깨지지 않았는지 확인하는 테스트는 무엇인가요?',
answerValue: '2',
explanation: '회귀 테스트는 변경 이후 기존 기능의 정상 동작을 다시 확인하는 테스트입니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['테스트', '회귀'],
correctRate: 0.77,
choices: ['인수 테스트', '회귀 테스트', '알파 테스트', '베타 테스트'],
@@ -153,7 +153,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '시맨틱 버저닝에서 `2.4.1`의 마지막 숫자가 증가하는 일반적인 경우는 무엇인가요?',
answerValue: '4',
explanation: '패치 버전은 하위 호환 가능한 버그 수정이 있을 때 증가합니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['배포', '버전관리'],
correctRate: 0.72,
choices: ['대규모 구조 개편이 있을 때', '하위 호환이 깨지는 변경일 때', '새로운 주요 기능 묶음을 추가할 때', '하위 호환 가능한 버그 수정일 때'],
@@ -189,7 +189,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '집계 결과에 조건을 적용할 때 `WHERE` 대신 주로 사용하는 절은 무엇인가요?',
answerValue: '4',
explanation: '집계 이후의 그룹 조건은 HAVING 절에서 처리합니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['SQL', '집계'],
correctRate: 0.79,
choices: ['ORDER BY', 'LIMIT', 'GROUP SETS', 'HAVING'],
@@ -201,7 +201,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '공통 키가 있는 행만 결과로 가져오려면 어떤 조인을 사용해야 하나요?',
answerValue: '1',
explanation: 'INNER JOIN은 양쪽 테이블에서 조건이 일치하는 행만 반환합니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['SQL', '조인'],
correctRate: 0.81,
choices: ['INNER JOIN', 'LEFT OUTER JOIN', 'CROSS JOIN', 'SELF JOIN'],
@@ -237,7 +237,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '재귀 함수가 무한 호출되지 않도록 반드시 갖춰야 하는 요소는 무엇인가요?',
answerValue: '1',
explanation: '재귀 종료 조건(base case)이 있어야 반복 호출이 멈춥니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['기초', '재귀'],
correctRate: 0.82,
choices: ['종료 조건', '전역 변수', '배열 정렬', 'GUI 이벤트'],
@@ -273,7 +273,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '데이터와 메서드를 하나의 단위로 묶고 외부 접근을 제한하는 개념은 무엇인가요?',
answerValue: '3',
explanation: '캡슐화는 내부 구현을 숨기고 필요한 인터페이스만 노출합니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['객체지향', '캡슐화'],
correctRate: 0.78,
choices: ['오버로딩', '추상화', '캡슐화', '가비지 컬렉션'],
@@ -285,7 +285,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '선입선출(FIFO) 구조로 동작하는 자료구조는 무엇인가요?',
answerValue: '2',
explanation: 'Queue는 먼저 들어온 데이터가 먼저 나가는 FIFO 구조입니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['자료구조', '큐'],
correctRate: 0.8,
choices: ['Stack', 'Queue', 'Tree', 'Graph'],
@@ -309,7 +309,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '변경관리(Change Management)의 핵심 목적은 무엇인가요?',
answerValue: '4',
explanation: '변경 요청을 통제해 서비스 영향과 위험을 줄이려는 것이 핵심입니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['운영', '변경관리'],
correctRate: 0.73,
choices: ['문서 작성을 금지한다', '모든 변경을 즉시 반영한다', '개발 서버만 유지한다', '변경 영향과 승인 절차를 관리한다'],
@@ -333,7 +333,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
body: '최소 권한 원칙(Principle of Least Privilege)의 설명으로 가장 적절한 것은 무엇인가요?',
answerValue: '1',
explanation: '업무 수행에 필요한 최소한의 권한만 부여해야 보안 위험을 줄일 수 있습니다.',
difficulty: 'easy',
difficulty: 'medium',
tags: ['보안', '권한'],
correctRate: 0.76,
choices: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],

View File

@@ -1,5 +1,6 @@
import type { ExamCategory, QuestionRecord, QuestionSet, Subject } from './cbtTypes';
import { CBT_BONUS_QUESTION_SEEDS } from './cbtBonusQuestionSeeds';
import { CBT_SUBJECT_EXPANSION_QUESTION_SEEDS } from './cbtSubjectExpansionSeeds';
type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' | 'type' | 'isActive'> & {
examId?: string;
@@ -10,6 +11,7 @@ type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' |
const ENGINEER_SOURCE_CORE = '비공식 재구성 문제집';
const ENGINEER_SOURCE_PRACTICE = '비공식 실전형 문제집';
const WEB_SOURCE = '공개 허용 샘플 형식';
const WEB_SOURCE_ADVANCED = '공개 허용 심화 샘플 형식';
export const CBT_EXAMS: ExamCategory[] = [
{ 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-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-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 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 {
return {
@@ -78,7 +97,7 @@ function buildQuestion(seed: QuestionSeed): QuestionRecord {
sourceLabel: seed.sourceLabel ?? QUESTION_SET_SOURCE_MAP.get(seed.setId) ?? ENGINEER_SOURCE_PRACTICE,
tags: seed.tags,
correctRate: seed.correctRate,
isActive: true,
isActive: !INACTIVE_QUESTION_IDS.has(seed.id),
choices: seed.choices.map((label, index) => ({
value: String(index + 1),
label,
@@ -1098,6 +1117,7 @@ export const CBT_QUESTIONS: QuestionRecord[] = [
...QUESTION_SEEDS,
...ENGINEER_VARIANT_QUESTION_SEEDS,
...CBT_BONUS_QUESTION_SEEDS,
...CBT_SUBJECT_EXPANSION_QUESTION_SEEDS,
].map(buildQuestion);
export const QUICK_QUESTION_COUNTS = [10, 20, 40, 60, 80];

View 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: ['배경색만 바꾼다', '버튼 패딩과 실제 클릭 영역 크기를 늘린다', '스크롤을 잠근다', '폰트를 모두 소문자로 바꾼다'],
},
];

View File

@@ -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
widget-name/
├─ WidgetName.tsx
├─ WidgetName.css
index.ts
src/widgets
├─ core
├─ ag-grid-widget
api-sample-card
├─ dashboard-report-card
├─ gps-sample-card
├─ text-memo-widget
└─ registry.ts
```
- 위젯 진입점은 각 폴더의 `index.ts`입니다.
- 카드형 레이아웃이 필요하면 `WidgetShell`을 우선 사용합니다.
- 위젯 관련 공통 타입과 feature 정의는 `src/widgets/core`에 둡니다.
- 위젯 메타데이터는 `src/widgets/registry.ts`에 등록합니다.
- `core`: 공통 셸, 타입, registry 보조 코드
- 각 위젯 폴더: 실제 위젯 구현
- `registry.ts`: 위젯 메타데이터 등록점
## 구현 규약
## 기준
- 위젯 ID는 `registry.ts``id`와 컴포넌트 폴더명을 동일하게 맞춥니다.
- 위젯 제목 설명 registry를 단일 기준으로 관리하고, 화면 문자열을 위젯 내부에 중복 선언하지 않습니다.
- 기능 태그는 `WidgetFeatureKey` 범위 안에서만 사용하고, 새 태그가 필요하면 `core/types/widget.ts``core/registry/widget-features.ts`를 함께 수정합니다.
- 스크롤 이동이나 포커스 제어가 필요한 위젯은 `WidgetHandle` 계약을 따릅니다.
- 위젯이 공통 컴포넌트를 조합해도 프로젝트 전용 비즈니스 로직이 강하면 `src/features`로 이동할지 먼저 검토합니다.
## 문서 및 샘플 규약
- 위젯은 문서보다 동작 예제가 중요하므로 registry 설명과 샘플 화면에서 바로 이해될 수 있게 유지합니다.
- 컴포넌트 문서와 직접 연결되는 위젯은 `features``docs` 또는 `component-sample` 태그를 넣어 의도를 드러냅니다.
- API 연동 위젯은 데이터 소스, 실패 상태, 저장 동작을 설명하는 문서를 기능 문서 또는 관련 컴포넌트 문서에 남깁니다.
- 위젯 구조나 공통 계약이 바뀌면 이 문서와 `registry.ts` 설명을 함께 갱신합니다.
- 위젯은 카드형 조합 단위로 유지합니다.
- 제목, 설명, 기능 태그는 registry를 단일 기준으로 관리니다.
- 프로젝트 전용 로직이 강해지면 `src/features`로 이동을 우선 검토합니다.