diff --git a/docs/README.md b/docs/README.md index 6eda030..3e74043 100755 --- a/docs/README.md +++ b/docs/README.md @@ -1,184 +1,53 @@ -# Docs Guide +# 프로젝트 구조 -프로젝트 문서는 작업일지, 기능 문서, 컴포넌트 문서를 기본 축으로 운영합니다. 현재 메인 앱 `Docs` 화면은 `docs/**/*.md`를 동적으로 수집해 폴더별로 노출합니다. +이 문서는 현재 저장소의 큰 구조만 빠르게 확인하기 위한 기준 문서입니다. `Docs` 화면도 이 문서만 기본으로 읽으며, 채팅/자동화용 세부 context는 각 관리 화면에서 개별 항목으로 관리합니다. -## 0. 임시 로컬 모드 - -- 현재 저장소는 당분간 로컬 전용으로 운영합니다. -- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다. -- `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다. -- 자동화 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다. -- 자동화와 `Codex Live`는 별개로 취급하며, 자동화는 선택된 자동화 유형 context만 우선 참조합니다. -- Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다. - -## 1. 작업일지 - -- 위치: `docs/worklogs` -- 규칙: 날짜별 1개 Markdown 파일 작성 -- 파일명 예시: `2026-03-31.md` -- 템플릿: `docs/templates/worklog-template.md` -- 권장 기록 범위: 구현 내용, 구조 변경, 빌드/배포 이슈, Git 작업 내역 -- 최근 작업일지는 날짜별로 계속 누적 기록 -- 화면 캡처는 `docs/assets/worklogs/YYYY-MM-DD/` 아래에 저장하고 작업일지에서 상대 경로로 연결 -- 캡처는 전체 화면보다 작업한 컴포넌트 영역 단위 이미지를 우선 사용 -- 메뉴/기능 증적이 필요하면 `capture:menu`, `capture:feature` 스크립트로 화면 단위 캡처를 함께 남김 -- 화면 캡처를 남기지 못한 날에도 `## 화면 캡처` 섹션은 유지하고, 미첨부 사유를 한 줄로 기록 -- 문서 최신화 작업을 수행한 날에는 어떤 문서를 왜 수정했는지 함께 기록 - -권장 항목: - -- 오늘 작업한 내용 -- 이슈 및 해결 과정 -- 결정 사항 -- 상세 작업 내역 - -## 2. 기능 문서 - -- 위치: `docs/features` -- 규칙: 기능 단위로 Markdown 파일 작성 -- 파일명 예시: `auth.md`, `dashboard.md` -- 템플릿: `docs/templates/feature-template.md` -- 권장 기록 범위: 기능 목적, 화면 흐름, API/상태, 테스트 포인트 -- `docs/features/*.md`를 추가하거나 수정하면 앱 `Docs / 기능문서` 메뉴에 반영됨 -- `src/features/**/*.md`는 프로젝트 내부 전용 설명 문서용이며 메인 `Docs` 메뉴의 기본 수집 대상은 아님 - -권장 항목: - -- 기능 목적 -- 주요 화면/흐름 -- 데이터 구조 및 API -- 예외 처리 -- 테스트 포인트 - -현재 주요 기능 문서: - -- `docs/features/work-request-board.md`: 작업 요청 게시글, 하위 요청, 순차 자동화 접수 -- `docs/features/plan-board-review.md`: Plan 게시판과 상세 처리 -- `docs/features/plan-automation.md`: 자동화 처리 흐름과 worker 기준 -- `docs/features/plan-schedule.md`: 반복 등록과 스케줄 관리 -- `docs/features/plan-usage.md`: 운영자/검수자 활용 순서 - -## 3. 컴포넌트 문서 - -- 위치: `docs/components` -- 규칙: 컴포넌트별 1개 Markdown 파일 작성 -- 파일명 예시: `status-badge.md`, `user-card.md` -- 대표 샘플: 각 컴포넌트의 `samples/Sample.tsx` -- 확장 샘플: `samples/*.tsx` - -권장 항목: - -- 목적 -- 폴더 구조 -- UI props -- plugin input/output 규칙 -- plugin 합성 규칙 -- Sample 활용 예시 - -현재 기준 주요 컴포넌트 구조: +## 최상위 구조 ```text -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는 공용 문서가 아니라 각 관리 데이터에서 직접 관리합니다. diff --git a/docs/components/component-addition-suggestions.md b/docs/components/component-addition-suggestions.md deleted file mode 100755 index 8f160cf..0000000 --- a/docs/components/component-addition-suggestions.md +++ /dev/null @@ -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, 적용 위치, 확장 포인트를 함께 기록 diff --git a/docs/test001.md b/docs/test001.md deleted file mode 100755 index 3f0ca1f..0000000 --- a/docs/test001.md +++ /dev/null @@ -1 +0,0 @@ -테스트MD자동 생성 입니다. diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index cf6e7ba..9729289 100755 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -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 { 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({ diff --git a/etc/servers/work-server/src/routes/chat.test.ts b/etc/servers/work-server/src/routes/chat.test.ts new file mode 100644 index 0000000..7edc2d0 --- /dev/null +++ b/etc/servers/work-server/src/routes/chat.test.ts @@ -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'); +}); diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 4da6057..7529ba4 100755 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -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 }) { @@ -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, + }; + }); } diff --git a/etc/servers/work-server/src/routes/server-command.ts b/etc/servers/work-server/src/routes/server-command.ts index de82666..bc9b4bf 100755 --- a/etc/servers/work-server/src/routes/server-command.ts +++ b/etc/servers/work-server/src/routes/server-command.ts @@ -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) => { diff --git a/etc/servers/work-server/src/services/app-config-service.test.ts b/etc/servers/work-server/src/services/app-config-service.test.ts index 9300115..9548d0e 100644 --- a/etc/servers/work-server/src/services/app-config-service.test.ts +++ b/etc/servers/work-server/src/services/app-config-service.test.ts @@ -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', + }, + }); +}); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index d9c09db..c83a725 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -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//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; + } + + if (typeof row.config_json === 'string') { + try { + return normalizeConfigRecord(JSON.parse(row.config_json)); + } catch { + return {} as Record; + } + } + + return normalizeConfigRecord(row.config_json); +} + function normalizeConfigRecord(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} as Record; @@ -299,7 +389,7 @@ function sanitizeDefaultContexts(items: unknown) { const byId = new Map(); 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) { 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 { - 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 { + 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; } diff --git a/etc/servers/work-server/src/services/chat-message-parts.ts b/etc/servers/work-server/src/services/chat-message-parts.ts index 112b02c..a3b3d76 100644 --- a/etc/servers/work-server/src/services/chat-message-parts.ts +++ b/etc/servers/work-server/src/services/chat-message-parts.ts @@ -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; +type PromptOption = PromptPart['options'][number]; +type PromptPreview = NonNullable; +type PromptStep = NonNullable[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; + 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; + 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; + 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; + 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; - 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[]; } diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index 52e5ec2..d6f9973 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -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): 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(); + } + + 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(); + + 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 { 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({ diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index de85df4..1256224 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -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: { ... }) {", '', '', - '## 수동 메모', - '- 유지 메모', - '', ].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(//g) ?? []).length, 1); assert.equal((rebuiltContent.match(//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":"
C
","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: '
C
', + 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')), diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 15e9baf..2a5e9ef 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -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; diff --git a/etc/servers/work-server/src/services/chat-type-defaults.js b/etc/servers/work-server/src/services/chat-type-defaults.js index 6b463cd..d4c0680 100644 --- a/etc/servers/work-server/src/services/chat-type-defaults.js +++ b/etc/servers/work-server/src/services/chat-type-defaults.js @@ -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//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//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', diff --git a/etc/servers/work-server/src/services/chat-type-defaults.ts b/etc/servers/work-server/src/services/chat-type-defaults.ts index bc5a289..25ee058 100644 --- a/etc/servers/work-server/src/services/chat-type-defaults.ts +++ b/etc/servers/work-server/src/services/chat-type-defaults.ts @@ -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//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//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', diff --git a/etc/servers/work-server/src/services/main-project-root-service.ts b/etc/servers/work-server/src/services/main-project-root-service.ts new file mode 100644 index 0000000..7b3f694 --- /dev/null +++ b/etc/servers/work-server/src/services/main-project-root-service.ts @@ -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]; +} diff --git a/etc/servers/work-server/src/services/resource-manager-service.test.ts b/etc/servers/work-server/src/services/resource-manager-service.test.ts new file mode 100644 index 0000000..34620c4 --- /dev/null +++ b/etc/servers/work-server/src/services/resource-manager-service.test.ts @@ -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'); +}); diff --git a/etc/servers/work-server/src/services/resource-manager-service.ts b/etc/servers/work-server/src/services/resource-manager-service.ts index f255c3e..95ad47e 100644 --- a/etc/servers/work-server/src/services/resource-manager-service.ts +++ b/etc/servers/work-server/src/services/resource-manager-service.ts @@ -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'; diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts index 271afce..ee10c26 100755 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -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 [ { diff --git a/etc/servers/work-server/src/services/server-restart-reservation-service.ts b/etc/servers/work-server/src/services/server-restart-reservation-service.ts index 4e96ade..52d76d5 100644 --- a/etc/servers/work-server/src/services/server-restart-reservation-service.ts +++ b/etc/servers/work-server/src/services/server-restart-reservation-service.ts @@ -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) { 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; + + 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 { + 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) { + 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; + + try { + event = JSON.parse(line) as Record; + } 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 | 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; diff --git a/etc/servers/work-server/src/services/stock-alert-service.js b/etc/servers/work-server/src/services/stock-alert-service.js index 2b012f2..caa0d24 100644 --- a/etc/servers/work-server/src/services/stock-alert-service.js +++ b/etc/servers/work-server/src/services/stock-alert-service.js @@ -1,62 +1,6 @@ "use strict"; -var __assign = (this && this.__assign) || function () { - __assign = Object.assign || function(t) { - for (var s, i = 1, n = arguments.length; i < n; i++) { - s = arguments[i]; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) - t[p] = s[p]; - } - return t; - }; - return __assign.apply(this, arguments); -}; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); - return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { - if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { - if (ar || !(i in from)) { - if (!ar) ar = Array.prototype.slice.call(from, 0, i); - ar[i] = from[i]; - } - } - return to.concat(ar || Array.prototype.slice.call(from)); -}; Object.defineProperty(exports, "__esModule", { value: true }); -exports.STOCK_ALERT_TYPE_OPTIONS = exports.STOCK_ALERT_LAYOUT_NAME = exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = exports.STOCK_ALERT_TABLE = void 0; +exports.STOCK_ALERT_TYPE_OPTIONS = exports.STOCK_ALERT_LAYOUT_NAME = exports.STOCK_ALERT_VOLUME_HISTORY_TABLE = exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = exports.STOCK_ALERT_TABLE = void 0; exports.resolveVolumeRate5dFromHistory = resolveVolumeRate5dFromHistory; exports.searchStockAlertCandidates = searchStockAlertCandidates; exports.resolveLatestQuoteFromMeta = resolveLatestQuoteFromMeta; @@ -75,28 +19,29 @@ exports.buildStockAlertNotificationIdentity = buildStockAlertNotificationIdentit exports.sendManagedStockAlertWebPush = sendManagedStockAlertWebPush; exports.sendCurrentPriceStockAlertWebPush = sendCurrentPriceStockAlertWebPush; exports.updateStockAlertLayoutFeatureDescription = updateStockAlertLayoutFeatureDescription; -var notification_service_js_1 = require("./notification-service.js"); -var client_js_1 = require("../db/client.js"); +const notification_service_js_1 = require("./notification-service.js"); +const client_js_1 = require("../db/client.js"); exports.STOCK_ALERT_TABLE = 'stock_alerts'; exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots'; +exports.STOCK_ALERT_VOLUME_HISTORY_TABLE = 'stock_alert_volume_histories'; exports.STOCK_ALERT_LAYOUT_NAME = 'stock알림'; exports.STOCK_ALERT_TYPE_OPTIONS = [ { value: 'all', label: '전체' }, { value: 'price', label: '현재가' }, { value: 'top3', label: '등락폭이 큰 상위3종목' }, ]; -var STOCK_ALERT_LABEL_MAP = new Map(exports.STOCK_ALERT_TYPE_OPTIONS.map(function (option) { return [option.value, option.label]; })); -var STOCK_ALERT_VALUE_SET = new Set(['price', 'top3']); -var KRX_CORP_LIST_URL = 'https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13'; -var KRX_CORP_LIST_CACHE_TTL_MS = 1000 * 60 * 60 * 12; -var STOCK_ALERT_NOTIFICATION_TITLE = '현재 주가'; -var STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN = 'test.sm-home.cloud'; -var STOCK_ALERT_NOTIFICATION_SCOPE = 'schedule-2-stock-alert'; -var STOCK_ALERT_NOTIFICATION_TARGET_URL = "https://".concat(STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN, "/?topMenu=play&playMenu=layout"); -var KOREA_TIMEZONE = 'Asia/Seoul'; -var KOREA_PREOPEN_RESET_HOUR = 5; -var KOREA_REGULAR_OPEN_HOUR = 9; -var cachedKrxListedStocks = null; +const STOCK_ALERT_LABEL_MAP = new Map(exports.STOCK_ALERT_TYPE_OPTIONS.map((option) => [option.value, option.label])); +const STOCK_ALERT_VALUE_SET = new Set(['price', 'top3']); +const KRX_CORP_LIST_URL = 'https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13'; +const KRX_CORP_LIST_CACHE_TTL_MS = 1000 * 60 * 60 * 12; +const STOCK_ALERT_NOTIFICATION_TITLE = '현재 주가'; +const STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN = 'test.sm-home.cloud'; +const STOCK_ALERT_NOTIFICATION_SCOPE = 'schedule-2-stock-alert'; +const STOCK_ALERT_NOTIFICATION_TARGET_URL = `https://${STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN}/?topMenu=play&playMenu=layout`; +const KOREA_TIMEZONE = 'Asia/Seoul'; +const KOREA_PREOPEN_RESET_HOUR = 5; +const KOREA_REGULAR_OPEN_HOUR = 9; +let cachedKrxListedStocks = null; function normalizeTimestamp(value) { if (!value) { return new Date().toISOString(); @@ -104,18 +49,18 @@ function normalizeTimestamp(value) { if (value instanceof Date) { return value.toISOString(); } - var parsed = Date.parse(value); + const parsed = Date.parse(value); return Number.isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString(); } function normalizeAlertType(value) { - var normalized = value.trim().toLowerCase(); + const normalized = value.trim().toLowerCase(); if (!STOCK_ALERT_VALUE_SET.has(normalized)) { throw new Error('알림유형은 현재가 또는 등락폭이 큰 상위3종목만 저장할 수 있습니다.'); } return normalized; } function normalizeStockCode(value) { - var digits = value.replace(/\D+/g, ''); + const digits = value.replace(/\D+/g, ''); return digits.length === 6 ? digits : ''; } function isFiniteNumber(value) { @@ -128,11 +73,11 @@ function parseLooseNumber(value) { if (typeof value !== 'string') { return null; } - var normalized = value.replace(/[^0-9.-]+/g, ''); + const normalized = value.replace(/[^0-9.-]+/g, ''); if (!normalized) { return null; } - var parsed = Number(normalized); + const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } function applySign(value, sign) { @@ -142,8 +87,7 @@ function applySign(value, sign) { return Math.abs(value) * sign; } function resolveNaverDirectionSign(compareToPreviousPrice) { - var _a, _b; - var direction = ((_a = compareToPreviousPrice === null || compareToPreviousPrice === void 0 ? void 0 : compareToPreviousPrice.name) === null || _a === void 0 ? void 0 : _a.trim().toUpperCase()) || ((_b = compareToPreviousPrice === null || compareToPreviousPrice === void 0 ? void 0 : compareToPreviousPrice.code) === null || _b === void 0 ? void 0 : _b.trim()); + const direction = compareToPreviousPrice?.name?.trim().toUpperCase() || compareToPreviousPrice?.code?.trim(); if (direction === 'FALLING' || direction === '4' || direction === '5') { return -1; } @@ -153,7 +97,7 @@ function resolveNaverDirectionSign(compareToPreviousPrice) { return null; } function resolveSignedNaverChangeRate(rate, compareToPreviousClosePrice, compareToPreviousPrice) { - var signedChangeAmount = parseLooseNumber(compareToPreviousClosePrice); + const signedChangeAmount = parseLooseNumber(compareToPreviousClosePrice); if (signedChangeAmount !== null && signedChangeAmount !== 0) { return applySign(rate, signedChangeAmount < 0 ? -1 : 1); } @@ -164,54 +108,52 @@ function resolveCapturedTimestampMs(value) { return value; } if (typeof value === 'string' && value.trim()) { - var parsed = Date.parse(value); + const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function extractKoreaHour(timestampMs) { - var formatted = new Intl.DateTimeFormat('en-GB', { + const formatted = new Intl.DateTimeFormat('en-GB', { timeZone: KOREA_TIMEZONE, hour: '2-digit', hour12: false, }).format(new Date(timestampMs)); - var hour = Number(formatted); + const hour = Number(formatted); return Number.isFinite(hour) ? hour : null; } function isKoreaMorningResetWindow(timestampMs) { if (!Number.isFinite(timestampMs)) { return false; } - var koreaHour = extractKoreaHour(timestampMs); + const koreaHour = extractKoreaHour(timestampMs); return koreaHour !== null && koreaHour >= KOREA_PREOPEN_RESET_HOUR && koreaHour < KOREA_REGULAR_OPEN_HOUR; } function getAlertTypeLabel(value) { - var _a; - return (_a = STOCK_ALERT_LABEL_MAP.get(value)) !== null && _a !== void 0 ? _a : value; + return STOCK_ALERT_LABEL_MAP.get(value) ?? value; } function average(values) { if (!values.length) { return null; } - var sum = values.reduce(function (acc, value) { return acc + value; }, 0); + const sum = values.reduce((acc, value) => acc + value, 0); return sum / values.length; } function resolveVolumeRate5dFromHistory(currentVolume, historicalVolumes) { - var _a; - var normalizedCurrentVolume = isFiniteNumber(currentVolume) && currentVolume >= 0 ? currentVolume : null; - var normalizedVolumes = historicalVolumes.filter(function (value) { return isFiniteNumber(value) && value >= 0; }); + const normalizedCurrentVolume = isFiniteNumber(currentVolume) && currentVolume >= 0 ? currentVolume : null; + const normalizedVolumes = historicalVolumes.filter((value) => isFiniteNumber(value) && value >= 0); if (normalizedVolumes.length < 2) { return null; } - var latestVolume = (_a = normalizedCurrentVolume !== null && normalizedCurrentVolume !== void 0 ? normalizedCurrentVolume : normalizedVolumes[normalizedVolumes.length - 1]) !== null && _a !== void 0 ? _a : null; - var previousFiveAverage = average(normalizedVolumes.slice(-6, -1)); + const latestVolume = normalizedCurrentVolume ?? normalizedVolumes[normalizedVolumes.length - 1] ?? null; + const previousFiveAverage = average(normalizedVolumes.slice(-6, -1)); if (latestVolume === null || previousFiveAverage === null || previousFiveAverage <= 0) { return null; } return (latestVolume / previousFiveAverage) * 100; } function normalizeNonNegativeVolume(value) { - var parsed = parseLooseNumber(value); + const parsed = parseLooseNumber(value); if (!isFiniteNumber(parsed) || parsed < 0) { return null; } @@ -223,11 +165,26 @@ function calculateVolumeIncreasePercent(currentVolume, previousVolume) { } return ((currentVolume - previousVolume) / previousVolume) * 100; } +function calculateVolumeAmplificationGrowthPercent(currentIncreasePercent, recentMaxIncreasePercent) { + if (!isFiniteNumber(currentIncreasePercent) + || !isFiniteNumber(recentMaxIncreasePercent) + || recentMaxIncreasePercent <= 0 + || currentIncreasePercent < recentMaxIncreasePercent) { + return null; + } + return ((currentIncreasePercent - recentMaxIncreasePercent) / recentMaxIncreasePercent) * 100; +} +function resolveComparableVolumeBaseline(item, previousSnapshot) { + const snapshotBaseline = previousSnapshot?.currentVolume ?? null; + if (snapshotBaseline !== null) { + return snapshotBaseline; + } + return null; +} function normalizeStockAlertVolumeSnapshotRow(row) { - var _a; return { stockCode: normalizeStockCode(row.stock_code), - stockName: String((_a = row.stock_name) !== null && _a !== void 0 ? _a : '').trim() || normalizeStockCode(row.stock_code), + stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code), previousVolume: normalizeNonNegativeVolume(row.previous_volume), currentVolume: normalizeNonNegativeVolume(row.current_volume), volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent), @@ -238,17 +195,30 @@ function normalizeStockAlertVolumeSnapshotRow(row) { updatedAt: normalizeTimestamp(row.updated_at), }; } +function normalizeStockAlertVolumeHistoryRow(row) { + 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, currentVolume, previousSnapshot) { - var _a, _b; - var now = new Date().toISOString(); - var normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume); - var previousCurrentVolume = (_a = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && _a !== void 0 ? _a : null; - var shouldResetBaseline = normalizedCurrentVolume !== null && + const now = new Date().toISOString(); + const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume); + const previousCurrentVolume = previousSnapshot?.currentVolume ?? null; + const shouldResetBaseline = normalizedCurrentVolume !== null && previousCurrentVolume !== null && normalizedCurrentVolume < previousCurrentVolume; - var comparisonBaseline = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume; - var volumeIncreasePercent = calculateVolumeIncreasePercent(normalizedCurrentVolume, comparisonBaseline); - var nextPreviousVolume = shouldResetBaseline + const comparisonBaseline = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume; + const volumeIncreasePercent = calculateVolumeIncreasePercent(normalizedCurrentVolume, comparisonBaseline); + const nextPreviousVolume = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume !== null ? previousCurrentVolume @@ -262,31 +232,48 @@ function buildStockAlertVolumeSnapshotRecord(item, currentVolume, previousSnapsh current_price: item.currentPrice, change_rate: item.changeRate, quoted_at: item.quotedAt, - created_at: (_b = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.createdAt) !== null && _b !== void 0 ? _b : now, + created_at: previousSnapshot?.createdAt ?? now, updated_at: now, }; } +function buildStockAlertVolumeHistoryRecord(item, baselineVolume, currentVolume) { + 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, + }; +} function buildStockSymbols(stockCode) { - var normalizedCode = normalizeStockCode(stockCode); + const normalizedCode = normalizeStockCode(stockCode); if (!normalizedCode) { return []; } - return ["".concat(normalizedCode, ".KS"), "".concat(normalizedCode, ".KQ")]; + return [`${normalizedCode}.KS`, `${normalizedCode}.KQ`]; } function extractStockCodeFromSymbol(symbol) { - var match = symbol.trim().match(/^(\d{6})\.(?:KS|KQ)$/i); + const match = symbol.trim().match(/^(\d{6})\.(?:KS|KQ)$/i); return match ? match[1] : ''; } function resolveMarketLabel(quote) { - var _a, _b, _c, _d, _e; - var symbol = (_b = (_a = quote.symbol) === null || _a === void 0 ? void 0 : _a.trim().toUpperCase()) !== null && _b !== void 0 ? _b : ''; + const symbol = quote.symbol?.trim().toUpperCase() ?? ''; if (symbol.endsWith('.KS')) { return 'KOSPI'; } if (symbol.endsWith('.KQ')) { return 'KOSDAQ'; } - var exchange = ((_c = quote.exchDisp) === null || _c === void 0 ? void 0 : _c.trim()) || ((_d = quote.exchange) === null || _d === void 0 ? void 0 : _d.trim()) || ((_e = quote.typeDisp) === null || _e === void 0 ? void 0 : _e.trim()); + const exchange = quote.exchDisp?.trim() || quote.exchange?.trim() || quote.typeDisp?.trim(); if (!exchange) { return '기타'; } @@ -298,95 +285,83 @@ function resolveMarketLabel(quote) { } return exchange; } -function ensureStockAlertTable() { - return __awaiter(this, void 0, void 0, function () { - var exists; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.STOCK_ALERT_TABLE)]; - case 1: - exists = _a.sent(); - if (exists) { - return [2 /*return*/]; - } - return [4 /*yield*/, client_js_1.db.schema.createTable(exports.STOCK_ALERT_TABLE, function (table) { - table.text('id').primary(); - table.text('stock_code').notNullable(); - table.text('stock_name').notNullable(); - table.text('alert_type').notNullable(); - table.timestamp('created_at', { useTz: true }).notNullable(); - table.timestamp('updated_at', { useTz: true }).notNullable(); - })]; - case 2: - _a.sent(); - return [2 /*return*/]; - } - }); +async function ensureStockAlertTable() { + const exists = await client_js_1.db.schema.hasTable(exports.STOCK_ALERT_TABLE); + if (exists) { + return; + } + await client_js_1.db.schema.createTable(exports.STOCK_ALERT_TABLE, (table) => { + table.text('id').primary(); + table.text('stock_code').notNullable(); + table.text('stock_name').notNullable(); + table.text('alert_type').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable(); + table.timestamp('updated_at', { useTz: true }).notNullable(); }); } -function ensureStockAlertVolumeSnapshotTable() { - return __awaiter(this, void 0, void 0, function () { - var exists; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE)]; - case 1: - exists = _a.sent(); - if (exists) { - return [2 /*return*/]; - } - return [4 /*yield*/, client_js_1.db.schema.createTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE, function (table) { - table.text('stock_code').primary(); - table.text('stock_name').notNullable(); - table.bigInteger('previous_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(); - table.timestamp('updated_at', { useTz: true }).notNullable(); - })]; - case 2: - _a.sent(); - return [2 /*return*/]; - } - }); +async function ensureStockAlertVolumeSnapshotTable() { + const exists = await client_js_1.db.schema.hasTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE); + if (exists) { + return; + } + await client_js_1.db.schema.createTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE, (table) => { + table.text('stock_code').primary(); + table.text('stock_name').notNullable(); + table.bigInteger('previous_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(); + table.timestamp('updated_at', { useTz: true }).notNullable(); }); } -function fetchJson(url, init) { - return __awaiter(this, void 0, void 0, function () { - var response; - var _a; - return __generator(this, function (_b) { - switch (_b.label) { - case 0: return [4 /*yield*/, fetch(url, __assign(__assign({}, init), { headers: __assign({ accept: 'application/json', 'user-agent': 'ai-code-app/stock-alert' }, ((_a = init === null || init === void 0 ? void 0 : init.headers) !== null && _a !== void 0 ? _a : {})) }))]; - case 1: - response = _b.sent(); - if (!response.ok) { - throw new Error("\uC678\uBD80 \uC2DC\uC138 \uC751\uB2F5\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (".concat(response.status, ")")); - } - return [2 /*return*/, response.json()]; - } - }); +async function ensureStockAlertVolumeHistoryTable() { + const exists = await client_js_1.db.schema.hasTable(exports.STOCK_ALERT_VOLUME_HISTORY_TABLE); + if (exists) { + return; + } + await client_js_1.db.schema.createTable(exports.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(); }); } -function fetchText(url, init) { - return __awaiter(this, void 0, void 0, function () { - var response; - var _a; - return __generator(this, function (_b) { - switch (_b.label) { - case 0: return [4 /*yield*/, fetch(url, __assign(__assign({}, init), { headers: __assign({ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'user-agent': 'ai-code-app/stock-alert' }, ((_a = init === null || init === void 0 ? void 0 : init.headers) !== null && _a !== void 0 ? _a : {})) }))]; - case 1: - response = _b.sent(); - if (!response.ok) { - throw new Error("\uC678\uBD80 \uC885\uBAA9 \uAC80\uC0C9 \uC751\uB2F5\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (".concat(response.status, ")")); - } - return [2 /*return*/, response.arrayBuffer()]; - } - }); +async function fetchJson(url, init) { + const response = await fetch(url, { + ...init, + headers: { + accept: 'application/json', + 'user-agent': 'ai-code-app/stock-alert', + ...(init?.headers ?? {}), + }, }); + if (!response.ok) { + throw new Error(`외부 시세 응답을 불러오지 못했습니다. (${response.status})`); + } + return response.json(); +} +async function fetchText(url, init) { + const response = await fetch(url, { + ...init, + headers: { + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'user-agent': 'ai-code-app/stock-alert', + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`외부 종목 검색 응답을 불러오지 못했습니다. (${response.status})`); + } + return response.arrayBuffer(); } function decodeEucKr(value) { return new TextDecoder('euc-kr').decode(value); @@ -407,7 +382,7 @@ function normalizeSearchKeyword(value) { return value.trim().replace(/\s+/g, '').toLowerCase(); } function normalizeMarketLabel(value) { - var trimmedValue = value.trim(); + const trimmedValue = value.trim(); if (/코스닥/i.test(trimmedValue)) { return 'KOSDAQ'; } @@ -417,314 +392,226 @@ function normalizeMarketLabel(value) { return trimmedValue || '기타'; } function parseKrxListedStocks(html) { - var _a; - var rowMatches = (_a = html.match(//gi)) !== null && _a !== void 0 ? _a : []; - var items = []; - rowMatches.forEach(function (rowHtml) { - var _a, _b, _c, _d; - var cellMatches = (_a = rowHtml.match(//gi)) !== null && _a !== void 0 ? _a : []; + const rowMatches = html.match(//gi) ?? []; + const items = []; + rowMatches.forEach((rowHtml) => { + const cellMatches = rowHtml.match(//gi) ?? []; if (cellMatches.length < 3) { return; } - var stockName = stripHtmlTags((_b = cellMatches[0]) !== null && _b !== void 0 ? _b : ''); - var market = normalizeMarketLabel(stripHtmlTags((_c = cellMatches[1]) !== null && _c !== void 0 ? _c : '')); - var stockCode = normalizeStockCode(stripHtmlTags((_d = cellMatches[2]) !== null && _d !== void 0 ? _d : '')); + const stockName = stripHtmlTags(cellMatches[0] ?? ''); + const market = normalizeMarketLabel(stripHtmlTags(cellMatches[1] ?? '')); + const stockCode = normalizeStockCode(stripHtmlTags(cellMatches[2] ?? '')); if (!stockCode || !stockName) { return; } items.push({ - stockCode: stockCode, - stockName: stockName, - market: market, + stockCode, + stockName, + market, }); }); return items; } -function findKrxListedStockByCode(stockCode) { - return __awaiter(this, void 0, void 0, function () { - var normalizedCode, items; - var _a; - return __generator(this, function (_b) { - switch (_b.label) { - case 0: - normalizedCode = normalizeStockCode(stockCode); - if (!normalizedCode) { - return [2 /*return*/, null]; - } - return [4 /*yield*/, fetchKrxListedStocks()]; - case 1: - items = _b.sent(); - return [2 /*return*/, (_a = items.find(function (item) { return item.stockCode === normalizedCode; })) !== null && _a !== void 0 ? _a : null]; - } - }); - }); +async function findKrxListedStockByCode(stockCode) { + const normalizedCode = normalizeStockCode(stockCode); + if (!normalizedCode) { + return null; + } + const items = await fetchKrxListedStocks(); + return items.find((item) => item.stockCode === normalizedCode) ?? null; } -function fetchKrxListedStocks() { - return __awaiter(this, void 0, void 0, function () { - var buffer, decodedHtml, items; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - if (cachedKrxListedStocks && cachedKrxListedStocks.expiresAt > Date.now()) { - return [2 /*return*/, cachedKrxListedStocks.items]; - } - return [4 /*yield*/, fetchText(KRX_CORP_LIST_URL)]; - case 1: - buffer = _a.sent(); - decodedHtml = decodeEucKr(buffer); - items = parseKrxListedStocks(decodedHtml); - cachedKrxListedStocks = { - expiresAt: Date.now() + KRX_CORP_LIST_CACHE_TTL_MS, - items: items, - }; - return [2 /*return*/, items]; - } - }); - }); +async function fetchKrxListedStocks() { + if (cachedKrxListedStocks && cachedKrxListedStocks.expiresAt > Date.now()) { + return cachedKrxListedStocks.items; + } + const buffer = await fetchText(KRX_CORP_LIST_URL); + const decodedHtml = decodeEucKr(buffer); + const items = parseKrxListedStocks(decodedHtml); + cachedKrxListedStocks = { + expiresAt: Date.now() + KRX_CORP_LIST_CACHE_TTL_MS, + items, + }; + return items; } -function searchKrxListedStocks(query_1) { - return __awaiter(this, arguments, void 0, function (query, limit) { - var normalizedKeyword, items, matchedItems; - if (limit === void 0) { limit = 20; } - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - normalizedKeyword = normalizeSearchKeyword(query); - if (!normalizedKeyword) { - return [2 /*return*/, []]; - } - return [4 /*yield*/, fetchKrxListedStocks()]; - case 1: - items = _a.sent(); - matchedItems = items.filter(function (item) { - var normalizedCode = item.stockCode.toLowerCase(); - var normalizedName = normalizeSearchKeyword(item.stockName); - return normalizedCode.includes(normalizedKeyword) || normalizedName.includes(normalizedKeyword); - }); - matchedItems.sort(function (left, right) { - var trimmedQuery = query.trim(); - var leftExactCode = left.stockCode === trimmedQuery ? 1 : 0; - var rightExactCode = right.stockCode === trimmedQuery ? 1 : 0; - if (leftExactCode !== rightExactCode) { - return rightExactCode - leftExactCode; - } - var leftExactName = normalizeSearchKeyword(left.stockName) === normalizedKeyword ? 1 : 0; - var rightExactName = normalizeSearchKeyword(right.stockName) === normalizedKeyword ? 1 : 0; - if (leftExactName !== rightExactName) { - return rightExactName - leftExactName; - } - var leftStartsWith = normalizeSearchKeyword(left.stockName).startsWith(normalizedKeyword) ? 1 : 0; - var rightStartsWith = normalizeSearchKeyword(right.stockName).startsWith(normalizedKeyword) ? 1 : 0; - if (leftStartsWith !== rightStartsWith) { - return rightStartsWith - leftStartsWith; - } - var leftLengthGap = Math.abs(left.stockName.length - trimmedQuery.length); - var rightLengthGap = Math.abs(right.stockName.length - trimmedQuery.length); - if (leftLengthGap !== rightLengthGap) { - return leftLengthGap - rightLengthGap; - } - return left.stockName.localeCompare(right.stockName, 'ko-KR'); - }); - return [2 /*return*/, matchedItems.slice(0, Math.max(1, Math.min(50, limit)))]; - } - }); +async function searchKrxListedStocks(query, limit = 20) { + const normalizedKeyword = normalizeSearchKeyword(query); + if (!normalizedKeyword) { + return []; + } + const items = await fetchKrxListedStocks(); + const matchedItems = items.filter((item) => { + const normalizedCode = item.stockCode.toLowerCase(); + const normalizedName = normalizeSearchKeyword(item.stockName); + return normalizedCode.includes(normalizedKeyword) || normalizedName.includes(normalizedKeyword); }); + matchedItems.sort((left, right) => { + const trimmedQuery = query.trim(); + const leftExactCode = left.stockCode === trimmedQuery ? 1 : 0; + const rightExactCode = right.stockCode === trimmedQuery ? 1 : 0; + if (leftExactCode !== rightExactCode) { + return rightExactCode - leftExactCode; + } + const leftExactName = normalizeSearchKeyword(left.stockName) === normalizedKeyword ? 1 : 0; + const rightExactName = normalizeSearchKeyword(right.stockName) === normalizedKeyword ? 1 : 0; + if (leftExactName !== rightExactName) { + return rightExactName - leftExactName; + } + const leftStartsWith = normalizeSearchKeyword(left.stockName).startsWith(normalizedKeyword) ? 1 : 0; + const rightStartsWith = normalizeSearchKeyword(right.stockName).startsWith(normalizedKeyword) ? 1 : 0; + if (leftStartsWith !== rightStartsWith) { + return rightStartsWith - leftStartsWith; + } + const leftLengthGap = Math.abs(left.stockName.length - trimmedQuery.length); + const rightLengthGap = Math.abs(right.stockName.length - trimmedQuery.length); + if (leftLengthGap !== rightLengthGap) { + return leftLengthGap - rightLengthGap; + } + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }); + return matchedItems.slice(0, Math.max(1, Math.min(50, limit))); } -function resolveStockIdentity(input) { - return __awaiter(this, void 0, void 0, function () { - var codeFromInput, trimmedName, krxMatch, quotes, quote, krxMatches, exactMatch, searchUrl, payload, matchedQuote, resolvedCode; - var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; - return __generator(this, function (_l) { - switch (_l.label) { - case 0: - codeFromInput = normalizeStockCode((_a = input.stockCode) !== null && _a !== void 0 ? _a : ''); - trimmedName = (_c = (_b = input.stockName) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : ''; - if (!codeFromInput) return [3 /*break*/, 3]; - return [4 /*yield*/, findKrxListedStockByCode(codeFromInput)]; - case 1: - krxMatch = _l.sent(); - if (krxMatch) { - return [2 /*return*/, { - stockCode: codeFromInput, - stockName: krxMatch.stockName, - }]; - } - if (trimmedName) { - return [2 /*return*/, { - stockCode: codeFromInput, - stockName: trimmedName, - }]; - } - return [4 /*yield*/, fetchQuotesByCodes([codeFromInput])]; - case 2: - quotes = _l.sent(); - quote = quotes.get(codeFromInput); - if (quote) { - return [2 /*return*/, { - stockCode: codeFromInput, - stockName: (_d = quote.stockName) !== null && _d !== void 0 ? _d : codeFromInput, - }]; - } - return [2 /*return*/, { - stockCode: codeFromInput, - stockName: codeFromInput, - }]; - case 3: - if (!trimmedName) { - throw new Error('종목명을 입력해 주세요.'); - } - return [4 /*yield*/, searchKrxListedStocks(trimmedName, 10)]; - case 4: - krxMatches = _l.sent(); - exactMatch = (_f = (_e = krxMatches.find(function (item) { return normalizeSearchKeyword(item.stockName) === normalizeSearchKeyword(trimmedName); })) !== null && _e !== void 0 ? _e : krxMatches[0]) !== null && _f !== void 0 ? _f : null; - if (exactMatch) { - return [2 /*return*/, { - stockCode: exactMatch.stockCode, - stockName: exactMatch.stockName, - }]; - } - searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); - searchUrl.searchParams.set('q', trimmedName); - searchUrl.searchParams.set('quotesCount', '10'); - searchUrl.searchParams.set('newsCount', '0'); - searchUrl.searchParams.set('lang', 'ko-KR'); - searchUrl.searchParams.set('region', 'KR'); - return [4 /*yield*/, fetchJson(searchUrl)]; - case 5: - payload = _l.sent(); - matchedQuote = (_h = (_g = payload.quotes) === null || _g === void 0 ? void 0 : _g.find(function (quote) { return typeof quote.symbol === 'string' && extractStockCodeFromSymbol(quote.symbol).length === 6; })) !== null && _h !== void 0 ? _h : null; - if (!(matchedQuote === null || matchedQuote === void 0 ? void 0 : matchedQuote.symbol)) { - throw new Error("\uC885\uBAA9\uBA85 \"".concat(trimmedName, "\"\uC5D0 \uD574\uB2F9\uD558\uB294 \uC885\uBAA9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.")); - } - resolvedCode = extractStockCodeFromSymbol(matchedQuote.symbol); - if (!resolvedCode) { - throw new Error("\uC885\uBAA9\uBA85 \"".concat(trimmedName, "\"\uC5D0 \uD574\uB2F9\uD558\uB294 \uC885\uBAA9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.")); - } - return [2 /*return*/, { - stockCode: resolvedCode, - stockName: ((_j = matchedQuote.shortname) === null || _j === void 0 ? void 0 : _j.trim()) || ((_k = matchedQuote.longname) === null || _k === void 0 ? void 0 : _k.trim()) || trimmedName, - }]; - } - }); - }); +async function resolveStockIdentity(input) { + const codeFromInput = normalizeStockCode(input.stockCode ?? ''); + const trimmedName = input.stockName?.trim() ?? ''; + if (codeFromInput) { + const krxMatch = await findKrxListedStockByCode(codeFromInput); + if (krxMatch) { + return { + stockCode: codeFromInput, + stockName: krxMatch.stockName, + }; + } + if (trimmedName) { + return { + stockCode: codeFromInput, + stockName: trimmedName, + }; + } + const quotes = await fetchQuotesByCodes([codeFromInput]); + const quote = quotes.get(codeFromInput); + if (quote) { + return { + stockCode: codeFromInput, + stockName: quote.stockName ?? codeFromInput, + }; + } + return { + stockCode: codeFromInput, + stockName: codeFromInput, + }; + } + if (!trimmedName) { + throw new Error('종목명을 입력해 주세요.'); + } + const krxMatches = await searchKrxListedStocks(trimmedName, 10); + const exactMatch = krxMatches.find((item) => normalizeSearchKeyword(item.stockName) === normalizeSearchKeyword(trimmedName)) ?? krxMatches[0] ?? null; + if (exactMatch) { + return { + stockCode: exactMatch.stockCode, + stockName: exactMatch.stockName, + }; + } + const searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); + searchUrl.searchParams.set('q', trimmedName); + searchUrl.searchParams.set('quotesCount', '10'); + searchUrl.searchParams.set('newsCount', '0'); + searchUrl.searchParams.set('lang', 'ko-KR'); + searchUrl.searchParams.set('region', 'KR'); + const payload = await fetchJson(searchUrl); + const matchedQuote = payload.quotes?.find((quote) => typeof quote.symbol === 'string' && extractStockCodeFromSymbol(quote.symbol).length === 6) ?? + null; + if (!matchedQuote?.symbol) { + throw new Error(`종목명 "${trimmedName}"에 해당하는 종목코드를 찾지 못했습니다.`); + } + const resolvedCode = extractStockCodeFromSymbol(matchedQuote.symbol); + if (!resolvedCode) { + throw new Error(`종목명 "${trimmedName}"에 해당하는 종목코드를 찾지 못했습니다.`); + } + return { + stockCode: resolvedCode, + stockName: matchedQuote.shortname?.trim() || matchedQuote.longname?.trim() || trimmedName, + }; } -function searchYahooStocks(query_1) { - return __awaiter(this, arguments, void 0, function (query, quotesCount) { - var trimmedQuery, searchUrl, payload, error_1; - var _a; - if (quotesCount === void 0) { quotesCount = 20; } - return __generator(this, function (_b) { - switch (_b.label) { - case 0: - trimmedQuery = query.trim(); - if (!trimmedQuery) { - return [2 /*return*/, []]; - } - searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); - searchUrl.searchParams.set('q', trimmedQuery); - searchUrl.searchParams.set('quotesCount', String(Math.max(1, Math.min(50, quotesCount)))); - searchUrl.searchParams.set('newsCount', '0'); - searchUrl.searchParams.set('lang', 'ko-KR'); - searchUrl.searchParams.set('region', 'KR'); - _b.label = 1; - case 1: - _b.trys.push([1, 3, , 4]); - return [4 /*yield*/, fetchJson(searchUrl)]; - case 2: - payload = _b.sent(); - return [2 /*return*/, (_a = payload.quotes) !== null && _a !== void 0 ? _a : []]; - case 3: - error_1 = _b.sent(); - // Yahoo search rejects some non-code Korean queries with HTTP 400. - if (/[^\x00-\x7F]/.test(trimmedQuery)) { - return [2 /*return*/, []]; - } - throw error_1; - case 4: return [2 /*return*/]; - } - }); - }); +async function searchYahooStocks(query, quotesCount = 20) { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + return []; + } + const searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); + searchUrl.searchParams.set('q', trimmedQuery); + searchUrl.searchParams.set('quotesCount', String(Math.max(1, Math.min(50, quotesCount)))); + searchUrl.searchParams.set('newsCount', '0'); + searchUrl.searchParams.set('lang', 'ko-KR'); + searchUrl.searchParams.set('region', 'KR'); + try { + const payload = await fetchJson(searchUrl); + return payload.quotes ?? []; + } + catch (error) { + // Yahoo search rejects some non-code Korean queries with HTTP 400. + if (/[^\x00-\x7F]/.test(trimmedQuery)) { + return []; + } + throw error; + } } -function searchStockAlertCandidates(query_1) { - return __awaiter(this, arguments, void 0, function (query, limit) { - var normalizedLimit, _a, krxItems, quotes, seenCodes, items, krxByCode; - if (limit === void 0) { limit = 20; } - return __generator(this, function (_b) { - switch (_b.label) { - case 0: - normalizedLimit = Math.max(1, Math.min(50, limit)); - return [4 /*yield*/, Promise.all([ - searchKrxListedStocks(query, normalizedLimit), - searchYahooStocks(query, normalizedLimit * 2), - ])]; - case 1: - _a = _b.sent(), krxItems = _a[0], quotes = _a[1]; - seenCodes = new Set(); - items = __spreadArray([], krxItems, true); - krxByCode = new Map(krxItems.map(function (item) { return [item.stockCode, item]; })); - krxItems.forEach(function (item) { - seenCodes.add(item.stockCode); - }); - quotes.forEach(function (quote) { - var _a, _b, _c, _d; - if (!quote.symbol) { - return; - } - var stockCode = extractStockCodeFromSymbol(quote.symbol); - if (!stockCode || seenCodes.has(stockCode)) { - return; - } - var stockName = ((_a = quote.shortname) === null || _a === void 0 ? void 0 : _a.trim()) || ((_b = quote.longname) === null || _b === void 0 ? void 0 : _b.trim()) || stockCode; - var krxMatch = krxByCode.get(stockCode); - seenCodes.add(stockCode); - items.push({ - stockCode: stockCode, - stockName: (_c = krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.stockName) !== null && _c !== void 0 ? _c : stockName, - market: (_d = krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.market) !== null && _d !== void 0 ? _d : resolveMarketLabel(quote), - }); - }); - return [2 /*return*/, items.slice(0, normalizedLimit)]; - } +async function searchStockAlertCandidates(query, limit = 20) { + const normalizedLimit = Math.max(1, Math.min(50, limit)); + const [krxItems, quotes] = await Promise.all([ + searchKrxListedStocks(query, normalizedLimit), + searchYahooStocks(query, normalizedLimit * 2), + ]); + const seenCodes = new Set(); + const items = [...krxItems]; + const krxByCode = new Map(krxItems.map((item) => [item.stockCode, item])); + krxItems.forEach((item) => { + seenCodes.add(item.stockCode); + }); + quotes.forEach((quote) => { + if (!quote.symbol) { + return; + } + const stockCode = extractStockCodeFromSymbol(quote.symbol); + if (!stockCode || seenCodes.has(stockCode)) { + return; + } + const stockName = quote.shortname?.trim() || quote.longname?.trim() || stockCode; + const krxMatch = krxByCode.get(stockCode); + seenCodes.add(stockCode); + items.push({ + stockCode, + stockName: krxMatch?.stockName ?? stockName, + market: krxMatch?.market ?? resolveMarketLabel(quote), }); }); + return items.slice(0, normalizedLimit); } -function ensureNoDuplicateStockCode(stockCode, currentId) { - return __awaiter(this, void 0, void 0, function () { - var normalizedCode, existing; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - normalizedCode = normalizeStockCode(stockCode); - if (!normalizedCode) { - throw new Error('종목코드를 확인할 수 없습니다.'); - } - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) - .select('id') - .where({ stock_code: normalizedCode }) - .modify(function (query) { - if (currentId === null || currentId === void 0 ? void 0 : currentId.trim()) { - query.whereNot('id', currentId.trim()); - } - }) - .first()]; - case 1: - existing = (_a.sent()); - if (existing === null || existing === void 0 ? void 0 : existing.id) { - throw new Error('이미 추가된 종목입니다.'); - } - return [2 /*return*/]; - } - }); - }); +async function ensureNoDuplicateStockCode(stockCode, currentId) { + const normalizedCode = normalizeStockCode(stockCode); + if (!normalizedCode) { + throw new Error('종목코드를 확인할 수 없습니다.'); + } + const existing = (await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('id') + .where({ stock_code: normalizedCode }) + .modify((query) => { + if (currentId?.trim()) { + query.whereNot('id', currentId.trim()); + } + }) + .first()); + if (existing?.id) { + throw new Error('이미 추가된 종목입니다.'); + } } function resolveLatestQuoteFromMeta(meta) { - var _a, _b, _c, _d; - var marketState = String((_a = meta.marketState) !== null && _a !== void 0 ? _a : '') + const marketState = String(meta.marketState ?? '') .trim() .toUpperCase(); - var shouldPreferPremarket = ['PRE', 'PREPRE'].includes(marketState); - var shouldPreferPostmarket = ['POST', 'POSTPOST', 'CLOSED'].includes(marketState); - var preferredCandidate = shouldPreferPremarket + const shouldPreferPremarket = ['PRE', 'PREPRE'].includes(marketState); + const shouldPreferPostmarket = ['POST', 'POSTPOST', 'CLOSED'].includes(marketState); + const preferredCandidate = shouldPreferPremarket ? { price: meta.preMarketPrice, changeRate: meta.preMarketChangePercent, @@ -741,7 +628,7 @@ function resolveLatestQuoteFromMeta(meta) { changeRate: meta.regularMarketChangePercent, time: meta.regularMarketTime, }; - var quoteCandidates = [ + const quoteCandidates = [ { price: meta.regularMarketPrice, changeRate: meta.regularMarketChangePercent, @@ -758,82 +645,80 @@ function resolveLatestQuoteFromMeta(meta) { time: meta.postMarketTime, }, ]; - var latestCandidate = (_b = quoteCandidates - .flatMap(function (item) { - return isFiniteNumber(item.price) && isFiniteNumber(item.time) - ? [ - { - price: item.price, - changeRate: item.changeRate, - time: item.time, - }, - ] - : []; - }) - .sort(function (left, right) { return right.time - left.time; })[0]) !== null && _b !== void 0 ? _b : null; - var resolvedCandidate = isFiniteNumber(preferredCandidate.price) && isFiniteNumber(preferredCandidate.time) ? preferredCandidate : latestCandidate; - var currentPrice = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.price) + const latestCandidate = quoteCandidates + .flatMap((item) => isFiniteNumber(item.price) && isFiniteNumber(item.time) + ? [ + { + price: item.price, + changeRate: item.changeRate, + time: item.time, + }, + ] + : []) + .sort((left, right) => right.time - left.time)[0] ?? null; + const resolvedCandidate = isFiniteNumber(preferredCandidate.price) && isFiniteNumber(preferredCandidate.time) ? preferredCandidate : latestCandidate; + const currentPrice = isFiniteNumber(resolvedCandidate?.price) ? resolvedCandidate.price : isFiniteNumber(meta.regularMarketPrice) ? meta.regularMarketPrice : null; - var quotedAt = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.time) + const quotedAt = isFiniteNumber(resolvedCandidate?.time) ? new Date(resolvedCandidate.time * 1000).toISOString() : isFiniteNumber(meta.regularMarketTime) ? new Date(meta.regularMarketTime * 1000).toISOString() : null; - var previousClose = typeof meta.chartPreviousClose === 'number' + const previousClose = typeof meta.chartPreviousClose === 'number' ? meta.chartPreviousClose : typeof meta.previousClose === 'number' ? meta.previousClose : null; - var changeRate = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.changeRate) + const changeRate = isFiniteNumber(resolvedCandidate?.changeRate) ? resolvedCandidate.changeRate : currentPrice !== null && previousClose !== null && previousClose !== 0 ? ((currentPrice - previousClose) / previousClose) * 100 : null; return { - currentPrice: currentPrice, - changeRate: changeRate, + currentPrice, + changeRate, volumeRate5d: null, currentVolume: null, - quotedAt: quotedAt, - stockName: ((_c = meta.shortName) === null || _c === void 0 ? void 0 : _c.trim()) || ((_d = meta.longName) === null || _d === void 0 ? void 0 : _d.trim()) || null, + quotedAt, + stockName: meta.shortName?.trim() || meta.longName?.trim() || null, }; } function resolveLatestQuoteFromNaverRealtime(data, capturedAt) { - var _a, _b, _c, _d, _e; - var overMarketInfo = data.nxtOverMarketPriceInfo; - var overPrice = parseLooseNumber(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.overPrice); - var overChangeRate = resolveSignedNaverChangeRate(parseLooseNumber(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.fluctuationsRatio), overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.compareToPreviousClosePrice, overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.compareToPreviousPrice); - var overQuotedAt = ((_a = overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.localTradedAt) === null || _a === void 0 ? void 0 : _a.trim()) || null; - var hasExtendedSessionQuote = overPrice !== null && overQuotedAt; - var baseQuotedAt = typeof capturedAt === 'number' && Number.isFinite(capturedAt) + const overMarketInfo = data.nxtOverMarketPriceInfo; + const overPrice = parseLooseNumber(overMarketInfo?.overPrice); + const overChangeRate = resolveSignedNaverChangeRate(parseLooseNumber(overMarketInfo?.fluctuationsRatio), overMarketInfo?.compareToPreviousClosePrice, overMarketInfo?.compareToPreviousPrice); + const overQuotedAt = overMarketInfo?.localTradedAt?.trim() || null; + const hasExtendedSessionQuote = overPrice !== null && overQuotedAt; + const baseQuotedAt = typeof capturedAt === 'number' && Number.isFinite(capturedAt) ? new Date(capturedAt).toISOString() : typeof capturedAt === 'string' && capturedAt.trim() ? new Date(capturedAt).toISOString() : null; - var capturedTimestampMs = resolveCapturedTimestampMs(capturedAt); - var basePrice = isFiniteNumber(data.nv) ? data.nv : null; - var baseChangeRate = applySign(isFiniteNumber(data.cr) ? data.cr : null, resolveNaverDirectionSign(((_b = data.rf) === null || _b === void 0 ? void 0 : _b.trim()) ? { code: data.rf } : undefined)); - var previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null; - var currentVolume = (_d = (_c = normalizeNonNegativeVolume(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.accumulatedTradingVolume)) !== null && _c !== void 0 ? _c : normalizeNonNegativeVolume(data.accumulatedTradingVolume)) !== null && _d !== void 0 ? _d : normalizeNonNegativeVolume(data.aq); - var shouldResetToPreviousClose = !hasExtendedSessionQuote && + const capturedTimestampMs = resolveCapturedTimestampMs(capturedAt); + const basePrice = isFiniteNumber(data.nv) ? data.nv : null; + const baseChangeRate = applySign(isFiniteNumber(data.cr) ? data.cr : null, resolveNaverDirectionSign(data.rf?.trim() ? { code: data.rf } : undefined)); + const previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null; + const currentVolume = normalizeNonNegativeVolume(overMarketInfo?.accumulatedTradingVolume) ?? + normalizeNonNegativeVolume(data.accumulatedTradingVolume) ?? + normalizeNonNegativeVolume(data.aq); + const shouldResetToPreviousClose = !hasExtendedSessionQuote && isKoreaMorningResetWindow(capturedTimestampMs) && previousClosePrice !== null; return { currentPrice: hasExtendedSessionQuote ? overPrice : shouldResetToPreviousClose ? previousClosePrice : basePrice, changeRate: hasExtendedSessionQuote ? overChangeRate : shouldResetToPreviousClose ? 0 : baseChangeRate, volumeRate5d: null, - currentVolume: currentVolume, + currentVolume, quotedAt: hasExtendedSessionQuote ? overQuotedAt : baseQuotedAt, - stockName: ((_e = data.nm) === null || _e === void 0 ? void 0 : _e.trim()) || null, + stockName: data.nm?.trim() || null, }; } function choosePreferredQuote(primary, fallback) { - var _a, _b, _c, _d, _e, _f, _g, _h, _j; - if ((primary === null || primary === void 0 ? void 0 : primary.currentPrice) === null && (fallback === null || fallback === void 0 ? void 0 : fallback.currentPrice) === null) { - return (_a = primary !== null && primary !== void 0 ? primary : fallback) !== null && _a !== void 0 ? _a : null; + if (primary?.currentPrice === null && fallback?.currentPrice === null) { + return primary ?? fallback ?? null; } if (!primary) { return fallback; @@ -841,433 +726,279 @@ function choosePreferredQuote(primary, fallback) { if (!fallback) { return primary; } - var primaryQuotedAt = primary.quotedAt ? Date.parse(primary.quotedAt) : Number.NaN; - var fallbackQuotedAt = fallback.quotedAt ? Date.parse(fallback.quotedAt) : Number.NaN; + const primaryQuotedAt = primary.quotedAt ? Date.parse(primary.quotedAt) : Number.NaN; + const fallbackQuotedAt = fallback.quotedAt ? Date.parse(fallback.quotedAt) : Number.NaN; if (Number.isFinite(primaryQuotedAt) && Number.isFinite(fallbackQuotedAt) && fallbackQuotedAt > primaryQuotedAt) { - return __assign(__assign({}, fallback), { stockName: (_b = fallback.stockName) !== null && _b !== void 0 ? _b : primary.stockName, currentVolume: (_c = fallback.currentVolume) !== null && _c !== void 0 ? _c : primary.currentVolume }); + return { + ...fallback, + stockName: fallback.stockName ?? primary.stockName, + currentVolume: fallback.currentVolume ?? primary.currentVolume, + }; } - return __assign(__assign({}, primary), { stockName: (_d = primary.stockName) !== null && _d !== void 0 ? _d : fallback.stockName, currentPrice: (_e = primary.currentPrice) !== null && _e !== void 0 ? _e : fallback.currentPrice, changeRate: (_f = primary.changeRate) !== null && _f !== void 0 ? _f : fallback.changeRate, volumeRate5d: (_g = primary.volumeRate5d) !== null && _g !== void 0 ? _g : fallback.volumeRate5d, currentVolume: (_h = primary.currentVolume) !== null && _h !== void 0 ? _h : fallback.currentVolume, quotedAt: (_j = primary.quotedAt) !== null && _j !== void 0 ? _j : fallback.quotedAt }); + return { + ...primary, + stockName: primary.stockName ?? fallback.stockName, + currentPrice: primary.currentPrice ?? fallback.currentPrice, + changeRate: primary.changeRate ?? fallback.changeRate, + volumeRate5d: primary.volumeRate5d ?? fallback.volumeRate5d, + currentVolume: primary.currentVolume ?? fallback.currentVolume, + quotedAt: primary.quotedAt ?? fallback.quotedAt, + }; } -function fetchNaverRealtimeQuoteByCode(stockCode) { - return __awaiter(this, void 0, void 0, function () { - var quoteUrl, payload, data; - var _a, _b, _c, _d, _e; - return __generator(this, function (_f) { - switch (_f.label) { - case 0: - quoteUrl = new URL('https://polling.finance.naver.com/api/realtime'); - quoteUrl.searchParams.set('query', "SERVICE_ITEM:".concat(stockCode)); - return [4 /*yield*/, fetchJson(quoteUrl, { - headers: { - accept: '*/*', - }, - })]; - case 1: - payload = _f.sent(); - data = (_d = (_c = (_b = (_a = payload.result) === null || _a === void 0 ? void 0 : _a.areas) === null || _b === void 0 ? void 0 : _b.find(function (area) { return area.name === 'SERVICE_ITEM'; })) === null || _c === void 0 ? void 0 : _c.datas) === null || _d === void 0 ? void 0 : _d[0]; - if (!data) { - return [2 /*return*/, null]; - } - return [2 /*return*/, resolveLatestQuoteFromNaverRealtime(data, (_e = payload.result) === null || _e === void 0 ? void 0 : _e.time)]; +async function fetchNaverRealtimeQuoteByCode(stockCode) { + const quoteUrl = new URL('https://polling.finance.naver.com/api/realtime'); + quoteUrl.searchParams.set('query', `SERVICE_ITEM:${stockCode}`); + const payload = await fetchJson(quoteUrl, { + headers: { + accept: '*/*', + }, + }); + const data = payload.result?.areas?.find((area) => area.name === 'SERVICE_ITEM')?.datas?.[0]; + if (!data) { + return null; + } + return resolveLatestQuoteFromNaverRealtime(data, payload.result?.time); +} +async function fetchQuoteBySymbol(symbol) { + const quoteUrl = new URL(`https://query1.finance.yahoo.com/v8/finance/chart/${symbol}`); + quoteUrl.searchParams.set('range', '3mo'); + quoteUrl.searchParams.set('interval', '1d'); + quoteUrl.searchParams.set('includePrePost', 'true'); + quoteUrl.searchParams.set('lang', 'ko-KR'); + quoteUrl.searchParams.set('region', 'KR'); + const payload = await fetchJson(quoteUrl); + const result = payload.chart?.result?.[0]; + const meta = result?.meta; + if (!meta) { + return null; + } + const quote = resolveLatestQuoteFromMeta(meta); + const dailyVolumes = result?.indicators?.quote?.[0]?.volume ?? []; + return { + ...quote, + currentVolume: dailyVolumes[dailyVolumes.length - 1] ?? null, + volumeRate5d: resolveVolumeRate5dFromHistory(dailyVolumes[dailyVolumes.length - 1] ?? null, dailyVolumes), + }; +} +async function fetchQuotesByCodes(stockCodes) { + const normalizedCodes = Array.from(new Set(stockCodes + .map((value) => normalizeStockCode(value)) + .filter(Boolean))); + const quoteMap = new Map(); + if (!normalizedCodes.length) { + return quoteMap; + } + await Promise.all(normalizedCodes.map(async (stockCode) => { + let preferredQuote = null; + try { + preferredQuote = await fetchNaverRealtimeQuoteByCode(stockCode); + } + catch { + // Ignore realtime provider failures and fall back to the next source. + } + const symbols = buildStockSymbols(stockCode); + for (const symbol of symbols) { + try { + const yahooQuote = await fetchQuoteBySymbol(symbol); + preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote); + if (preferredQuote && + (preferredQuote.currentPrice !== null || preferredQuote.stockName) && + preferredQuote.volumeRate5d !== null) { + quoteMap.set(stockCode, preferredQuote); + return; + } } + catch { + // Ignore per-symbol failures and try the next market suffix. + } + } + if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName || preferredQuote.volumeRate5d !== null)) { + quoteMap.set(stockCode, preferredQuote); + } + })); + return quoteMap; +} +async function listStockAlerts(filterType = 'all') { + await ensureStockAlertTable(); + let rows = []; + if (filterType === 'all') { + rows = (await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).select('*').orderBy('updated_at', 'desc')); + } + else { + const matchedCodes = (await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('stock_code') + .where({ alert_type: normalizeAlertType(filterType) }) + .groupBy('stock_code')).map((row) => row.stock_code); + if (!matchedCodes.length) { + return []; + } + rows = (await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('*') + .whereIn('stock_code', matchedCodes) + .orderBy('updated_at', 'desc')); + } + const quotes = await fetchQuotesByCodes(rows.map((row) => row.stock_code)); + const krxItems = await fetchKrxListedStocks(); + const krxByCode = new Map(krxItems.map((item) => [item.stockCode, item])); + const groupedItems = new Map(); + rows.forEach((row) => { + const alertType = normalizeAlertType(row.alert_type); + const quote = quotes.get(row.stock_code); + const krxMatch = krxByCode.get(row.stock_code); + const existing = groupedItems.get(row.stock_code); + if (existing) { + if (!existing.alertTypes.includes(alertType)) { + existing.alertTypes.push(alertType); + existing.alertTypeLabels.push(getAlertTypeLabel(alertType)); + } + const updatedAtTime = Date.parse(normalizeTimestamp(row.updated_at)); + const existingUpdatedAtTime = Date.parse(existing.updatedAt); + if (Number.isFinite(updatedAtTime) && (!Number.isFinite(existingUpdatedAtTime) || updatedAtTime > existingUpdatedAtTime)) { + existing.updatedAt = normalizeTimestamp(row.updated_at); + } + return; + } + groupedItems.set(row.stock_code, { + id: row.stock_code, + stockCode: row.stock_code, + stockName: row.stock_name || krxMatch?.stockName || quote?.stockName || row.stock_code, + alertTypes: [alertType], + alertTypeLabels: [getAlertTypeLabel(alertType)], + currentPrice: quote?.currentPrice ?? null, + changeRate: quote?.changeRate ?? null, + volumeRate5d: quote?.volumeRate5d ?? null, + currentVolume: quote?.currentVolume ?? null, + quotedAt: quote?.quotedAt ?? null, + createdAt: normalizeTimestamp(row.created_at), + updatedAt: normalizeTimestamp(row.updated_at), }); }); + return Array.from(groupedItems.values()).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)); } -function fetchQuoteBySymbol(symbol) { - return __awaiter(this, void 0, void 0, function () { - var quoteUrl, payload, result, meta, quote, dailyVolumes; - var _a, _b, _c, _d, _e, _f, _g, _h; - return __generator(this, function (_j) { - switch (_j.label) { - case 0: - quoteUrl = new URL("https://query1.finance.yahoo.com/v8/finance/chart/".concat(symbol)); - quoteUrl.searchParams.set('range', '3mo'); - quoteUrl.searchParams.set('interval', '1d'); - quoteUrl.searchParams.set('includePrePost', 'true'); - quoteUrl.searchParams.set('lang', 'ko-KR'); - quoteUrl.searchParams.set('region', 'KR'); - return [4 /*yield*/, fetchJson(quoteUrl)]; - case 1: - payload = _j.sent(); - result = (_b = (_a = payload.chart) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b[0]; - meta = result === null || result === void 0 ? void 0 : result.meta; - if (!meta) { - return [2 /*return*/, null]; - } - quote = resolveLatestQuoteFromMeta(meta); - dailyVolumes = (_f = (_e = (_d = (_c = result === null || result === void 0 ? void 0 : result.indicators) === null || _c === void 0 ? void 0 : _c.quote) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.volume) !== null && _f !== void 0 ? _f : []; - return [2 /*return*/, __assign(__assign({}, quote), { currentVolume: (_g = dailyVolumes[dailyVolumes.length - 1]) !== null && _g !== void 0 ? _g : null, volumeRate5d: resolveVolumeRate5dFromHistory((_h = dailyVolumes[dailyVolumes.length - 1]) !== null && _h !== void 0 ? _h : null, dailyVolumes) })]; - } +async function createStockAlert(input) { + await ensureStockAlertTable(); + const identity = await resolveStockIdentity(input); + const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value)))); + if (!alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + const existingRows = (await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('*') + .where({ stock_code: identity.stockCode })); + if (existingRows.length) { + const mergedAlertTypes = Array.from(new Set([...existingRows.map((row) => normalizeAlertType(row.alert_type)), ...alertTypes])); + return updateStockAlert(identity.stockCode, { + stockCode: identity.stockCode, + stockName: identity.stockName, + alertTypes: mergedAlertTypes, }); - }); + } + const now = new Date().toISOString(); + await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).insert(alertTypes.map((alertType) => ({ + id: `stock-alert-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + stock_code: identity.stockCode, + stock_name: identity.stockName, + alert_type: alertType, + created_at: now, + updated_at: now, + }))); + const [created] = await listStockAlerts('all').then((rows) => rows.filter((row) => row.stockCode === identity.stockCode)); + if (!created) { + throw new Error('저장된 종목 알림을 다시 불러오지 못했습니다.'); + } + return created; } -function fetchQuotesByCodes(stockCodes) { - return __awaiter(this, void 0, void 0, function () { - var normalizedCodes, quoteMap; - var _this = this; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - normalizedCodes = Array.from(new Set(stockCodes - .map(function (value) { return normalizeStockCode(value); }) - .filter(Boolean))); - quoteMap = new Map(); - if (!normalizedCodes.length) { - return [2 /*return*/, quoteMap]; - } - return [4 /*yield*/, Promise.all(normalizedCodes.map(function (stockCode) { return __awaiter(_this, void 0, void 0, function () { - var preferredQuote, _a, symbols, _i, symbols_1, symbol, yahooQuote, _b; - return __generator(this, function (_c) { - switch (_c.label) { - case 0: - preferredQuote = null; - _c.label = 1; - case 1: - _c.trys.push([1, 3, , 4]); - return [4 /*yield*/, fetchNaverRealtimeQuoteByCode(stockCode)]; - case 2: - preferredQuote = _c.sent(); - return [3 /*break*/, 4]; - case 3: - _a = _c.sent(); - return [3 /*break*/, 4]; - case 4: - symbols = buildStockSymbols(stockCode); - _i = 0, symbols_1 = symbols; - _c.label = 5; - case 5: - if (!(_i < symbols_1.length)) return [3 /*break*/, 10]; - symbol = symbols_1[_i]; - _c.label = 6; - case 6: - _c.trys.push([6, 8, , 9]); - return [4 /*yield*/, fetchQuoteBySymbol(symbol)]; - case 7: - yahooQuote = _c.sent(); - preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote); - if (preferredQuote && - (preferredQuote.currentPrice !== null || preferredQuote.stockName) && - preferredQuote.volumeRate5d !== null) { - quoteMap.set(stockCode, preferredQuote); - return [2 /*return*/]; - } - return [3 /*break*/, 9]; - case 8: - _b = _c.sent(); - return [3 /*break*/, 9]; - case 9: - _i++; - return [3 /*break*/, 5]; - case 10: - if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName || preferredQuote.volumeRate5d !== null)) { - quoteMap.set(stockCode, preferredQuote); - } - return [2 /*return*/]; - } - }); - }); }))]; - case 1: - _a.sent(); - return [2 /*return*/, quoteMap]; - } - }); +async function updateStockAlert(id, input) { + await ensureStockAlertTable(); + const currentRows = (await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('*') + .where((query) => { + query.where({ stock_code: normalizeStockCode(id) || '__never__' }).orWhere({ id }); + })); + if (!currentRows.length) { + throw new Error('수정할 종목 알림을 찾을 수 없습니다.'); + } + const existing = currentRows[0]; + const identity = await resolveStockIdentity({ + stockCode: input.stockCode ?? existing?.stock_code, + stockName: input.stockName ?? existing?.stock_name, }); + const updatedAt = new Date().toISOString(); + const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value)))); + if (!alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + if (identity.stockCode !== existing.stock_code) { + await ensureNoDuplicateStockCode(identity.stockCode); + } + await client_js_1.db.transaction(async (trx) => { + await trx(exports.STOCK_ALERT_TABLE).where({ stock_code: existing.stock_code }).delete(); + await trx(exports.STOCK_ALERT_TABLE).insert(alertTypes.map((alertType) => ({ + id: `stock-alert-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + stock_code: identity.stockCode, + stock_name: identity.stockName, + alert_type: alertType, + created_at: currentRows.find((row) => row.alert_type === alertType)?.created_at ?? updatedAt, + updated_at: updatedAt, + }))); + }); + const [updated] = await listStockAlerts('all').then((rows) => rows.filter((row) => row.stockCode === identity.stockCode)); + if (!updated) { + throw new Error('수정된 종목 알림을 다시 불러오지 못했습니다.'); + } + return updated; } -function listStockAlerts() { - return __awaiter(this, arguments, void 0, function (filterType) { - var rows, matchedCodes, quotes, krxItems, krxByCode, groupedItems; - if (filterType === void 0) { filterType = 'all'; } - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, ensureStockAlertTable()]; - case 1: - _a.sent(); - rows = []; - if (!(filterType === 'all')) return [3 /*break*/, 3]; - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).select('*').orderBy('updated_at', 'desc')]; - case 2: - rows = (_a.sent()); - return [3 /*break*/, 6]; - case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) - .select('stock_code') - .where({ alert_type: normalizeAlertType(filterType) }) - .groupBy('stock_code')]; - case 4: - matchedCodes = (_a.sent()).map(function (row) { return row.stock_code; }); - if (!matchedCodes.length) { - return [2 /*return*/, []]; - } - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) - .select('*') - .whereIn('stock_code', matchedCodes) - .orderBy('updated_at', 'desc')]; - case 5: - rows = (_a.sent()); - _a.label = 6; - case 6: return [4 /*yield*/, fetchQuotesByCodes(rows.map(function (row) { return row.stock_code; }))]; - case 7: - quotes = _a.sent(); - return [4 /*yield*/, fetchKrxListedStocks()]; - case 8: - krxItems = _a.sent(); - krxByCode = new Map(krxItems.map(function (item) { return [item.stockCode, item]; })); - groupedItems = new Map(); - rows.forEach(function (row) { - var _a, _b, _c, _d, _e; - var alertType = normalizeAlertType(row.alert_type); - var quote = quotes.get(row.stock_code); - var krxMatch = krxByCode.get(row.stock_code); - var existing = groupedItems.get(row.stock_code); - if (existing) { - if (!existing.alertTypes.includes(alertType)) { - existing.alertTypes.push(alertType); - existing.alertTypeLabels.push(getAlertTypeLabel(alertType)); - } - var updatedAtTime = Date.parse(normalizeTimestamp(row.updated_at)); - var existingUpdatedAtTime = Date.parse(existing.updatedAt); - if (Number.isFinite(updatedAtTime) && (!Number.isFinite(existingUpdatedAtTime) || updatedAtTime > existingUpdatedAtTime)) { - existing.updatedAt = normalizeTimestamp(row.updated_at); - } - return; - } - groupedItems.set(row.stock_code, { - id: row.stock_code, - stockCode: row.stock_code, - stockName: row.stock_name || (krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.stockName) || (quote === null || quote === void 0 ? void 0 : quote.stockName) || row.stock_code, - alertTypes: [alertType], - alertTypeLabels: [getAlertTypeLabel(alertType)], - currentPrice: (_a = quote === null || quote === void 0 ? void 0 : quote.currentPrice) !== null && _a !== void 0 ? _a : null, - changeRate: (_b = quote === null || quote === void 0 ? void 0 : quote.changeRate) !== null && _b !== void 0 ? _b : null, - volumeRate5d: (_c = quote === null || quote === void 0 ? void 0 : quote.volumeRate5d) !== null && _c !== void 0 ? _c : null, - currentVolume: (_d = quote === null || quote === void 0 ? void 0 : quote.currentVolume) !== null && _d !== void 0 ? _d : null, - quotedAt: (_e = quote === null || quote === void 0 ? void 0 : quote.quotedAt) !== null && _e !== void 0 ? _e : null, - createdAt: normalizeTimestamp(row.created_at), - updatedAt: normalizeTimestamp(row.updated_at), - }); - }); - return [2 /*return*/, Array.from(groupedItems.values()).sort(function (left, right) { return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); })]; - } - }); - }); +async function deleteStockAlert(id) { + await ensureStockAlertTable(); + const normalizedCode = normalizeStockCode(id); + const count = normalizedCode + ? await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ stock_code: normalizedCode }).delete() + : await (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ id }).delete(); + if (!count) { + throw new Error('삭제할 종목 알림을 찾을 수 없습니다.'); + } } -function createStockAlert(input) { - return __awaiter(this, void 0, void 0, function () { - var identity, alertTypes, existingRows, mergedAlertTypes, now, created; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, ensureStockAlertTable()]; - case 1: - _a.sent(); - return [4 /*yield*/, resolveStockIdentity(input)]; - case 2: - identity = _a.sent(); - alertTypes = Array.from(new Set(input.alertTypes.map(function (value) { return normalizeAlertType(value); }))); - if (!alertTypes.length) { - throw new Error('알림유형을 하나 이상 선택해 주세요.'); - } - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) - .select('*') - .where({ stock_code: identity.stockCode })]; - case 3: - existingRows = (_a.sent()); - if (existingRows.length) { - mergedAlertTypes = Array.from(new Set(__spreadArray(__spreadArray([], existingRows.map(function (row) { return normalizeAlertType(row.alert_type); }), true), alertTypes, true))); - return [2 /*return*/, updateStockAlert(identity.stockCode, { - stockCode: identity.stockCode, - stockName: identity.stockName, - alertTypes: mergedAlertTypes, - })]; - } - now = new Date().toISOString(); - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).insert(alertTypes.map(function (alertType) { return ({ - id: "stock-alert-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), - stock_code: identity.stockCode, - stock_name: identity.stockName, - alert_type: alertType, - created_at: now, - updated_at: now, - }); }))]; - case 4: - _a.sent(); - return [4 /*yield*/, listStockAlerts('all').then(function (rows) { return rows.filter(function (row) { return row.stockCode === identity.stockCode; }); })]; - case 5: - created = (_a.sent())[0]; - if (!created) { - throw new Error('저장된 종목 알림을 다시 불러오지 못했습니다.'); - } - return [2 /*return*/, created]; - } - }); - }); -} -function updateStockAlert(id, input) { - return __awaiter(this, void 0, void 0, function () { - var currentRows, existing, identity, updatedAt, alertTypes, updated; - var _this = this; - var _a, _b; - return __generator(this, function (_c) { - switch (_c.label) { - case 0: return [4 /*yield*/, ensureStockAlertTable()]; - case 1: - _c.sent(); - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) - .select('*') - .where(function (query) { - query.where({ stock_code: normalizeStockCode(id) || '__never__' }).orWhere({ id: id }); - })]; - case 2: - currentRows = (_c.sent()); - if (!currentRows.length) { - throw new Error('수정할 종목 알림을 찾을 수 없습니다.'); - } - existing = currentRows[0]; - return [4 /*yield*/, resolveStockIdentity({ - stockCode: (_a = input.stockCode) !== null && _a !== void 0 ? _a : existing === null || existing === void 0 ? void 0 : existing.stock_code, - stockName: (_b = input.stockName) !== null && _b !== void 0 ? _b : existing === null || existing === void 0 ? void 0 : existing.stock_name, - })]; - case 3: - identity = _c.sent(); - updatedAt = new Date().toISOString(); - alertTypes = Array.from(new Set(input.alertTypes.map(function (value) { return normalizeAlertType(value); }))); - if (!alertTypes.length) { - throw new Error('알림유형을 하나 이상 선택해 주세요.'); - } - if (!(identity.stockCode !== existing.stock_code)) return [3 /*break*/, 5]; - return [4 /*yield*/, ensureNoDuplicateStockCode(identity.stockCode)]; - case 4: - _c.sent(); - _c.label = 5; - case 5: return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, trx(exports.STOCK_ALERT_TABLE).where({ stock_code: existing.stock_code }).delete()]; - case 1: - _a.sent(); - return [4 /*yield*/, trx(exports.STOCK_ALERT_TABLE).insert(alertTypes.map(function (alertType) { - var _a, _b; - return ({ - id: "stock-alert-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), - stock_code: identity.stockCode, - stock_name: identity.stockName, - alert_type: alertType, - created_at: (_b = (_a = currentRows.find(function (row) { return row.alert_type === alertType; })) === null || _a === void 0 ? void 0 : _a.created_at) !== null && _b !== void 0 ? _b : updatedAt, - updated_at: updatedAt, - }); - }))]; - case 2: - _a.sent(); - return [2 /*return*/]; - } - }); - }); })]; - case 6: - _c.sent(); - return [4 /*yield*/, listStockAlerts('all').then(function (rows) { return rows.filter(function (row) { return row.stockCode === identity.stockCode; }); })]; - case 7: - updated = (_c.sent())[0]; - if (!updated) { - throw new Error('수정된 종목 알림을 다시 불러오지 못했습니다.'); - } - return [2 /*return*/, updated]; - } - }); - }); -} -function deleteStockAlert(id) { - return __awaiter(this, void 0, void 0, function () { - var normalizedCode, count, _a; - return __generator(this, function (_b) { - switch (_b.label) { - case 0: return [4 /*yield*/, ensureStockAlertTable()]; - case 1: - _b.sent(); - normalizedCode = normalizeStockCode(id); - if (!normalizedCode) return [3 /*break*/, 3]; - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ stock_code: normalizedCode }).delete()]; - case 2: - _a = _b.sent(); - return [3 /*break*/, 5]; - case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ id: id }).delete()]; - case 4: - _a = _b.sent(); - _b.label = 5; - case 5: - count = _a; - if (!count) { - throw new Error('삭제할 종목 알림을 찾을 수 없습니다.'); - } - return [2 /*return*/]; - } - }); - }); -} -function saveStockAlerts(items) { - return __awaiter(this, void 0, void 0, function () { - var seenCodes, _i, items_1, item, normalizedCode, savedItems, _a, items_2, item, trimmedId, savedItem, _b; - var _c, _d; - return __generator(this, function (_e) { - switch (_e.label) { - case 0: return [4 /*yield*/, ensureStockAlertTable()]; - case 1: - _e.sent(); - seenCodes = new Set(); - for (_i = 0, items_1 = items; _i < items_1.length; _i++) { - item = items_1[_i]; - normalizedCode = normalizeStockCode((_c = item.stockCode) !== null && _c !== void 0 ? _c : ''); - if (!normalizedCode) { - continue; - } - if (seenCodes.has(normalizedCode)) { - throw new Error('동일한 종목코드를 중복 저장할 수 없습니다.'); - } - if (!item.alertTypes.length) { - throw new Error('알림유형을 하나 이상 선택해 주세요.'); - } - seenCodes.add(normalizedCode); - } - savedItems = []; - _a = 0, items_2 = items; - _e.label = 2; - case 2: - if (!(_a < items_2.length)) return [3 /*break*/, 8]; - item = items_2[_a]; - trimmedId = (_d = item.id) === null || _d === void 0 ? void 0 : _d.trim(); - if (!trimmedId) return [3 /*break*/, 4]; - return [4 /*yield*/, updateStockAlert(trimmedId, item)]; - case 3: - _b = _e.sent(); - return [3 /*break*/, 6]; - case 4: return [4 /*yield*/, createStockAlert(item)]; - case 5: - _b = _e.sent(); - _e.label = 6; - case 6: - savedItem = _b; - savedItems.push(savedItem); - _e.label = 7; - case 7: - _a++; - return [3 /*break*/, 2]; - case 8: return [2 /*return*/, savedItems]; - } - }); - }); +async function saveStockAlerts(items) { + await ensureStockAlertTable(); + const seenCodes = new Set(); + for (const item of items) { + const normalizedCode = normalizeStockCode(item.stockCode ?? ''); + if (!normalizedCode) { + continue; + } + if (seenCodes.has(normalizedCode)) { + throw new Error('동일한 종목코드를 중복 저장할 수 없습니다.'); + } + if (!item.alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + seenCodes.add(normalizedCode); + } + const savedItems = []; + for (const item of items) { + const trimmedId = item.id?.trim(); + const savedItem = trimmedId + ? await updateStockAlert(trimmedId, item) + : await createStockAlert(item); + savedItems.push(savedItem); + } + return savedItems; } function formatStockAlertPrice(value) { if (!isFiniteNumber(value)) { return '-'; } - return "".concat(Math.round(value).toLocaleString('ko-KR'), "\u20A9"); + return `${Math.round(value).toLocaleString('ko-KR')}₩`; } function formatStockAlertChangeRate(value) { if (!isFiniteNumber(value)) { return '(변동률 확인불가)'; } if (value > 0) { - return "(+".concat(value.toFixed(2), "% \u25B2)"); + return `(+${value.toFixed(2)}% ▲)`; } if (value < 0) { - return "(".concat(value.toFixed(2), "% \u25BC)"); + return `(${value.toFixed(2)}% ▼)`; } return '(0.00% -)'; } @@ -1280,88 +1011,107 @@ function canBuildChangeThresholdStockAlertLine(item) { function buildCurrentPriceStockAlertLines(items) { return items .filter(canBuildCurrentPriceStockAlertLine) - .map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); + .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`); } function buildChangeRateThresholdStockAlertLines(items, thresholdPercent) { return items - .filter(function (item) { var _a; return canBuildChangeThresholdStockAlertLine(item) && Math.abs((_a = item.changeRate) !== null && _a !== void 0 ? _a : 0) >= thresholdPercent; }) - .sort(function (left, right) { - var _a, _b; - var changeRateGap = Math.abs(((_a = right.changeRate) !== null && _a !== void 0 ? _a : 0)) - Math.abs(((_b = left.changeRate) !== null && _b !== void 0 ? _b : 0)); + .filter((item) => canBuildChangeThresholdStockAlertLine(item) && Math.abs(item.changeRate ?? 0) >= thresholdPercent) + .sort((left, right) => { + const changeRateGap = Math.abs((right.changeRate ?? 0)) - Math.abs((left.changeRate ?? 0)); if (changeRateGap !== 0) { return changeRateGap; } return left.stockName.localeCompare(right.stockName, 'ko-KR'); }) - .map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); + .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`); } -function listStockAlertVolumeSnapshots() { - return __awaiter(this, void 0, void 0, function () { - var rows; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, ensureStockAlertVolumeSnapshotTable()]; - case 1: - _a.sent(); - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) - .select('*') - .orderBy('updated_at', 'desc')]; - case 2: - rows = (_a.sent()); - return [2 /*return*/, new Map(rows - .map(function (row) { return normalizeStockAlertVolumeSnapshotRow(row); }) - .filter(function (row) { return row.stockCode; }) - .map(function (row) { return [row.stockCode, row]; }))]; - } - }); +async function listStockAlertVolumeSnapshots() { + await ensureStockAlertVolumeSnapshotTable(); + const rows = (await (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) + .select('*') + .orderBy('updated_at', 'desc')); + return new Map(rows + .map((row) => normalizeStockAlertVolumeSnapshotRow(row)) + .filter((row) => row.stockCode) + .map((row) => [row.stockCode, row])); +} +async function listRecentStockAlertVolumeHistories(limitPerStock = 5) { + await ensureStockAlertVolumeHistoryTable(); + const rows = (await (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_HISTORY_TABLE) + .select('*') + .orderBy('created_at', 'desc')); + const historyMap = new Map(); + 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, previousSnapshots) { + await ensureStockAlertVolumeSnapshotTable(); + if (!items.length) { + return; + } + const records = items.map((item) => buildStockAlertVolumeSnapshotRecord(item, item.currentVolume, previousSnapshots.get(item.stockCode) ?? null)); + await (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) + .insert(records) + .onConflict('stock_code') + .merge({ + stock_name: client_js_1.db.ref('excluded.stock_name'), + previous_volume: client_js_1.db.ref('excluded.previous_volume'), + current_volume: client_js_1.db.ref('excluded.current_volume'), + volume_increase_percent: client_js_1.db.ref('excluded.volume_increase_percent'), + current_price: client_js_1.db.ref('excluded.current_price'), + change_rate: client_js_1.db.ref('excluded.change_rate'), + quoted_at: client_js_1.db.ref('excluded.quoted_at'), + updated_at: client_js_1.db.ref('excluded.updated_at'), }); } -function upsertStockAlertVolumeSnapshots(items, previousSnapshots) { - return __awaiter(this, void 0, void 0, function () { - var records; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, ensureStockAlertVolumeSnapshotTable()]; - case 1: - _a.sent(); - if (!items.length) { - return [2 /*return*/]; - } - records = items.map(function (item) { var _a; return buildStockAlertVolumeSnapshotRecord(item, item.currentVolume, (_a = previousSnapshots.get(item.stockCode)) !== null && _a !== void 0 ? _a : null); }); - return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) - .insert(records) - .onConflict('stock_code') - .merge({ - stock_name: client_js_1.db.ref('excluded.stock_name'), - previous_volume: client_js_1.db.ref('excluded.previous_volume'), - current_volume: client_js_1.db.ref('excluded.current_volume'), - volume_increase_percent: client_js_1.db.ref('excluded.volume_increase_percent'), - current_price: client_js_1.db.ref('excluded.current_price'), - change_rate: client_js_1.db.ref('excluded.change_rate'), - quoted_at: client_js_1.db.ref('excluded.quoted_at'), - updated_at: client_js_1.db.ref('excluded.updated_at'), - })]; - case 2: - _a.sent(); - return [2 /*return*/]; - } - }); - }); +async function insertStockAlertVolumeHistories(items, previousSnapshots) { + 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 (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_HISTORY_TABLE).insert(records).onConflict('id').ignore(); } -function buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options) { +function buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, recentHistories, options) { return items - .flatMap(function (item) { - var _a, _b; + .flatMap((item) => { if (!canBuildChangeThresholdStockAlertLine(item)) { return []; } - var previousSnapshot = previousSnapshots.get(item.stockCode); - var previousVolume = (_a = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && _a !== void 0 ? _a : null; - var currentVolume = normalizeNonNegativeVolume(item.currentVolume); - var volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume); - if (volumeIncreasePercent === null || - Math.abs((_b = item.changeRate) !== null && _b !== void 0 ? _b : 0) < options.thresholdPercent || - volumeIncreasePercent < options.minVolumeIncreasePercent) { + const previousSnapshot = previousSnapshots.get(item.stockCode); + const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null); + const currentVolume = normalizeNonNegativeVolume(item.currentVolume); + const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume); + const recentMaxVolumeIncreasePercent = Math.max(...((recentHistories.get(item.stockCode) ?? []) + .map((history) => history.volumeIncreasePercent) + .filter((value) => isFiniteNumber(value)))); + const normalizedRecentMaxVolumeIncreasePercent = Number.isFinite(recentMaxVolumeIncreasePercent) + ? recentMaxVolumeIncreasePercent + : null; + const volumeAmplificationGrowthPercent = calculateVolumeAmplificationGrowthPercent(volumeIncreasePercent, normalizedRecentMaxVolumeIncreasePercent); + if (volumeAmplificationGrowthPercent === null || + Math.abs(item.changeRate ?? 0) < options.thresholdPercent || + volumeAmplificationGrowthPercent < options.minVolumeIncreasePercent) { return []; } return [ @@ -1370,42 +1120,43 @@ function buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapsh stockName: item.stockName, currentPrice: item.currentPrice, changeRate: item.changeRate, - currentVolume: currentVolume, - previousVolume: previousVolume, - volumeIncreasePercent: volumeIncreasePercent, + currentVolume, + previousVolume, + volumeIncreasePercent, + recentMaxVolumeIncreasePercent: normalizedRecentMaxVolumeIncreasePercent, + volumeAmplificationGrowthPercent, quotedAt: item.quotedAt, }, ]; }) - .sort(function (left, right) { - var _a, _b, _c, _d; - var changeRateGap = Math.abs(((_a = right.changeRate) !== null && _a !== void 0 ? _a : 0)) - Math.abs(((_b = left.changeRate) !== null && _b !== void 0 ? _b : 0)); + .sort((left, right) => { + const changeRateGap = Math.abs((right.changeRate ?? 0)) - Math.abs((left.changeRate ?? 0)); if (changeRateGap !== 0) { return changeRateGap; } - var volumeGap = ((_c = right.volumeIncreasePercent) !== null && _c !== void 0 ? _c : 0) - ((_d = left.volumeIncreasePercent) !== null && _d !== void 0 ? _d : 0); + const volumeGap = (right.volumeAmplificationGrowthPercent ?? 0) - (left.volumeAmplificationGrowthPercent ?? 0); if (volumeGap !== 0) { return volumeGap; } return left.stockName.localeCompare(right.stockName, 'ko-KR'); }); } -function buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, options) { - return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); +function buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, options) { + return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, recentHistories, options).map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`); } function createSkippedNotificationResult(reason) { - var skippedWebResult = { + const skippedWebResult = { ok: true, skipped: true, - reason: reason, + reason, sentCount: 0, failedCount: 0, invalidEndpoints: [], }; - var skippedIosResult = { + const skippedIosResult = { ok: true, skipped: true, - reason: reason, + reason, sentCount: 0, failedCount: 0, invalidTokens: [], @@ -1416,212 +1167,195 @@ function createSkippedNotificationResult(reason) { }; } function buildStockAlertNotificationIdentity(options) { - var modeKey = options.mode === 'price' + const modeKey = options.mode === 'price' ? 'current-price' : options.mode === 'change-threshold' ? 'change-threshold' : 'change-threshold-volume-spike'; - var legacyNotificationKey = "".concat(options.serviceKey, ":current-price"); - var legacyModeNotificationKey = "".concat(options.serviceKey, ":").concat(modeKey); + const legacyNotificationKey = `${options.serviceKey}:current-price`; + const legacyModeNotificationKey = `${options.serviceKey}:${modeKey}`; return { - threadId: "schedule-stock-alert:".concat(options.scheduleId), - notificationKey: "schedule-stock-alert:".concat(options.scheduleId), - notificationScope: "schedule-stock-alert:".concat(options.scheduleId), - notificationAliases: __spreadArray([], new Set([ - options.serviceKey, - legacyNotificationKey, - legacyModeNotificationKey, - options.scheduleId === 2 ? STOCK_ALERT_NOTIFICATION_SCOPE : '', - options.scheduleId === 2 ? "".concat(STOCK_ALERT_NOTIFICATION_SCOPE, ":current-price") : '', - ].filter(Boolean)), true), + threadId: `schedule-stock-alert:${options.scheduleId}`, + notificationKey: `schedule-stock-alert:${options.scheduleId}`, + notificationScope: `schedule-stock-alert:${options.scheduleId}`, + notificationAliases: [...new Set([ + options.serviceKey, + legacyNotificationKey, + legacyModeNotificationKey, + options.scheduleId === 2 ? STOCK_ALERT_NOTIFICATION_SCOPE : '', + options.scheduleId === 2 ? `${STOCK_ALERT_NOTIFICATION_SCOPE}:current-price` : '', + ].filter(Boolean))], }; } -function sendManagedStockAlertWebPush(options) { - return __awaiter(this, void 0, void 0, function () { - var thresholdPercent, minVolumeIncreasePercent, items, previousSnapshots, _a, lines, hasRegisteredTargets, hasComparableVolumeBaseline, skippedReason, skippedResult, body, notificationIdentity, result; - var _b, _c; - return __generator(this, function (_d) { - switch (_d.label) { - case 0: - thresholdPercent = Math.max(0, Number((_b = options.thresholdPercent) !== null && _b !== void 0 ? _b : 5)); - minVolumeIncreasePercent = Math.max(0, Number((_c = options.minVolumeIncreasePercent) !== null && _c !== void 0 ? _c : 300)); - return [4 /*yield*/, listStockAlerts(options.mode === 'price' ? 'price' : 'all')]; - case 1: - items = _d.sent(); - if (!(options.mode === 'change-threshold-volume-spike')) return [3 /*break*/, 3]; - return [4 /*yield*/, listStockAlertVolumeSnapshots()]; - case 2: - _a = _d.sent(); - return [3 /*break*/, 4]; - case 3: - _a = new Map(); - _d.label = 4; - case 4: - previousSnapshots = _a; - lines = options.mode === 'price' - ? buildCurrentPriceStockAlertLines(items) - : options.mode === 'change-threshold' - ? buildChangeRateThresholdStockAlertLines(items, thresholdPercent) - : buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, { - thresholdPercent: thresholdPercent, - minVolumeIncreasePercent: minVolumeIncreasePercent, - }); - hasRegisteredTargets = options.mode === 'price' ? items.some(function (item) { return item.alertTypes.includes('price'); }) : items.length > 0; - hasComparableVolumeBaseline = options.mode !== 'change-threshold-volume-spike' - ? false - : items.some(function (item) { - var previousSnapshot = previousSnapshots.get(item.stockCode); - return (previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && (previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== undefined; - }); - skippedReason = options.mode === 'price' - ? hasRegisteredTargets - ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' - : '현재가로 등록된 종목이 없습니다.' - : options.mode === 'change-threshold' - ? hasRegisteredTargets - ? "".concat(thresholdPercent, "% \uC774\uC0C1 \uBCC0\uB3D9 \uC885\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.") - : '등록된 종목이 없습니다.' - : !hasRegisteredTargets - ? '등록된 종목이 없습니다.' - : !hasComparableVolumeBaseline - ? '\uC774\uC804 \uAC70\uB798\uB7C9 \uB610\uB294 5\uC601\uC5C5\uC77C \uD3C9\uADE0 \uAC70\uB798\uB7C9 \uBE44\uAD50 \uAE30\uC900\uC774 \uC5C6\uC5B4 \uC2A4\uB0C5\uC0F7\uB9CC \uAC31\uC2E0\uD588\uC2B5\uB2C8\uB2E4.' - : "\uB4F1\uB77D\uB960 ".concat(thresholdPercent, "% \uC774\uC0C1\uC774\uBA74\uC11C \uC9C1\uC804 \uAC70\uB798\uB7C9 \uC99D\uD3ED\uC218 \uB300\uBE44 \uC774\uBC88 \uAC70\uB798\uB7C9 \uC99D\uD3ED\uC774 ").concat(minVolumeIncreasePercent, "% \uC774\uC0C1\uC778 \uC885\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."); - skippedResult = createSkippedNotificationResult(skippedReason); - if (!(options.mode === 'change-threshold-volume-spike')) return [3 /*break*/, 6]; - return [4 /*yield*/, upsertStockAlertVolumeSnapshots(items, previousSnapshots)]; - case 5: - _d.sent(); - _d.label = 6; - case 6: - if (!lines.length) { - return [2 /*return*/, { - ok: true, - skipped: true, - reason: skippedReason, - title: options.title, - body: '', - itemCount: 0, - lines: [], - ios: skippedResult.ios, - web: skippedResult.web, - }]; - } - body = lines.join('\n'); - notificationIdentity = buildStockAlertNotificationIdentity(options); - return [4 /*yield*/, (0, notification_service_js_1.sendNotifications)({ - title: options.title, - body: body, - threadId: notificationIdentity.threadId, - targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN], - data: { - category: 'stock-alert', - eventType: options.mode === 'price' - ? 'stock-alert-current-price' - : options.mode === 'change-threshold' - ? 'stock-alert-change-threshold' - : 'stock-alert-change-threshold-volume-spike', - notificationKey: notificationIdentity.notificationKey, - notificationScope: notificationIdentity.notificationScope, - notificationAliases: JSON.stringify(notificationIdentity.notificationAliases), - replaceExistingScope: 'true', - source: options.serviceKey, - targetUrl: STOCK_ALERT_NOTIFICATION_TARGET_URL, - }, - }, { - disableIos: true, - })]; - case 7: - result = _d.sent(); - return [2 /*return*/, __assign(__assign({}, result), { title: options.title, body: body, itemCount: lines.length, lines: lines })]; +async function sendManagedStockAlertWebPush(options) { + const thresholdPercent = Math.max(0, Number(options.thresholdPercent ?? 5)); + const minVolumeIncreasePercent = Math.max(0, Number(options.minVolumeIncreasePercent ?? 300)); + const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all'); + const previousSnapshots = options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map(); + const recentHistories = options.mode === 'change-threshold-volume-spike' ? await listRecentStockAlertVolumeHistories() : new Map(); + const lines = options.mode === 'price' + ? buildCurrentPriceStockAlertLines(items) + : options.mode === 'change-threshold' + ? buildChangeRateThresholdStockAlertLines(items, thresholdPercent) + : buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, { + thresholdPercent, + minVolumeIncreasePercent, + }); + const hasRegisteredTargets = options.mode === 'price' ? items.some((item) => item.alertTypes.includes('price')) : items.length > 0; + const hasComparableVolumeBaseline = options.mode !== 'change-threshold-volume-spike' + ? false + : items.some((item) => { + 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 + ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' + : '현재가로 등록된 종목이 없습니다.' + : options.mode === 'change-threshold' + ? hasRegisteredTargets + ? `${thresholdPercent}% 이상 변동 종목이 없습니다.` + : '등록된 종목이 없습니다.' + : !hasRegisteredTargets + ? '등록된 종목이 없습니다.' + : !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) { + return { + ok: true, + skipped: true, + reason: skippedReason, + title: options.title, + body: '', + itemCount: 0, + lines: [], + ios: skippedResult.ios, + web: skippedResult.web, + }; + } + const body = lines.join('\n'); + const notificationIdentity = buildStockAlertNotificationIdentity(options); + const result = await (0, notification_service_js_1.sendNotifications)({ + title: options.title, + body, + threadId: notificationIdentity.threadId, + targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN], + data: { + category: 'stock-alert', + eventType: options.mode === 'price' + ? 'stock-alert-current-price' + : options.mode === 'change-threshold' + ? 'stock-alert-change-threshold' + : 'stock-alert-change-threshold-volume-spike', + notificationKey: notificationIdentity.notificationKey, + notificationScope: notificationIdentity.notificationScope, + notificationAliases: JSON.stringify(notificationIdentity.notificationAliases), + replaceExistingScope: 'true', + source: options.serviceKey, + targetUrl: STOCK_ALERT_NOTIFICATION_TARGET_URL, + }, + }, { + disableIos: true, + }); + return { + ...result, + title: options.title, + body, + itemCount: lines.length, + lines, + }; +} +async function sendCurrentPriceStockAlertWebPush() { + return sendManagedStockAlertWebPush({ + scheduleId: 2, + serviceKey: STOCK_ALERT_NOTIFICATION_SCOPE, + title: STOCK_ALERT_NOTIFICATION_TITLE, + mode: 'price', + }); +} +async function updateStockAlertLayoutFeatureDescription() { + await ensureStockAlertTable(); + const layoutRecord = await (0, client_js_1.db)('play_layouts').select('id', 'tree').where({ name: exports.STOCK_ALERT_LAYOUT_NAME }).first(); + if (!layoutRecord || typeof layoutRecord !== 'object') { + return false; + } + const tree = layoutRecord.tree; + if (!tree || !Array.isArray(tree.interactions)) { + return false; + } + let changed = false; + const nextInteractions = tree.interactions.map((interaction) => { + const title = interaction.title?.trim(); + if (title === '그리드 기본정의') { + const nextDescription = [ + '## 그리드 필드를 아래로 정의하세요.', + ' - 종목명, 등락률, 현재가, 기준일시, 알림유형', + '## 숨긴필드', + ' - 종목코드', + '## DB관리 데이터', + ' - 종목코드, 알림유형', + '## 외부 API 및 가공 데이터', + ' - DB에 저장된 종목코드에 대한 현재가 등락률 기준일시를 표현', + ' - 현재가 데이터는 정규장이 아닌 가격도 모두 가져오도록 해주세요.', + '## 서비스 구현', + ' - 해당 그리드 데이터를 저장할 DB 및 서비스 API를 구현허고 연결하세요. CRUD', + ' - 알림유형은 한종목에 멀티로 저장되어야 합니다.', + '## 입력', + '알림유형의 경우 멀티선택 가능하게 해주세요.', + ].join('\n'); + const nextNotes = '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; + return { + ...interaction, + description: nextDescription, + implementationNotes: nextNotes, + }; } - }); - }); -} -function sendCurrentPriceStockAlertWebPush() { - return __awaiter(this, void 0, void 0, function () { - return __generator(this, function (_a) { - return [2 /*return*/, sendManagedStockAlertWebPush({ - scheduleId: 2, - serviceKey: STOCK_ALERT_NOTIFICATION_SCOPE, - title: STOCK_ALERT_NOTIFICATION_TITLE, - mode: 'price', - })]; - }); - }); -} -function updateStockAlertLayoutFeatureDescription() { - return __awaiter(this, void 0, void 0, function () { - var layoutRecord, tree, changed, nextInteractions; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, ensureStockAlertTable()]; - case 1: - _a.sent(); - return [4 /*yield*/, (0, client_js_1.db)('play_layouts').select('id', 'tree').where({ name: exports.STOCK_ALERT_LAYOUT_NAME }).first()]; - case 2: - layoutRecord = _a.sent(); - if (!layoutRecord || typeof layoutRecord !== 'object') { - return [2 /*return*/, false]; - } - tree = layoutRecord.tree; - if (!tree || !Array.isArray(tree.interactions)) { - return [2 /*return*/, false]; - } - changed = false; - nextInteractions = tree.interactions.map(function (interaction) { - var _a; - var title = (_a = interaction.title) === null || _a === void 0 ? void 0 : _a.trim(); - if (title === '그리드 기본정의') { - var nextDescription = [ - '## 그리드 필드를 아래로 정의하세요.', - ' - 종목명, 등락률, 현재가, 기준일시, 알림유형', - '## 숨긴필드', - ' - 종목코드', - '## DB관리 데이터', - ' - 종목코드, 알림유형', - '## 외부 API 및 가공 데이터', - ' - DB에 저장된 종목코드에 대한 현재가 등락률 기준일시를 표현', - ' - 현재가 데이터는 정규장이 아닌 가격도 모두 가져오도록 해주세요.', - '## 서비스 구현', - ' - 해당 그리드 데이터를 저장할 DB 및 서비스 API를 구현허고 연결하세요. CRUD', - ' - 알림유형은 한종목에 멀티로 저장되어야 합니다.', - '## 입력', - '알림유형의 경우 멀티선택 가능하게 해주세요.', - ].join('\n'); - var nextNotes = 'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.'; - if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) { - changed = true; - return __assign(__assign({}, interaction), { description: nextDescription, implementationNotes: nextNotes }); - } - } - if (title === '얼림유형 검색') { - var nextNotes = 'Primary Pane Select Input 값은 all, price, top3 코드로 유지하고 Secondary Pane 그리드 조회 파라미터 alertType 으로 바로 전달합니다.'; - if (interaction.implementationNotes !== nextNotes) { - changed = true; - return __assign(__assign({}, interaction), { implementationNotes: nextNotes }); - } - } - if (title === '행추가 기능') { - var nextNotes = 'GET /api/stock-alerts/search?query=... 로 종목명/종목코드를 조회하고, 선택한 종목코드·종목명·시장구분을 그리드 신규 행으로 매핑합니다. 동일 종목코드는 중복 추가를 막고, 선택 후 해당 행으로 포커스를 이동합니다.'; - if (interaction.implementationNotes !== nextNotes) { - changed = true; - return __assign(__assign({}, interaction), { implementationNotes: nextNotes }); - } - } - return interaction; - }); - if (!changed) { - return [2 /*return*/, false]; - } - return [4 /*yield*/, (0, client_js_1.db)('play_layouts') - .where({ id: layoutRecord.id }) - .update({ - tree: __assign(__assign({}, tree), { interactions: nextInteractions }), - })]; - case 3: - _a.sent(); - return [2 /*return*/, true]; + } + if (title === '얼림유형 검색') { + const nextNotes = 'Primary Pane Select Input 값은 all, price, top3 코드로 유지하고 Secondary Pane 그리드 조회 파라미터 alertType 으로 바로 전달합니다.'; + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return { + ...interaction, + implementationNotes: nextNotes, + }; } - }); + } + if (title === '행추가 기능') { + const nextNotes = 'GET /api/stock-alerts/search?query=... 로 종목명/종목코드를 조회하고, 선택한 종목코드·종목명·시장구분을 그리드 신규 행으로 매핑합니다. 동일 종목코드는 중복 추가를 막고, 선택 후 해당 행으로 포커스를 이동합니다.'; + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return { + ...interaction, + implementationNotes: nextNotes, + }; + } + } + return interaction; }); + if (!changed) { + return false; + } + await (0, client_js_1.db)('play_layouts') + .where({ id: layoutRecord.id }) + .update({ + tree: { + ...tree, + interactions: nextInteractions, + }, + }); + return true; } diff --git a/etc/servers/work-server/src/services/stock-alert-service.test.ts b/etc/servers/work-server/src/services/stock-alert-service.test.ts index 7b63950..9cd5bb6 100644 --- a/etc/servers/work-server/src/services/stock-alert-service.test.ts +++ b/etc/servers/work-server/src/services/stock-alert-service.test.ts @@ -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', () => { diff --git a/etc/servers/work-server/src/services/stock-alert-service.ts b/etc/servers/work-server/src/services/stock-alert-service.ts index 4531bef..6954de2 100644 --- a/etc/servers/work-server/src/services/stock-alert-service.ts +++ b/etc/servers/work-server/src/services/stock-alert-service.ts @@ -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 & { 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 & { 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(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(STOCK_ALERT_VOLUME_HISTORY_TABLE) + .select('*') + .orderBy('created_at', 'desc')) as StockAlertVolumeHistoryRow[]; + const historyMap = new Map(); + + 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, @@ -1523,9 +1606,35 @@ async function upsertStockAlertVolumeSnapshots( }); } +async function insertStockAlertVolumeHistories( + items: StockAlertItem[], + previousSnapshots: Map, +) { + 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, + recentHistories: Map, 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, + recentHistories: Map, 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(); + const recentHistories = + options.mode === 'change-threshold-volume-spike' ? await listRecentStockAlertVolumeHistories() : new Map(); 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; diff --git a/etc/servers/work-server/src/services/work-server-build-service.ts b/etc/servers/work-server/src/services/work-server-build-service.ts index f4b0732..cd6146a 100755 --- a/etc/servers/work-server/src/services/work-server-build-service.ts +++ b/etc/servers/work-server/src/services/work-server-build-service.ts @@ -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'), diff --git a/resource/prod/.gitkeep b/resource/prod/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/resource/prod/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/resource/prod/clipboard-20260506-214618-1.html b/resource/prod/clipboard-20260506-214618-1.html deleted file mode 100644 index ca2a761..0000000 --- a/resource/prod/clipboard-20260506-214618-1.html +++ /dev/null @@ -1 +0,0 @@ -/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f \ No newline at end of file diff --git a/resource/prod/clipboard-20260506-214618-2.txt b/resource/prod/clipboard-20260506-214618-2.txt deleted file mode 100644 index 6691f8d..0000000 --- a/resource/prod/clipboard-20260506-214618-2.txt +++ /dev/null @@ -1 +0,0 @@ -/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f \ No newline at end of file diff --git a/resource/release/.gitkeep b/resource/release/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/resource/release/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/resource/release/IMG_9111.PNG b/resource/release/IMG_9111.PNG deleted file mode 100644 index 4c47c56..0000000 Binary files a/resource/release/IMG_9111.PNG and /dev/null differ diff --git a/src/app/main/AutomationContextManagementPage.css b/src/app/main/AutomationContextManagementPage.css new file mode 100644 index 0000000..abbee57 --- /dev/null +++ b/src/app/main/AutomationContextManagementPage.css @@ -0,0 +1 @@ +@import './ManagementPage.shared.css'; diff --git a/src/app/main/AutomationContextManagementPage.tsx b/src/app/main/AutomationContextManagementPage.tsx index e68ad77..a58849c 100644 --- a/src/app/main/AutomationContextManagementPage.tsx +++ b/src/app/main/AutomationContextManagementPage.tsx @@ -9,7 +9,7 @@ import { useAutomationContextRegistry, } from './automationContextAccess'; import { useTokenAccess } from './tokenAccess'; -import './ChatTypeManagementPage.css'; +import './AutomationContextManagementPage.css'; const { Text, Title } = Typography; diff --git a/src/app/main/AutomationTypeManagementPage.css b/src/app/main/AutomationTypeManagementPage.css new file mode 100644 index 0000000..abbee57 --- /dev/null +++ b/src/app/main/AutomationTypeManagementPage.css @@ -0,0 +1 @@ +@import './ManagementPage.shared.css'; diff --git a/src/app/main/AutomationTypeManagementPage.tsx b/src/app/main/AutomationTypeManagementPage.tsx index 36dd783..186b3ac 100644 --- a/src/app/main/AutomationTypeManagementPage.tsx +++ b/src/app/main/AutomationTypeManagementPage.tsx @@ -18,7 +18,7 @@ import { type AutomationTypeRecord, } from './automationTypeAccess'; import { useTokenAccess } from './tokenAccess'; -import './ChatTypeManagementPage.css'; +import './AutomationTypeManagementPage.css'; const { Text, Title } = Typography; diff --git a/src/app/main/ChatDefaultContextManagementPage.css b/src/app/main/ChatDefaultContextManagementPage.css new file mode 100644 index 0000000..abbee57 --- /dev/null +++ b/src/app/main/ChatDefaultContextManagementPage.css @@ -0,0 +1 @@ +@import './ManagementPage.shared.css'; diff --git a/src/app/main/ChatDefaultContextManagementPage.tsx b/src/app/main/ChatDefaultContextManagementPage.tsx index 0fe37d2..19ef11d 100644 --- a/src/app/main/ChatDefaultContextManagementPage.tsx +++ b/src/app/main/ChatDefaultContextManagementPage.tsx @@ -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(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(); 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={ - } >
- {saveErrorMessage ? : null} - {contextSettingsErrorMessage ? : null} -
- 등록 기본 유형 - {`${defaultContexts.length}건`} -
- {defaultContexts.length > 0 ? ( - ( - { - openDetail(item.id); - }} - actions={[ - + {lastLoadedAt ? ( + {`마지막 정상 동기화: ${new Date(lastLoadedAt).toLocaleString()}`} + ) : ( + 정상 동기화 이력이 없어 부분 목록을 대신 보여주지 않습니다. + )} + {lastFailedAt ? ( + {`마지막 실패: ${new Date(lastFailedAt).toLocaleString()}`} + ) : null} -
- {item.content ? : '본문 없음'} + + } + /> + ) : null} +
+ 등록 기본 유형 + + {shouldRenderServerList ? `${defaultContexts.length}건` : '서버 확인 전'} + {isLoading ? 서버 동기화 중 : null} + {shouldRenderServerList && lastLoadedAt ? ( + {`서버 기준 ${new Date(lastLoadedAt).toLocaleTimeString()}`} + ) : null} + {!shouldRenderServerList && !contextSettingsErrorMessage ? ( + 표시는 `/api/chat-context-settings` 최신 응답 기준입니다. + ) : null} + +
+ {shouldRenderServerList && defaultContexts.length > 0 ? ( + ( + { + openDetail(item.id); + }} + actions={[ +
-
- )} - /> - ) : ( - - )} + + )} + /> + ) : isLoading && !hasLoadedFromServer ? ( + + ) : ( + + )} +
) : ( @@ -248,17 +312,25 @@ export function ChatDefaultContextManagementPage() { shape="circle" icon={} aria-label={isCreating ? '등록' : '수정 저장'} + disabled={!isServerDataReadyForEditing} onClick={() => { void form.submit(); }} /> - - } + extra={!isMobileViewport ? ( + + + + + ) : null} >
{errorMessage ? : null} {contextSettingsErrorMessage ? : null} {saveErrorMessage ? : null}
- 등록 컨텍스트 - {isLoading ? '불러오는 중' : `${chatTypes.length}건`} + 사용자 채팅유형 + {isLoading ? '불러오는 중' : `${customChatTypes.length}건`}
+ {isMobileViewport ? ( + + + + + ) : null} - {chatTypes.length > 0 ? ( - { - const isCurrentUserAllowed = canUseChatType(item, userRoles); - const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id) - .map((contextId) => defaultContexts.find((context) => context.id === contextId)) - .filter((context): context is NonNullable => Boolean(context)); - const itemClassName = - item.id === selectedChatTypeId - ? 'chat-type-management-page__item chat-type-management-page__item--active' - : 'chat-type-management-page__item'; +
+ {builtInChatTypes.length > 0 ? ( + <> + + { + const isCurrentUserAllowed = canUseChatType(item, userRoles); - return ( - { - openDetail(item.id); - }} - actions={[ -
+ + ); + }} + /> + ) : ( + + )} +
) : ( @@ -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: '채팅유형명을 입력하세요.' }]} > - + span, - .app-chat-panel .app-chat-panel__composer-queue-text, - .app-chat-panel .app-chat-panel__composer-queue-more, - .app-chat-panel .app-chat-panel__preview-modal-close-label { - font-size: 15px; - } - - .app-chat-panel .app-chat-message__body, - .app-chat-panel .app-chat-message__body.ant-typography { - font-size: 20px !important; - line-height: 1.6; - } - - .app-chat-panel .app-chat-panel__composer .ant-input-textarea textarea, - .app-chat-panel .app-chat-panel__composer textarea.ant-input { - font-size: 19px; - line-height: 1.6; - } - -.app-chat-panel .app-chat-panel__composer-input-shell, -.app-chat-panel .app-chat-panel__composer textarea.ant-input { - min-height: 0; -} -} - -@media (min-width: 820px) and (max-width: 1366px) { - .app-chat-panel--ipad-readable .app-chat-message__body, - .app-chat-panel--ipad-readable .app-chat-message__body.ant-typography, - .app-chat-panel--ipad-readable .app-chat-message__block, - .app-chat-panel--ipad-readable .app-chat-message__block .ant-typography, - .app-chat-panel--ipad-readable .app-chat-message__block span, - .app-chat-panel--ipad-readable .app-chat-message__block a { - font-size: 22px !important; - line-height: 1.6; - } -} - -@media (min-width: 768px) and (pointer: fine) { - .app-chat-panel .app-chat-message__body, - .app-chat-panel .app-chat-message__body.ant-typography { - font-size: 19px !important; - } -} - -@media (min-width: 768px) and (max-width: 1366px) and (pointer: fine) { - .app-chat-panel .app-chat-message__body, - .app-chat-panel .app-chat-message__body.ant-typography { - font-size: 22px !important; - } -} - -.app-chat-panel__conversation-header { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 8px; - padding: 10px 12px 8px; - border-bottom: 1px solid rgba(148, 163, 184, 0.14); - background: rgba(248, 250, 252, 0.86); -} - -.app-chat-panel__conversation-title { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.app-chat-panel__conversation-header .ant-typography { - margin: 0; - font-size: 13px; -} - -.app-chat-panel__conversation-toggle.ant-btn { - width: 28px; - min-width: 28px; - height: 28px; - padding: 0; -} - -.app-chat-panel__conversation-toolbar { - position: absolute; - top: 6px; - right: 8px; - z-index: 4; - display: block; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - overflow: visible; - pointer-events: none; -} - -.app-chat-panel__conversation-toggle.ant-btn { - pointer-events: auto; - width: 24px; - min-width: 24px; - height: 24px; - padding: 0; - border-radius: 0; - background: transparent; - border: 0; - box-shadow: none; -} - -.app-chat-panel__conversation-toggle.ant-btn, -.app-chat-panel__conversation-toggle.ant-btn:hover, -.app-chat-panel__conversation-toggle.ant-btn:focus, -.app-chat-panel__conversation-toggle.ant-btn:active, -.app-chat-panel__conversation-toggle.ant-btn-default, -.app-chat-panel__conversation-toggle.ant-btn-default:hover, -.app-chat-panel__conversation-toggle.ant-btn-default:focus, -.app-chat-panel__conversation-toggle.ant-btn-default:active, -.app-chat-panel__conversation-toggle.ant-btn-text, -.app-chat-panel__conversation-toggle.ant-btn-text:hover, -.app-chat-panel__conversation-toggle.ant-btn-text:focus, -.app-chat-panel__conversation-toggle.ant-btn-text:active { - background: transparent !important; - border-color: transparent !important; - box-shadow: none !important; -} - -.app-chat-panel__messages { - flex: 1; - min-height: 0; - min-width: 0; - width: 100%; - max-width: 100%; - padding: 10px 12px; - overflow-y: auto; - overflow-x: hidden; - overscroll-behavior: contain; - scrollbar-width: thin; -} - -.app-chat-panel__history-loader { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin: 0 auto 4px; - padding: 0 12px; - overflow: hidden; - color: #64748b; - font-size: 12px; - line-height: 1.4; - transition: max-height 160ms ease, opacity 160ms ease, color 160ms ease; -} - -.app-chat-panel__history-loader.is-armed { - color: #0f172a; -} - -.app-chat-panel__history-loader.is-loading { - color: #1d4ed8; -} - -.app-chat-panel__system-status-slot { - min-height: 0; - padding: 0; -} - -.app-chat-panel__system-status-slot--bottom { - padding: 0 12px 8px; -} - -.app-chat-panel__system-status { - display: inline-flex; - align-items: center; - gap: 8px; - width: 100%; - min-height: 26px; - margin: 0; - padding: 6px 8px; - border-left: 2px solid rgba(59, 130, 246, 0.35); - background: rgba(248, 250, 252, 0.82); - transition: opacity 140ms ease; -} - -.app-chat-panel__system-status .ant-typography { - margin: 0; - font-size: 11px; -} - -.app-chat-panel__system-status-dots { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.app-chat-panel__system-status-dot { - width: 5px; - height: 5px; - border-radius: 999px; - background: #60a5fa; - opacity: 0.35; - animation: app-chat-message-loading 1.2s ease-in-out infinite; -} - -.app-chat-panel__system-status-dot:nth-child(2) { - animation-delay: 0.2s; -} - -.app-chat-panel__system-status-dot:nth-child(3) { - animation-delay: 0.4s; -} - -.app-chat-message { - --app-chat-message-fade-end: rgba(248, 250, 252, 0.96); - gap: 4px; - width: fit-content; - max-width: min(72%, 560px); - min-width: 0; - padding: 8px 11px; - border-radius: 0; - box-shadow: 0 6px 14px rgba(15, 23, 42, 0.05); - align-self: flex-start; - margin-left: 0; - margin-right: 56px; -} - -.app-chat-message-stack { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - align-items: flex-start; -} - -.app-chat-message-stack--user { - align-items: flex-end; -} - -.app-chat-message-stack__previews { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; -} - -.app-chat-message-stack--artifact-only { - gap: 0; -} - -.app-chat-message-stack--artifact-only .app-chat-message-stack__previews { - gap: 10px; -} - -.app-chat-message--codex { - --app-chat-message-fade-end: rgba(248, 251, 255, 0.96); - margin-left: 8px; - margin-right: 64px; - background: linear-gradient(180deg, #edf4ff, #f8fbff); -} - -.app-chat-message--system-inline { - --app-chat-message-fade-end: rgba(241, 245, 249, 0.96); - margin-left: 8px; - margin-right: 64px; - border-left: 2px solid rgba(59, 130, 246, 0.32); - background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.92)); -} - -.app-chat-message__header-meta { - display: flex; - align-items: center; - flex-wrap: nowrap; - gap: 4px; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; -} - -.app-chat-message__header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - min-width: 0; - width: 100%; -} - -.app-chat-message__header .ant-typography { - margin: 0; - font-size: 12px; - line-height: 1.2; -} - -.app-chat-message__header-meta > .ant-typography:first-child { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.app-chat-message__header-action { - width: 24px; - min-width: 24px; - height: 24px; - padding: 0; - margin-left: auto; - flex: none; -} - -.app-chat-message__header-meta .ant-typography { - font-size: 11px; - white-space: nowrap; -} - -.app-chat-message__status { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 4px; - font-size: 10px; - white-space: nowrap; -} - -.app-chat-message__status--retrying { - color: #0369a1; -} - -.app-chat-message__status--failed { - color: #b91c1c; -} - -.app-chat-message__retry.ant-btn, -.app-chat-message__cancel.ant-btn { - height: 22px; - padding-inline: 4px; - margin-left: 2px; -} - -.app-chat-message__retry.ant-btn { - color: #b91c1c; -} - -.app-chat-message__retry.ant-btn:hover, -.app-chat-message__retry.ant-btn:focus { - color: #991b1b; -} - -.app-chat-message__delete.ant-btn { - min-width: 22px; - padding-inline: 2px; -} - -.app-chat-message--user { - --app-chat-message-fade-end: rgba(238, 252, 244, 0.96); - align-self: flex-end; - margin-left: 64px; - margin-right: 8px; - background: linear-gradient(180deg, #dff7ea, #eefcf4); -} - -.app-chat-message--user .app-chat-message__body, -.app-chat-message--user .app-chat-message__expand { - text-align: right; -} - -.app-chat-message__body { - margin: 0; - width: 100%; - max-width: 100%; - font-size: 12px; - line-height: 1.45; - overflow-wrap: anywhere; - word-break: break-word; -} - -.app-chat-message__block { - white-space: pre-wrap; -} - -.app-chat-message__block + .app-chat-message__block { - margin-top: 4px; -} - -.app-chat-message__block--spacer { - min-height: calc(1.45em * 0.7); -} - -.app-chat-message__block--image { - white-space: normal; -} - -.app-chat-message__inline-image { - display: block; - width: min(100%, 560px); - margin-top: 2px; -} - -.app-chat-message__body a { - text-decoration: underline; - text-underline-offset: 2px; -} - -.app-chat-message__body--collapsed { - position: relative; - max-height: calc(1.45em * 6); - overflow: hidden; - padding-bottom: 16px; -} - -.app-chat-message__body--collapsed::after { - content: ''; - position: absolute; - right: 0; - bottom: 0; - left: 0; - height: 28px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0), var(--app-chat-message-fade-end) 78%); - pointer-events: none; -} - -.app-chat-message__body--system-status { - color: #475569; -} - -.app-chat-message__request-detail { - width: 100%; - margin-top: 2px; - color: #7f1d1d; - font-size: 11px; - line-height: 1.45; - text-align: right; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; -} - -.app-chat-preview-card { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - max-width: none; - padding: 8px 1px 12px; - border: 0; - border-radius: 16px; - background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); - box-shadow: - inset 0 0 0 1px rgba(148, 163, 184, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.65); - box-sizing: border-box; - overflow: clip; - height: auto; -} - -.app-chat-preview-card--collapsed { - gap: 0; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.app-chat-message-stack--codex .app-chat-preview-card, -.app-chat-message-stack--system .app-chat-preview-card { - margin-left: 0; - margin-right: 0; -} - -.app-chat-message-stack--user .app-chat-preview-card { - margin-left: 0; - margin-right: 0; -} - -.app-chat-message-stack--artifact-only .app-chat-preview-card { - margin-left: 0; - margin-right: 0; -} - -.app-chat-preview-card__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 10px; -} - -.app-chat-preview-card__actions { - display: inline-flex; - align-items: center; - gap: 2px; - flex: 0 0 auto; -} - -.app-chat-preview-card--collapsed .app-chat-preview-card__header { - border: 0; - border-radius: 16px; - background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); - box-shadow: - inset 0 0 0 1px rgba(148, 163, 184, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.65); -} - -.app-chat-preview-card__meta { - display: flex; - align-items: flex-start; - gap: 8px; - min-width: 0; - flex: 1 1 auto; -} - -.app-chat-preview-card__glyph { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - min-width: 20px; - height: 20px; - margin-top: 1px; - color: #475569; - background: rgba(226, 232, 240, 0.9); - border-radius: 999px; - font-size: 12px; -} - -.app-chat-preview-card__titles { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - flex: 1 1 auto; - overflow: hidden; -} - -.app-chat-preview-card__label, -.app-chat-preview-card__kind, -.app-chat-preview-card__label.ant-typography, -.app-chat-preview-card__kind.ant-typography { - margin: 0; - max-width: 100%; -} - -.app-chat-preview-card__label, -.app-chat-preview-card__label.ant-typography { - font-size: 12px; - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - color: #0f172a; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.app-chat-preview-card__kind, -.app-chat-preview-card__kind.ant-typography { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #64748b; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.app-chat-preview-card__toggle.ant-btn { - height: 20px; - min-width: 20px; - padding: 0; - flex: 0 0 auto; -} - -.app-chat-preview-card__action.ant-btn { - height: 22px; - min-width: 22px; - padding: 0; - color: #475569; -} - -.app-chat-preview-card--ranked-link { - gap: 0; -} - -.app-chat-preview-card__glyph--ranked-link { - color: #1d4ed8; - background: rgba(191, 219, 254, 0.72); -} - -.app-chat-preview-card__body--ranked-link { - padding: 10px; -} - -.app-chat-preview-card__ranked-link-anchor { - display: block; - color: #1d4ed8; - font-size: 12px; - line-height: 1.5; - word-break: break-all; - text-decoration: none; -} - -.app-chat-preview-card__ranked-link-anchor:hover { - text-decoration: underline; -} - -.app-chat-preview-card__open-link.ant-btn { - height: 26px; - padding-inline: 8px; -} - -@media (max-width: 1180px) { - .app-chat-panel { - height: 100%; - max-height: none; - border-radius: 0; - } - - .app-chat-panel--tablet-app { - align-self: stretch; - width: 100%; - max-width: 100%; - margin-inline: 0; - border-radius: 0; - overflow: hidden; - } - - .app-chat-panel.ant-card .ant-card-head { - padding: 0 14px; - } - - .app-chat-panel.ant-card .ant-card-body { - padding: 10px; - } - - .app-chat-panel__conversation-shell, - .app-chat-panel__stack--chat, - .app-chat-panel__conversation-main, - .app-chat-panel__conversation-view, - .app-chat-panel__conversation-view-inner, - .app-chat-panel__messages, - .app-chat-panel__composer, - .app-chat-panel__composer-input-shell { - min-width: 0; - width: 100%; - max-width: 100%; - } - - .app-chat-message { - width: auto; - max-width: calc(100% - 40px); - margin-right: 20px; - } - - .app-chat-message--codex, - .app-chat-message--system-inline { - margin-left: 4px; - margin-right: 20px; - } - - .app-chat-message--user { - margin-left: 20px; - margin-right: 4px; - } - - .app-chat-message-stack--codex .app-chat-preview-card, - .app-chat-message-stack--system .app-chat-preview-card, - .app-chat-message-stack--user .app-chat-preview-card { - margin-left: 0; - margin-right: 0; - max-width: 100%; - } - - .app-chat-message-stack--artifact-only .app-chat-preview-card { - margin-left: 0; - margin-right: 0; - max-width: 100%; - } - - .app-chat-panel__composer-queue { - width: min(220px, calc(100% - 88px)); - } -} - -@media (min-width: 1181px) and (max-width: 1366px) { - .app-chat-panel__conversation-list { - flex: 0 0 clamp(208px, 19vw, 240px); - width: clamp(208px, 19vw, 240px); - min-width: clamp(208px, 19vw, 240px); - max-width: clamp(208px, 19vw, 240px); - } - - .app-chat-panel__conversation-main, - .app-chat-panel__conversation-view, - .app-chat-panel__conversation-view-inner { - width: auto; - } -} - -.app-chat-preview-card__body { - display: flex; - min-height: 0; - border-top: 1px solid rgba(148, 163, 184, 0.18); - padding: 8px 0 1px; - width: 100%; - box-sizing: border-box; -} - -.app-chat-preview-card--fullscreen { - position: fixed; - inset: 0; - z-index: 1400; - width: 100vw; - min-width: 100vw; - max-width: 100vw; - height: 100vh; - max-height: 100vh; - margin: 0 !important; - gap: 0; - padding: 0; - border: 0; - border-radius: 0; - background: #f8fafc; - box-shadow: 0 18px 48px rgba(15, 23, 42, 0.26); -} - -.app-chat-preview-card--fullscreen .app-chat-preview-card__header { - position: sticky; - top: 0; - z-index: 1; - padding: 12px 16px; - border-bottom: 1px solid rgba(148, 163, 184, 0.22); - background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96)); -} - -.app-chat-preview-card--fullscreen .app-chat-preview-card__body { - flex: 1 1 auto; - min-height: 0; - width: 100vw; - max-width: 100vw; - padding-top: 0; - border-top: 0; - overflow: hidden; -} - -.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich { - width: 100vw; - max-width: 100vw; -} - -.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich, -.app-chat-preview-card--fullscreen .codex-diff-previewer, -.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list, -.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section, -.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-body, -.app-chat-preview-card--fullscreen .previewer-ui, -.app-chat-preview-card--fullscreen .previewer-ui__editor, -.app-chat-preview-card--fullscreen .previewer-ui__editor-body { - height: 100%; - width: 100%; - max-width: none; -} - -.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list { - gap: 0; -} - -.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section { - border-width: 0 0 1px; - border-radius: 0; -} - -.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-toggle { - padding-inline: 16px 88px; -} - -.app-chat-preview-card--fullscreen .previewer-ui__editor { - border-width: 0; - border-radius: 0; -} - -.app-chat-preview-card--fullscreen .previewer-ui__editor-body { - max-height: none; - padding-inline: 0; -} - -.app-chat-panel__preview-rich { - width: 100%; - min-width: 0; - padding-bottom: 1px; - box-sizing: border-box; -} - -.app-chat-panel__preview-rich .previewer-ui__editor, -.app-chat-panel__preview-rich .codex-diff-previewer__diff-list, -.app-chat-panel__preview-rich .codex-diff-previewer__diff-section { - width: 100%; -} - -.app-chat-panel__preview-rich .codex-diff-previewer, -.app-chat-panel__preview-rich .codex-diff-previewer__diff-body, -.app-chat-panel__preview-rich .previewer-ui, -.app-chat-panel__preview-rich .previewer-ui__body { - width: 100%; -} - -.app-chat-panel__preview-rich .previewer-ui__editor { - border-color: rgba(15, 23, 42, 0.58); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); -} - -.app-chat-panel__preview-rich .previewer-ui__editor-tab { - font-weight: 700; - letter-spacing: 0.1em; -} - -.app-chat-panel__preview-rich .previewer-ui__editor-body { - max-height: 420px; - overflow: auto; -} - -.app-chat-panel__preview-rich .codex-diff-previewer__diff-toolbar { - display: none; -} - -.app-chat-panel__preview-rich .codex-diff-previewer__diff-section { - border-radius: 0; -} - -.app-chat-panel__preview-rich .codex-diff-previewer__file-name { - color: #e2e8f0; -} - -.app-chat-panel__preview-rich .codex-diff-previewer__diff-list--expand-all .codex-diff-previewer__diff-section { - border-radius: 0; -} - -.app-chat-panel__preview-rich--markdown { - display: flex; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: auto; - max-height: min(420px, 70vh); - padding: 4px 2px 0; -} - -.app-chat-panel__preview-rich--markdown .markdown-preview { - width: 100%; - min-width: 0; -} - -.app-chat-panel__preview-table { - display: flex; - flex: 1 1 auto; - flex-direction: column; - gap: 10px; - min-width: 0; - min-height: 0; -} - -.app-chat-panel__preview-table-meta { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: center; -} - -.app-chat-panel__preview-table-scroll { - overflow: auto; - max-height: min(420px, 70vh); - border: 1px solid rgba(148, 163, 184, 0.24); - border-radius: 18px; - background: rgba(255, 255, 255, 0.92); -} - -.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; -} - -.app-chat-panel__preview-table-grid th, -.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; -} - -.app-chat-panel__preview-table-grid th { - position: sticky; - top: 0; - z-index: 1; - background: #eff6ff; - color: #1e3a8a; - font-weight: 700; -} - -.app-chat-panel__preview-table-grid tbody tr:nth-child(even) td { - background: rgba(248, 250, 252, 0.82); -} - -.app-chat-panel__preview-table-grid tbody tr:last-child td { - border-bottom: 0; -} - -.app-chat-message__preview-image, -.app-chat-message__preview-video, -.app-chat-message__preview-frame { - width: 100%; - max-height: 200px; - min-height: 120px; - border: 1px solid rgba(148, 163, 184, 0.18); - object-fit: contain; - background: #f8fafc; -} - -.app-chat-message__preview-text { - width: 100%; - max-height: 180px; - margin: 0; - padding: 8px; - overflow: auto; - background: #f8fafc; - border: 1px solid rgba(148, 163, 184, 0.18); - font: 11px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; -} - -.app-chat-message__expand.ant-btn { - align-self: flex-start; - display: inline-flex; - align-items: center; - gap: 4px; - width: auto; - min-width: 0; - height: 26px; - padding-inline: 8px; - font-size: 12px; - color: #475569; - border-radius: 999px; -} - -.app-chat-message__expand.ant-btn:hover, -.app-chat-message__expand.ant-btn:focus { - color: #0f172a; - background: rgba(148, 163, 184, 0.12); -} - -.app-chat-panel__scroll-jump { - position: absolute; - left: 50%; - bottom: clamp(132px, 18vh, 176px); - transform: translateX(-50%); - z-index: 3; -} - -.app-chat-panel__scroll-jump .ant-btn { - width: 28px; - min-width: 28px; - height: 28px; - padding: 0; - border-radius: 999px; - box-shadow: 0 6px 12px rgba(15, 23, 42, 0.14); -} - -.app-chat-panel__composer { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 4px; - padding-top: 4px; - padding-right: 10px; - padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px)); - padding-left: 10px; - border-top: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 0; - background: rgba(248, 250, 252, 0.94); - box-shadow: none; -} - -.app-chat-panel__composer-input-shell { - position: relative; - display: flex; - align-items: stretch; - flex: none; - width: 100%; - min-width: 0; - min-height: clamp(112px, 18dvh, 160px); -} - -.app-chat-panel__composer-queue { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; - width: min(320px, calc(100% - 112px)); - pointer-events: auto; -} - -.app-chat-panel__composer-queue-count { - padding: 2px 8px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 999px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); -} - -.app-chat-panel__composer-queue-list { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 4px; - width: 100%; - max-height: 188px; - overflow-y: auto; -} - -.app-chat-panel__composer-queue-chip, -.app-chat-panel__composer-queue-more { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 6px 10px; - border: 1px solid rgba(96, 165, 250, 0.2); - border-radius: 12px; - background: rgba(239, 246, 255, 0.96); - box-shadow: 0 8px 20px rgba(37, 99, 235, 0.1); - color: #1e3a8a; -} - -.app-chat-panel__composer-queue-chip-main { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex: 1 1 auto; -} - -.app-chat-panel__composer-queue-chip-actions { - display: inline-flex; - align-items: center; - gap: 2px; - flex: none; -} - -.app-chat-panel__composer-queue-chip-actions .ant-btn { - color: #1d4ed8; -} - -.app-chat-panel__composer-queue-order { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - border-radius: 999px; - background: rgba(37, 99, 235, 0.12); - font-size: 11px; - font-weight: 700; -} - -.app-chat-panel__composer-queue-text, -.app-chat-panel__composer-queue-more { - font-size: 12px; - line-height: 1.3; -} - -.app-chat-panel__composer-queue-text { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.app-chat-panel__composer .ant-input-textarea, -.app-chat-panel__composer textarea.ant-input { - width: 100%; - min-width: 0; - display: block; - align-self: stretch; - flex: none; - min-height: 0; -} - -.app-chat-panel__composer textarea.ant-input { - width: 100%; - font-size: 13px; - line-height: 1.4; - height: clamp(112px, 18dvh, 160px); - min-height: clamp(112px, 18dvh, 160px); - padding: 10px 52px 8px 14px; - box-sizing: border-box; - resize: none; -} - -.app-chat-panel__composer-input-shell--with-queue textarea.ant-input { - padding-top: 96px; -} - -.app-chat-panel__composer-topline, -.app-chat-panel__composer-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; -} - -.app-chat-panel__composer-topline { - width: 100%; - justify-content: stretch; - flex-wrap: nowrap; -} - -.app-chat-panel__composer-utility-buttons { - flex: none; -} - -.app-chat-panel__composer-type { - flex: 1; - min-width: 0; -} - -.app-chat-panel__composer-action-buttons { - display: inline-flex; - gap: 6px; -} - -.app-chat-panel__composer-contextless-toggle.ant-btn { - color: #475569; -} - -.app-chat-panel__composer-contextless-toggle--active.ant-btn { - border-color: #0f766e; - background: linear-gradient(135deg, #0f766e, #0f766e); - color: #f8fafc; - box-shadow: 0 8px 18px rgba(15, 118, 110, 0.24); -} - -.app-chat-panel__composer-contextless-toggle--active.ant-btn:hover, -.app-chat-panel__composer-contextless-toggle--active.ant-btn:focus-visible { - border-color: #0f766e; - background: linear-gradient(135deg, #0f766e, #115e59); - color: #f8fafc; -} - -.app-chat-panel__composer-utility-buttons { - display: inline-flex; - gap: 6px; -} - -.app-chat-panel__composer-file-input { - display: none; -} - -.app-chat-panel__composer-clear.ant-btn { - position: absolute; - right: 10px; - top: 10px; - z-index: 2; - height: 28px; - padding: 0 10px; - border-radius: 999px; - color: rgba(71, 85, 105, 0.88); - background: rgba(255, 255, 255, 0.92); - border: 1px solid rgba(148, 163, 184, 0.24); - box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08); - opacity: 0; - pointer-events: none; - transition: - opacity 0.16s ease, - transform 0.16s ease; - transform: translateY(-4px); -} - -.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.app-chat-panel__composer-clear.ant-btn:disabled { - opacity: 0; - pointer-events: none; -} - -.app-chat-panel__composer-attachment-strip { - display: flex; - flex-wrap: wrap; - gap: 6px; - width: 100%; - min-height: 0; -} - -.app-chat-panel__composer-attachment-chip { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 100%; - padding: 4px 4px 4px 8px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 999px; - background: rgba(255, 255, 255, 0.96); - color: #334155; -} - -.app-chat-panel__composer-attachment-chip--pending { - border-style: dashed; - background: rgba(239, 246, 255, 0.96); - color: #1d4ed8; -} - -.app-chat-panel__composer-attachment-chip--failed { - border-style: solid; - border-color: rgba(239, 68, 68, 0.28); - background: rgba(254, 242, 242, 0.98); - color: #b91c1c; -} - -.app-chat-panel__composer-attachment-name { - max-width: min(240px, 52vw); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; - line-height: 1.3; -} - -.app-chat-panel__composer-attachment-pending-label { - flex: none; - font-size: 10px; - line-height: 1.2; - color: inherit; - opacity: 0.78; -} - -.app-chat-panel__composer-attachment-remove.ant-btn { - width: 22px; - min-width: 22px; - height: 22px; - padding: 0; - border-radius: 999px; - color: #64748b; -} - -.app-chat-panel__composer .ant-btn { - height: 28px; - padding-inline: 8px; - border-radius: 8px; - font-size: 12px; -} - -.app-chat-panel__composer-action-buttons .ant-btn, -.app-chat-panel__composer-type .ant-select-selector { - min-height: 28px; -} - -.app-chat-panel__composer-action-buttons .ant-btn-icon-only { - width: 28px; - min-width: 28px; - padding-inline: 0; -} - -.app-chat-panel__composer-type .ant-select-selector { - padding-block: 2px; -} - -.app-chat-panel__composer-actions .ant-typography { - font-size: 12px; -} - -.app-chat-panel__composer-hint { - display: block; -} - -.app-chat-panel__type-option { - display: flex; - flex-direction: column; - gap: 2px; -} - -.app-chat-panel__resource-strip { - position: absolute; - top: 38px; - right: 8px; - left: auto; - z-index: 4; - display: flex; - flex-direction: column; - gap: 8px; - width: min(420px, calc(100% - 16px)); - max-height: min(58vh, 520px); - padding: 10px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 16px; - background: rgba(246, 248, 252, 0.96); - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); - overflow: hidden; -} - -.app-chat-panel__resource-strip-list { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - padding-top: 2px; - max-height: min(32vh, 240px); - overflow-x: hidden; - overflow-y: auto; -} - -.app-chat-panel__resource-strip-filter { - display: flex; - align-items: center; - min-width: 0; - color: #334155; - font-size: 11px; - line-height: 1.4; -} - -.app-chat-panel__resource-strip-filter .ant-checkbox-wrapper { - width: 100%; - margin-inline-start: 0; - font-size: inherit; - color: inherit; -} - -.app-chat-panel__resource-strip-empty.ant-typography { - margin: 0; - font-size: 11px; - line-height: 1.5; -} - -.app-chat-panel__preview-stage { - display: flex; - min-height: 0; - padding: 0 18px; -} - -.app-chat-panel__preview-stage--modal { - padding: 0; -} - -.app-chat-panel__preview-stage > * { - width: 100%; - height: 100%; - min-height: 0; -} - -.app-chat-panel__preview-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - min-height: 220px; -} - -.app-chat-panel__conversation-view { - position: relative; - display: flex; - flex: 1; - min-height: 0; -} - -.app-chat-panel__conversation-view-inner { - display: flex; - flex: 1; - min-height: 0; - min-width: 0; - flex-direction: column; - overflow: hidden; -} - -.app-chat-panel__conversation-view-inner.is-loading { - pointer-events: none; -} - -.app-chat-panel__conversation-view-inner.is-busy { - user-select: auto; -} - -.app-chat-panel__conversation-loading { - position: absolute; - inset: 0; - z-index: 2; - display: flex; - flex: 1; - min-height: 360px; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 12px; - padding: 32px 24px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: 24px; - background: - radial-gradient(circle at top, rgba(191, 219, 254, 0.45), transparent 52%), - linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.86)); - backdrop-filter: blur(6px); - text-align: center; -} - -.app-chat-panel__busy-overlay { - position: absolute; - inset: 0; - z-index: 1; - pointer-events: none; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 28px 24px; - border-radius: 24px; - background: - radial-gradient(circle at top, rgba(147, 197, 253, 0.26), transparent 48%), - linear-gradient(180deg, rgba(248, 250, 252, 0.64), rgba(241, 245, 249, 0.74)); - backdrop-filter: blur(4px); - text-align: center; -} - -.app-chat-panel__busy-overlay strong { - color: #0f172a; -} - -.app-chat-panel__busy-overlay span { - color: #475569; - font-size: 12px; -} - -.app-chat-panel__preview-image, -.app-chat-panel__preview-video, -.app-chat-panel__preview-frame { - width: 100%; - min-height: 320px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 16px; - object-fit: contain; -} - -.app-chat-panel__preview-image { - display: block; - height: auto; - max-height: min(72vh, 640px); - margin: 0 auto; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)), - linear-gradient(135deg, rgba(226, 232, 240, 0.16), rgba(255, 255, 255, 0)); - object-position: top center; -} - -.app-chat-panel__preview-video { - height: 100%; - background: #0f172a; -} - -.app-chat-panel__preview-frame { - height: 100%; - background: #fff; -} - -.app-chat-panel__preview-text { - min-height: 320px; - margin: 0; - padding: 16px; - overflow: auto; - border-radius: 16px; - background: #0f172a; - color: #dbeafe; - font: 13px/1.6 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; -} - -.app-chat-panel__preview-file { - display: flex; - flex-direction: column; - justify-content: center; - gap: 12px; - min-height: 220px; - padding: 18px; - border-radius: 16px; - background: rgba(248, 250, 252, 0.88); - border: 1px dashed rgba(148, 163, 184, 0.35); -} - -.app-chat-panel__preview-modal .ant-modal-body { - padding: 12px 0 0; - display: flex; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; -} - -.app-chat-panel__preview-modal { - z-index: 1600; -} - -.app-chat-panel__preview-modal .ant-modal-close { - position: fixed; - top: 18px; - right: 18px; - inset-inline-end: 18px; - width: auto; - height: auto; - padding: 0; - border-radius: 999px; - background: rgba(15, 23, 42, 0.18); - box-shadow: none; - backdrop-filter: blur(3px); - opacity: 0.46; - transition: - opacity 160ms ease, - background-color 160ms ease; -} - -.app-chat-panel__preview-modal .ant-modal-close:hover { - background: rgba(15, 23, 42, 0.28); - opacity: 0.7; -} - -.app-chat-panel__preview-modal-close-label { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 56px; - padding: 10px 16px; - color: #fff; - font-size: 13px; - font-weight: 700; - line-height: 1; -} - -.app-chat-panel__preview-modal .ant-modal-content { - display: flex; - flex-direction: column; - min-height: 0; - height: 100dvh; - max-height: 100dvh; - padding: 0; - border-radius: 0; -} - -.app-chat-panel__preview-modal .ant-modal-header { - margin-bottom: 0; - padding: 16px 20px 12px; - border-radius: 0; -} - -.app-chat-panel__preview-modal .ant-modal-title { - padding-right: 40px; -} - -.app-chat-panel__preview-modal .ant-modal-footer { - margin-top: 0; - padding: 0 20px 16px; - border-top: 0; -} - -.app-chat-panel__preview-stage--modal { - display: flex; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; -} - -.app-chat-panel__delete-confirm-modal { - z-index: 1700 !important; -} - -.app-chat-panel__delete-confirm-modal .ant-modal-content { - border-radius: 18px; - box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); -} - -.app-chat-panel__preview-modal-body { - display: flex; - flex: 1 1 auto; - flex-direction: column; - gap: 0; - min-height: 0; - overflow: hidden; -} - -.app-chat-panel__preview-modal-meta { - display: flex; - justify-content: flex-start; - padding: 0 20px 12px; -} - -.app-chat-panel__preview-modal-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: 100%; - min-width: 0; -} - -.app-chat-panel__preview-modal-title-text { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.app-chat-panel__preview-modal-findbar { - display: flex; - align-items: center; - gap: 8px; - padding: 0 20px 12px; -} - -.app-chat-panel__preview-modal-findbar .ant-input-affix-wrapper { - flex: 1 1 auto; - min-width: 0; -} - -.app-chat-panel__preview-modal .app-chat-panel__preview-rich, -.app-chat-panel__preview-modal .previewer-ui, -.app-chat-panel__preview-modal .previewer-ui__editor, -.app-chat-panel__preview-modal .previewer-ui__editor-body, -.app-chat-panel__preview-modal .codex-diff-previewer, -.app-chat-panel__preview-modal .codex-diff-previewer__diff-list, -.app-chat-panel__preview-modal .codex-diff-previewer__diff-section, -.app-chat-panel__preview-modal .codex-diff-previewer__diff-body { - height: 100%; - width: 100%; - max-width: none; -} - -.app-chat-panel__preview-modal .app-chat-panel__preview-rich--markdown, -.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown { - height: 100%; - max-height: none; -} - -.app-chat-panel__preview-modal .previewer-ui__editor, -.app-chat-panel__preview-modal .codex-diff-previewer__diff-section, -.app-chat-panel__preview-modal .app-chat-panel__preview-image, -.app-chat-panel__preview-modal .app-chat-panel__preview-video, -.app-chat-panel__preview-modal .app-chat-panel__preview-frame { - border-left-width: 0; - border-right-width: 0; - border-radius: 0; -} - -.app-chat-panel__preview-modal .app-chat-panel__preview-image { - height: 100%; - max-height: none; - background: #fff; - object-position: center; -} - -.app-chat-panel__preview-modal .previewer-ui__editor-body { - max-height: none; - padding-inline: 0; -} - -.app-chat-panel__preview-modal--html-mobile .ant-modal-content { - background: #fff; -} - -.app-chat-panel__preview-modal--html-mobile .ant-modal-header, -.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-meta, -.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-findbar { - display: none; -} - -.app-chat-panel__preview-modal--html-mobile .ant-modal-body, -.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-body, -.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-stage--modal { - padding: 0; -} - -.app-chat-panel__preview-stage--html-mobile { - align-items: stretch; - justify-content: stretch; - padding: 0; - overflow: hidden; -} - -.app-chat-panel__preview-stage--html-mobile > * { - display: flex; - justify-content: stretch; - width: 100%; - min-height: 100%; - padding: 0; -} - -.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-frame { - width: 100%; - height: 100dvh; - min-height: 100dvh; - border: 0; - border-radius: 0; - background: #fff; - box-shadow: none; -} - -@media (max-width: 720px) { - .app-chat-panel__preview-modal .ant-modal-close { - top: 12px; - right: 12px; - inset-inline-end: 12px; - } - - .app-chat-panel__preview-stage--html-mobile > * { - padding: 0; - } - - .app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-frame { - width: 100%; - height: 100dvh; - min-height: 100dvh; - border-radius: 0; - box-shadow: none; - } -} - -@media (max-width: 720px) { - .app-chat-panel__preview-modal-title { - align-items: flex-start; - flex-direction: column; - } - - .app-chat-panel__preview-modal-findbar { - flex-wrap: wrap; - } - - .app-chat-panel__preview-modal-findbar .ant-btn { - flex: 1 1 calc(50% - 4px); - } -} - -.app-chat-panel__connection-dot--connecting { - background: #f59e0b; -} - -@keyframes app-chat-message-loading { - 0%, - 80%, - 100% { - transform: translateY(0); - opacity: 0.35; - } - - 40% { - transform: translateY(-2px); - opacity: 1; - } -} - -@media (max-width: 720px) { - .app-chat-panel { - height: 100%; - min-height: 100%; - border-radius: 0; - overflow: hidden; - } - - .app-chat-panel .ant-card-head { - flex: 0 0 auto; - padding-inline: 8px; - } - - .app-chat-panel .ant-card-head-wrapper { - min-width: 0; - gap: 8px; - } - - .app-chat-panel .ant-card-head-title, - .app-chat-panel .ant-card-extra { - padding-block: 8px; - } - - .app-chat-panel .ant-card-head-title { - flex: 1 1 auto; - min-width: 0; - } - - .app-chat-panel .ant-card-extra { - flex: 0 0 auto; - min-width: fit-content; - } - - .app-chat-panel .ant-card-body { - padding: 0; - } - - .app-chat-panel__stack, - .app-chat-panel__conversation-shell, - .app-chat-panel__conversation-list, - .app-chat-panel__conversation-main { - height: 100%; - min-width: 0; - overflow: hidden; - } - - .app-chat-panel__conversation-shell { - border-radius: 0; - border-left: 0; - border-right: 0; - box-shadow: none; - overscroll-behavior-y: none; - } - - .app-chat-panel__conversation-list-header { - padding-inline: 10px; - } - - .app-chat-panel__conversation-list-search { - padding-inline: 8px; - } - - .app-chat-panel__conversation-list-body { - padding-inline: 8px; - padding-bottom: 8px; - overscroll-behavior-y: contain; - -webkit-overflow-scrolling: touch; - } - - .app-chat-panel input, - .app-chat-panel textarea, - .app-chat-panel .ant-input, - .app-chat-panel .ant-input-affix-wrapper input, - .app-chat-panel .ant-select-selection-item, - .app-chat-panel .ant-select-selection-placeholder, - .app-chat-panel .ant-select-selector, - .app-chat-panel .ant-input-textarea textarea.ant-input { - font-size: 16px !important; - } - - .app-chat-panel__messages, - .app-chat-panel__composer, - .app-chat-panel__resource-strip { - overscroll-behavior-y: none; - } - - .app-chat-panel__conversation-header, - .app-chat-panel__composer-actions { - flex-direction: column; - align-items: stretch; - } - - .app-chat-panel__composer-topline { - flex-direction: row; - align-items: center; - } - - .app-chat-panel__conversation-badges { - align-items: flex-start; - } - - .app-chat-message { - max-width: 100%; - } - - .app-chat-panel__messages, - .app-chat-panel__preview-stage, - .app-chat-panel__resource-strip { - width: 100%; - min-width: 0; - padding-left: 12px; - padding-right: 12px; - box-sizing: border-box; - } - - .app-chat-panel__composer { - width: 100%; - min-width: 0; - padding-left: 10px; - padding-right: 10px; - padding-top: 4px; - padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px)); - box-sizing: border-box; - } - - .app-chat-panel__composer textarea.ant-input { - height: clamp(104px, 16dvh, 136px); - min-height: clamp(104px, 16dvh, 136px); - padding-top: 8px; - padding-bottom: 8px; - line-height: 1.5; - } - - .app-chat-panel__composer-input-shell { - min-height: clamp(104px, 16dvh, 136px); - } - - .app-chat-panel__composer-input-shell--with-queue textarea.ant-input { - padding-top: 88px; - } - - .app-chat-panel__resource-strip-list { - max-height: min(30vh, 220px); - overflow-x: hidden; - overflow-y: auto; - padding-bottom: 2px; - } - - .app-chat-panel__preview-image, - .app-chat-panel__preview-video, - .app-chat-panel__preview-frame, - .app-chat-panel__preview-text { - min-height: 220px; - } -} - -.app-chat-runtime { - display: flex; - flex: 1; - flex-direction: column; - gap: 14px; - min-height: 0; - min-width: 0; - overflow: hidden; -} - -.app-chat-runtime__summary-strip { - display: block; -} - -.app-chat-runtime__summary-card { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - padding: 14px 16px; - border-radius: 18px; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(255, 255, 255, 0.86); - box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05); -} - -.app-chat-runtime__summary-metric { - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.app-chat-runtime__summary-metric .ant-typography { - margin: 0; -} - -.app-chat-runtime__summary-status.ant-typography { - margin-inline-start: auto; -} - -.app-chat-runtime__session-strip { - display: flex; - gap: 8px; - overflow-x: auto; - padding-bottom: 2px; - min-width: 0; - -webkit-overflow-scrolling: touch; - scrollbar-width: thin; -} - -.app-chat-runtime__session-chip { - display: inline-flex; - flex-direction: column; - gap: 4px; - min-width: 180px; - padding: 10px 12px; - border-radius: 16px; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(248, 250, 252, 0.88); - text-align: left; - cursor: pointer; - touch-action: manipulation; -} - -.app-chat-runtime__session-chip--active { - border-color: rgba(37, 99, 235, 0.35); - background: rgba(239, 246, 255, 0.96); -} - -.app-chat-runtime__content { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - flex: 1; - min-height: 0; - min-width: 0; -} - -.app-chat-runtime__section { - display: flex; - flex-direction: column; - min-height: 0; - min-width: 0; - border-radius: 22px; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(255, 255, 255, 0.82); - overflow: hidden; -} - -.app-chat-runtime__section-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 14px 16px; - border-bottom: 1px solid rgba(148, 163, 184, 0.14); -} - -.app-chat-runtime__list { - display: flex; - flex: 1; - flex-direction: column; - gap: 10px; - min-height: 0; - min-width: 0; - padding: 12px; - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} - -.app-chat-runtime__empty { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - min-height: 220px; -} - -.app-chat-runtime__job { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; - padding: 14px; - border-radius: 18px; - border: 1px solid rgba(148, 163, 184, 0.16); - background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.96)); -} - -.app-chat-runtime__job--active { - border-color: rgba(37, 99, 235, 0.32); - box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.1); -} - -.app-chat-runtime__job-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.app-chat-runtime__job-actions { - justify-content: flex-end; -} - -.app-chat-runtime__job-actions .ant-btn { - touch-action: manipulation; -} - -.app-chat-runtime__job-headline { - display: flex; - flex-direction: column; - gap: 4px; -} - -.app-chat-runtime__job-summary.ant-typography { - margin: 0; - color: #0f172a; - white-space: pre-wrap; - word-break: break-word; -} - -.app-chat-runtime__job-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px 10px; -} - -.app-chat-runtime__log-modal { - display: flex; - flex: 1; - flex-direction: column; - gap: 12px; - min-height: 0; - height: calc(100dvh - 56px); - padding: 20px 24px 24px; - background: - radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 28%), - linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98)); -} - -.app-chat-runtime__drawer .ant-drawer-content { - background: transparent; -} - -.app-chat-runtime__drawer .ant-drawer-header { - padding: 18px 24px; - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(18px); -} - -.app-chat-runtime__drawer .ant-drawer-body { - display: flex; - min-height: 0; -} - -.app-chat-runtime__log-state { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - min-height: 0; - padding: 24px; -} - -.app-chat-runtime__log-viewer { - margin: 0; - flex: 1; - min-height: 0; - overflow: auto; - padding: 18px; - border-radius: 20px; - background: #0f172a; - color: #e2e8f0; - font-size: 12px; - line-height: 1.55; - font-family: - 'JetBrains Mono', 'SFMono-Regular', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; -} - -@media (max-width: 960px) { - .app-chat-runtime__content, - .app-chat-runtime__job-meta { - grid-template-columns: minmax(0, 1fr); - } -} - -@media (max-width: 768px) { - .app-chat-panel__conversation-list { - flex: 1 1 100%; - width: 100%; - min-width: 100%; - max-width: 100%; - border-right: 0; - } - - .app-chat-runtime { - overflow: hidden; - } - - .app-chat-runtime__summary-card { - padding: 14px; - border-radius: 16px; - align-items: flex-start; - gap: 10px 14px; - } - - .app-chat-runtime__summary-metric { - flex: 1 1 calc(50% - 8px); - } - - .app-chat-runtime__summary-status.ant-typography { - width: 100%; - margin-inline-start: 0; - } - - .app-chat-runtime__session-strip { - margin-inline: -2px; - padding-inline: 2px; - padding-bottom: 6px; - } - - .app-chat-runtime__session-chip { - min-width: min(240px, 82vw); - padding: 12px 14px; - border-radius: 16px; - } - - .app-chat-runtime__content { - display: flex; - flex-direction: column; - gap: 12px; - overflow-y: auto; - padding-right: 2px; - -webkit-overflow-scrolling: touch; - } - - .app-chat-runtime__section { - flex: 0 0 auto; - min-height: 320px; - border-radius: 18px; - } - - .app-chat-runtime__section--recent { - min-height: 260px; - } - - .app-chat-runtime__section-header { - padding: 14px; - } - - .app-chat-runtime__list { - padding: 10px; - overflow-y: visible; - } - - .app-chat-runtime__job { - padding: 14px; - border-radius: 16px; - } - - .app-chat-runtime__job-top { - flex-direction: column; - align-items: stretch; - } - - .app-chat-runtime__job-top > .ant-btn { - width: 100%; - min-height: 40px; - border-radius: 12px; - } - - .app-chat-runtime__job-actions { - width: 100%; - justify-content: stretch; - } - - .app-chat-runtime__job-actions .ant-space-item { - flex: 1 1 calc(50% - 4px); - min-width: 0; - } - - .app-chat-runtime__job-actions .ant-btn { - width: 100%; - min-height: 40px; - padding-inline: 12px; - border-radius: 12px; - } - - .app-chat-runtime__job-meta { - gap: 8px; - } - - .app-chat-runtime__log-modal { - height: calc(100dvh - 52px); - padding: 16px; - } - - .app-chat-runtime__drawer .ant-drawer-header { - padding: 14px 16px; - } - - .app-chat-runtime__log-viewer { - padding: 12px; - font-size: 11px; - } -} - -.chat-v2 { - display: flex; - flex-direction: column; - min-height: 0; - height: 100%; -} - -.chat-v2__toolbar { - display: flex; - justify-content: center; - padding: 8px 0 12px; -} - -.chat-v2__chat-layout { - display: flex; - min-height: 0; - height: 100%; -} - -.chat-v2__pane { - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 16px; - background: rgba(255, 255, 255, 0.92); - overflow: hidden; -} - -.chat-v2__pane--list { - flex: 0 0 320px; - max-width: 320px; -} - -.chat-v2__pane--room, -.chat-v2__pane--runtime, -.chat-v2__pane--errors { - flex: 1 1 auto; -} - -.chat-v2__pane-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border-bottom: 1px solid rgba(226, 232, 240, 0.9); - background: rgba(248, 250, 252, 0.96); -} - -.chat-v2__pane > .ant-input-search, -.chat-v2__pane > .ant-input-affix-wrapper, -.chat-v2__pane > .ant-input-group-wrapper { - margin: 12px 16px 0; -} - -.chat-v2__state { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - padding: 24px; - text-align: center; -} - -.chat-v2__conversation-list { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - padding: 12px 10px 10px; -} - -.chat-v2__conversation-list .ant-list-item { - padding: 0; - border-block-end: 0; -} - -.chat-v2__conversation-item { - appearance: none; - width: 100%; - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; - border: 0; - outline: none; - box-shadow: none; - background: transparent; - text-align: left; - cursor: pointer; -} - -.chat-v2__conversation-item:hover { - background: rgba(15, 23, 42, 0.04); -} - -.chat-v2__conversation-item:focus, -.chat-v2__conversation-item:focus-visible { - outline: none; - box-shadow: none; -} - -.chat-v2__conversation-item--active { - background: rgba(22, 119, 255, 0.08); -} - -.chat-v2__conversation-title { - color: #111827; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-v2__conversation-preview { - color: #6b7280; - font-size: 13px; - display: -webkit-box; - overflow: hidden; - line-height: 1.4; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -@media (max-width: 1180px) { - .chat-v2__pane--list { - flex-basis: auto; - max-width: none; - } -} - -@media (min-width: 1181px) { - .app-chat-panel { - background: - linear-gradient(180deg, rgba(244, 246, 248, 0.98), rgba(238, 241, 244, 0.98)), - radial-gradient(circle at top left, rgba(148, 163, 184, 0.08), transparent 28%); - } - - .app-chat-panel .ant-card-head { - background: rgba(248, 249, 250, 0.9); - border-bottom: 1px solid rgba(148, 163, 184, 0.14); - } - - .app-chat-panel__conversation-shell { - border: 1px solid rgba(148, 163, 184, 0.16); - background: rgba(255, 255, 255, 0.9); - box-shadow: 0 14px 28px rgba(15, 23, 42, 0.06); - } - - .app-chat-panel__conversation-list { - background: rgba(246, 247, 249, 0.92); - border-right: 1px solid rgba(148, 163, 184, 0.12); - } - - .app-chat-panel__conversation-section-title, - .app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-title { - color: #475569; - } - - .app-chat-panel__conversation-section-count, - .app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-count { - background: rgba(226, 232, 240, 0.92); - color: #475569; - box-shadow: none; - } - - .app-chat-panel__conversation-item { - border-color: transparent; - background: rgba(255, 255, 255, 0.92); - box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04); - } - - .app-chat-panel__conversation-item--active { - border-color: transparent; - background: rgba(248, 250, 252, 0.98); - box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06); - } - - .app-chat-panel__conversation-item--unread { - border-color: transparent; - background: - linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%), - #fff; - box-shadow: - inset 4px 0 0 rgba(100, 116, 139, 0.62), - 0 10px 24px rgba(15, 23, 42, 0.06); - } - - .app-chat-panel__conversation-item--unread-section { - border-color: transparent; - background: - linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%), - #fff; - box-shadow: - inset 6px 0 0 rgba(100, 116, 139, 0.68), - 0 12px 26px rgba(15, 23, 42, 0.07); - } - - .app-chat-panel__conversation-item--unread::after, - .app-chat-panel__conversation-item-unread-dot { - background: #64748b; - box-shadow: - 0 0 0 4px rgba(226, 232, 240, 0.9), - 0 0 8px rgba(100, 116, 139, 0.18); - } - - .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { - border-color: transparent; - background: - linear-gradient(90deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 0.99) 34%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%), - #fff; - box-shadow: - inset 4px 0 0 rgba(100, 116, 139, 0.82), - 0 12px 24px rgba(15, 23, 42, 0.08); - } - - .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section { - border-color: transparent; - background: - linear-gradient(135deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 1) 36%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%), - #fff; - box-shadow: - inset 6px 0 0 rgba(100, 116, 139, 0.84), - 0 14px 28px rgba(15, 23, 42, 0.08); - } - - .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title, - .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-time, - .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-id, - .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-preview { - color: #334155; - } - - .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title-wrap { - background: linear-gradient(90deg, rgba(241, 245, 249, 0.92), rgba(241, 245, 249, 0)); - } - - .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-time { - background: rgba(241, 245, 249, 0.96); - } - - .app-chat-panel__conversation-item-flag--unread, - .app-chat-panel__conversation-item-unread-badge { - color: #475569; - background: rgba(226, 232, 240, 0.92); - border-color: rgba(148, 163, 184, 0.24); - box-shadow: none; - } -} +@import './mainChatPanel/styles/MainChatPanel.layout.css'; +@import './mainChatPanel/styles/MainChatPanel.conversation.css'; +@import './mainChatPanel/styles/MainChatPanel.preview-runtime.css'; diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 78facfe..9b804f2 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -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; +}) { + await showLocalClientNotification({ + title: args.title, + body: args.body, + threadId: args.threadId, + data: args.data, + }).catch(() => false); +} + function getCachedSessionMessages(cache: Map, 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([]); 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(null); const [editingChatTypeDescription, setEditingChatTypeDescription] = useState(''); const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState([]); + 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(null); + const [pendingClearConversationSessionId, setPendingClearConversationSessionId] = useState(null); const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now()); const viewportRef = useRef(null); const composerRef = useRef(null); @@ -1975,9 +2179,27 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const isClosingConversationRef = useRef(false); const notifiedTerminalJobKeysRef = useRef([]); const notifiedRestartRequirementKeysRef = useRef([]); + const notifiedChatPushKeysRef = useRef([]); const lastMarkedReadResponseIdBySessionRef = useRef>({}); 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) => { 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(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} ) : null} + {isConversationPendingWork(item) ? ( + + 작업중 + + ) : null} {isUnread ? ( 답변 도착 @@ -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 = }} /> + +