From 442879313f32393be043d0338338e0c7e706116a Mon Sep 17 00:00:00 2001 From: how2ice Date: Fri, 8 May 2026 21:15:51 +0900 Subject: [PATCH] feat: refine codex live chat context flows --- docs/README.md | 211 +- .../component-addition-suggestions.md | 137 - docs/test001.md | 1 - .../work-server/src/routes/app-config.ts | 24 +- .../work-server/src/routes/chat.test.ts | 13 + etc/servers/work-server/src/routes/chat.ts | 42 +- .../work-server/src/routes/server-command.ts | 41 +- .../src/services/app-config-service.test.ts | 221 +- .../src/services/app-config-service.ts | 288 +- .../src/services/chat-message-parts.ts | 331 +- .../src/services/chat-room-service.ts | 239 +- .../src/services/chat-service.test.ts | 373 +- .../work-server/src/services/chat-service.ts | 116 +- .../src/services/chat-type-defaults.js | 23 +- .../src/services/chat-type-defaults.ts | 27 +- .../src/services/main-project-root-service.ts | 56 + .../services/resource-manager-service.test.ts | 13 + .../src/services/resource-manager-service.ts | 6 +- .../src/services/server-command-service.ts | 5 +- .../server-restart-reservation-service.ts | 578 ++- .../src/services/stock-alert-service.js | 2126 ++++----- .../src/services/stock-alert-service.test.ts | 125 +- .../src/services/stock-alert-service.ts | 237 +- .../src/services/work-server-build-service.ts | 5 +- resource/prod/.gitkeep | 1 - .../prod/clipboard-20260506-214618-1.html | 1 - resource/prod/clipboard-20260506-214618-2.txt | 1 - resource/release/.gitkeep | 1 - resource/release/IMG_9111.PNG | Bin 187112 -> 0 bytes .../main/AutomationContextManagementPage.css | 1 + .../main/AutomationContextManagementPage.tsx | 2 +- src/app/main/AutomationTypeManagementPage.css | 1 + src/app/main/AutomationTypeManagementPage.tsx | 2 +- .../main/ChatDefaultContextManagementPage.css | 1 + .../main/ChatDefaultContextManagementPage.tsx | 174 +- src/app/main/ChatTypeManagementPage.css | 653 +-- src/app/main/ChatTypeManagementPage.tsx | 247 +- src/app/main/MainChatPanel.hotfix.css | 3957 +---------------- src/app/main/MainChatPanel.tsx | 578 ++- src/app/main/MainHeader.tsx | 111 +- src/app/main/ManagementPage.shared.css | 668 +++ src/app/main/ResourceManagementPage.css | 444 +- src/app/main/ResourceManagementPage.tsx | 340 +- src/app/main/chatContextSettingsAccess.ts | 302 +- src/app/main/chatTypeAccess.ts | 115 +- src/app/main/chatTypeDefaults.ts | 30 +- .../components/ConversationRoomPane.tsx | 2 + src/app/main/chatV2/data/chatGateway.ts | 3 + .../useConversationComposerController.ts | 33 +- .../chatV2/hooks/useConversationListData.ts | 26 + .../useConversationRoomActionsController.ts | 43 + .../chatV2/hooks/useConversationRoomData.ts | 4 +- src/app/main/clientIdentity.ts | 12 + src/app/main/layout/MainLayout.tsx | 2 +- src/app/main/layout/useMainLayoutData.ts | 2 +- .../mainChatPanel/ChatActivityChecklist.tsx | 323 ++ .../mainChatPanel/ChatConversationView.tsx | 233 +- src/app/main/mainChatPanel/ChatPromptCard.tsx | 1160 +++++ src/app/main/mainChatPanel/chatResourceUrl.ts | 21 +- src/app/main/mainChatPanel/chatUtils.ts | 51 +- src/app/main/mainChatPanel/index.ts | 1 + src/app/main/mainChatPanel/messageParts.ts | 239 +- .../styles/MainChatPanel.conversation.css | 1019 +++++ .../styles/MainChatPanel.layout.css | 1692 +++++++ .../styles/MainChatPanel.preview-runtime.css | 1980 +++++++++ src/app/main/mainChatPanel/types.ts | 60 + src/app/main/mainView/constants.tsx | 7 +- src/app/main/routes.tsx | 7 +- src/app/manifests/docs.manifest.ts | 60 +- src/components/README.md | 70 +- .../chatPromptCard/samples/Sample.tsx | 157 + .../EvidenceAttachmentStrip.tsx | 21 +- .../previewer/CodexDiffPreviewer.tsx | 24 +- src/components/previewer/PreviewerUI.css | 13 + src/components/previewer/PreviewerUI.tsx | 24 +- src/features/board/BoardPage.tsx | 20 +- src/features/layout/README.md | 50 +- src/features/overview.md | 10 +- src/features/planBoard/PlanBoardPage.tsx | 323 +- .../planBoard/PlanListDetailLayout.tsx | 3 +- src/features/planBoard/planBoard.css | 126 + .../serverCommand/ServerCommandPage.tsx | 335 +- src/features/serverCommand/api.ts | 118 +- src/features/serverCommand/serverCommand.css | 20 + src/features/serverCommand/types.ts | 42 +- src/sw.js | 40 +- src/utils/clipboard.ts | 117 + src/views/play/apps/cbt/CbtPlayAppView.tsx | 2 +- .../play/apps/cbt/cbtBonusQuestionSeeds.ts | 22 +- src/views/play/apps/cbt/cbtData.ts | 22 +- .../play/apps/cbt/cbtSubjectExpansionSeeds.ts | 655 +++ src/widgets/README.md | 67 +- 92 files changed, 14815 insertions(+), 7314 deletions(-) delete mode 100755 docs/components/component-addition-suggestions.md delete mode 100755 docs/test001.md create mode 100644 etc/servers/work-server/src/routes/chat.test.ts create mode 100644 etc/servers/work-server/src/services/main-project-root-service.ts create mode 100644 etc/servers/work-server/src/services/resource-manager-service.test.ts delete mode 100644 resource/prod/.gitkeep delete mode 100644 resource/prod/clipboard-20260506-214618-1.html delete mode 100644 resource/prod/clipboard-20260506-214618-2.txt delete mode 100644 resource/release/.gitkeep delete mode 100644 resource/release/IMG_9111.PNG create mode 100644 src/app/main/AutomationContextManagementPage.css create mode 100644 src/app/main/AutomationTypeManagementPage.css create mode 100644 src/app/main/ChatDefaultContextManagementPage.css mode change 100755 => 100644 src/app/main/ChatTypeManagementPage.css create mode 100755 src/app/main/ManagementPage.shared.css create mode 100644 src/app/main/mainChatPanel/ChatActivityChecklist.tsx create mode 100644 src/app/main/mainChatPanel/ChatPromptCard.tsx create mode 100644 src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css create mode 100644 src/app/main/mainChatPanel/styles/MainChatPanel.layout.css create mode 100644 src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css create mode 100644 src/components/chatPromptCard/samples/Sample.tsx create mode 100644 src/utils/clipboard.ts create mode 100644 src/views/play/apps/cbt/cbtSubjectExpansionSeeds.ts 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 4c47c56039c484c64a56c530021ba40b0f38d9d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 187112 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

+&jqC{YT02kXJWlIDUDmm&Y3|eD5pAGtP}2 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[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 = }} /> + +