feat: update codex live runtime and restart flow
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@ etc/**/.DS_Store
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.swp
|
*.swp
|
||||||
*.root-owned-backup
|
*.root-owned-backup
|
||||||
|
*.root-owned-backup-*/
|
||||||
|
|
||||||
vite.config.js
|
vite.config.js
|
||||||
vite.config.d.ts
|
vite.config.d.ts
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ docs/
|
|||||||
- 앱 문서는 Vite `import.meta.glob`으로 Markdown 파일을 수집합니다.
|
- 앱 문서는 Vite `import.meta.glob`으로 Markdown 파일을 수집합니다.
|
||||||
- 작업일지는 날짜별 파일로 누적하며 캡처 이미지는 `docs/assets/worklogs/YYYY-MM-DD/` 기준으로 관리합니다.
|
- 작업일지는 날짜별 파일로 누적하며 캡처 이미지는 `docs/assets/worklogs/YYYY-MM-DD/` 기준으로 관리합니다.
|
||||||
- Plan 자동화 스크립트는 `scripts/run-plan-codex-once.mjs`를 사용합니다.
|
- Plan 자동화 스크립트는 `scripts/run-plan-codex-once.mjs`를 사용합니다.
|
||||||
- 서버 재기동을 호스트 프로젝트 루트 기준으로 처리하려면 `npm run server-command:runner`를 실행합니다.
|
- command runner는 별도 명시적 요청이 있을 때만 `npm run server-command:runner`로 직접 기동하거나 재기동합니다.
|
||||||
- 문서/작업일지 일일 정리는 `npm run docs:daily`와 `.github/workflows/daily-docs-maintenance.yml` 기준으로 실행합니다.
|
- 문서/작업일지 일일 정리는 `npm run docs:daily`와 `.github/workflows/daily-docs-maintenance.yml` 기준으로 실행합니다.
|
||||||
|
|
||||||
## 프로젝트 현황
|
## 프로젝트 현황
|
||||||
|
|||||||
34
docs/worklogs/2026-04-23.md
Normal file
34
docs/worklogs/2026-04-23.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 2026-04-23 작업일지
|
||||||
|
|
||||||
|
## 오늘 작업
|
||||||
|
|
||||||
|
- 화면 캡처 추가 예정
|
||||||
|
|
||||||
|
## 스크린샷
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 소스
|
||||||
|
|
||||||
|
### 파일 1: `path/to/file.tsx`
|
||||||
|
|
||||||
|
- 변경 목적과 핵심 수정 내용을 한 줄로 정리
|
||||||
|
|
||||||
|
```diff
|
||||||
|
# 이 파일의 핵심 diff
|
||||||
|
- before
|
||||||
|
+ after
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일 2: `path/to/another-file.ts`
|
||||||
|
|
||||||
|
- 필요 없으면 이 섹션은 삭제
|
||||||
|
|
||||||
|
## 실행 커맨드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
|
||||||
|
-
|
||||||
51
etc/commands/server-command/restart-prod.sh
Normal file
51
etc/commands/server-command/restart-prod.sh
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||||
|
SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}"
|
||||||
|
SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-prod-app}"
|
||||||
|
SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod}"
|
||||||
|
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||||
|
SERVER_COMMAND_PROD_GIT_REMOTE="${SERVER_COMMAND_PROD_GIT_REMOTE:-origin}"
|
||||||
|
SERVER_COMMAND_PROD_GIT_BRANCH="${SERVER_COMMAND_PROD_GIT_BRANCH:-main}"
|
||||||
|
SERVER_COMMAND_PROD_GIT_USERNAME="${SERVER_COMMAND_PROD_GIT_USERNAME:-}"
|
||||||
|
SERVER_COMMAND_PROD_GIT_PASSWORD="${SERVER_COMMAND_PROD_GIT_PASSWORD:-}"
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
|
||||||
|
cd "$MAIN_PROJECT_ROOT"
|
||||||
|
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "git CLI not found: cannot pull ${SERVER_COMMAND_PROD_GIT_REMOTE}/${SERVER_COMMAND_PROD_GIT_BRANCH}" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$CURRENT_BRANCH" != "$SERVER_COMMAND_PROD_GIT_BRANCH" ]; then
|
||||||
|
echo "expected branch ${SERVER_COMMAND_PROD_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SERVER_COMMAND_PROD_GIT_USERNAME" ] && [ -n "$SERVER_COMMAND_PROD_GIT_PASSWORD" ]; then
|
||||||
|
REMOTE_URL=$(git remote get-url "$SERVER_COMMAND_PROD_GIT_REMOTE")
|
||||||
|
CREDENTIAL_STORE=$(mktemp)
|
||||||
|
trap 'rm -f "$CREDENTIAL_STORE"' EXIT INT TERM
|
||||||
|
printf '%s\n' "$REMOTE_URL" \
|
||||||
|
| sed "s#^https://#https://${SERVER_COMMAND_PROD_GIT_USERNAME}:${SERVER_COMMAND_PROD_GIT_PASSWORD}@#" \
|
||||||
|
>"$CREDENTIAL_STORE"
|
||||||
|
git -c "credential.helper=store --file=$CREDENTIAL_STORE" pull --ff-only "$SERVER_COMMAND_PROD_GIT_REMOTE" "$SERVER_COMMAND_PROD_GIT_BRANCH"
|
||||||
|
else
|
||||||
|
git pull --ff-only "$SERVER_COMMAND_PROD_GIT_REMOTE" "$SERVER_COMMAND_PROD_GIT_BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "$SERVER_COMMAND_SERVICE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||||
|
exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2
|
||||||
|
exit 127
|
||||||
@@ -12,11 +12,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|||||||
cd "$MAIN_PROJECT_ROOT"
|
cd "$MAIN_PROJECT_ROOT"
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if command -v docker >/dev/null 2>&1; then
|
||||||
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then
|
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "$SERVER_COMMAND_SERVICE"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
|
|||||||
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
|
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
|
||||||
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
|
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
|
||||||
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
|
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
|
||||||
RUNNER_CPU_WATCHDOG_ENABLED="${SERVER_COMMAND_CPU_WATCHDOG_ENABLED:-true}"
|
|
||||||
RUNNER_CPU_WATCHDOG_INTERVAL_MS="${SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS:-60000}"
|
|
||||||
RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT="${SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT:-120}"
|
|
||||||
RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT="${SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT:-8}"
|
|
||||||
RUNNER_CPU_WATCHDOG_COOLDOWN_MS="${SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS:-1200000}"
|
|
||||||
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
|
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
|
||||||
|
|
||||||
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
|
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
|
||||||
@@ -43,11 +38,6 @@ setsid env \
|
|||||||
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
|
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
|
||||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
|
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
|
||||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
|
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
|
||||||
SERVER_COMMAND_CPU_WATCHDOG_ENABLED="$RUNNER_CPU_WATCHDOG_ENABLED" \
|
|
||||||
SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS="$RUNNER_CPU_WATCHDOG_INTERVAL_MS" \
|
|
||||||
SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT="$RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT" \
|
|
||||||
SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT="$RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT" \
|
|
||||||
SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS="$RUNNER_CPU_WATCHDOG_COOLDOWN_MS" \
|
|
||||||
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
||||||
|
|
||||||
echo "server-command-runner restart requested"
|
echo "server-command-runner restart requested"
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project
|
|||||||
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
|
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
|
||||||
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
|
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
|
||||||
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
|
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
|
||||||
|
SERVER_COMMAND_PROD_URL=https://sm-home.cloud/
|
||||||
|
SERVER_COMMAND_PROD_GIT_REMOTE=origin
|
||||||
|
SERVER_COMMAND_PROD_GIT_BRANCH=main
|
||||||
|
SERVER_COMMAND_PROD_GIT_USERNAME=
|
||||||
|
SERVER_COMMAND_PROD_GIT_PASSWORD=
|
||||||
SERVER_COMMAND_WORK_SERVER_URL=http://127.0.0.1:3100/health
|
SERVER_COMMAND_WORK_SERVER_URL=http://127.0.0.1:3100/health
|
||||||
SERVER_COMMAND_RUNNER_URL=http://host.docker.internal:3211/health
|
SERVER_COMMAND_RUNNER_URL=http://host.docker.internal:3211/health
|
||||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN=local-server-command-runner
|
SERVER_COMMAND_RUNNER_ACCESS_TOKEN=local-server-command-runner
|
||||||
@@ -49,4 +54,5 @@ SERVER_COMMAND_RUNNER_HOST=0.0.0.0
|
|||||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE=/workspace/main-project/.server-command-runner-heartbeat.json
|
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE=/workspace/main-project/.server-command-runner-heartbeat.json
|
||||||
SERVER_COMMAND_TEST_SERVICE=app
|
SERVER_COMMAND_TEST_SERVICE=app
|
||||||
SERVER_COMMAND_REL_SERVICE=release-app
|
SERVER_COMMAND_REL_SERVICE=release-app
|
||||||
|
SERVER_COMMAND_PROD_SERVICE=prod-app
|
||||||
SERVER_COMMAND_WORK_SERVER_SERVICE=work-server
|
SERVER_COMMAND_WORK_SERVER_SERVICE=work-server
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ docker compose logs -f work-server
|
|||||||
|
|
||||||
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
||||||
|
|
||||||
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner도 함께 켭니다.
|
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/how2ice/project/ai-code-app
|
cd /home/how2ice/project/ai-code-app
|
||||||
@@ -53,7 +53,9 @@ npm run server-command:runner
|
|||||||
|
|
||||||
## Codex Live
|
## Codex Live
|
||||||
|
|
||||||
`Codex Live`는 현재 프로젝트 환경의 `main_project` 경로를 기준으로 실행됩니다. 기본값은 `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`이며, 소스 수정이 필요하면 이 경로의 실제 프로젝트를 바로 수정합니다.
|
`Codex Live`는 현재 프로젝트 환경에서 `PLAN_MAIN_PROJECT_REPO_PATH`로 지정된 `main_project` 저장소 루트를 기준으로 실행됩니다. 문서 예시와 기본 컨테이너 값은 `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`이지만, 이것은 환경 변수 기본 예시일 뿐이며 실제 실행 루트는 현재 마운트된 프로젝트 경로를 따릅니다.
|
||||||
|
|
||||||
|
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
|
||||||
|
|
||||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -71,12 +71,14 @@ const envSchema = z.object({
|
|||||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
|
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
|
||||||
SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'),
|
SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'),
|
||||||
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||||
|
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
|
||||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
||||||
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
||||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||||
|
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
|
||||||
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
|
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||||
import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
|
import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
|
||||||
|
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||||
import {
|
import {
|
||||||
createChatConversation,
|
createChatConversation,
|
||||||
deleteUnansweredChatConversationRequest,
|
deleteUnansweredChatConversationRequest,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
listChatConversationDetailPage,
|
listChatConversationDetailPage,
|
||||||
listChatConversations,
|
listChatConversations,
|
||||||
markChatConversationResponsesRead,
|
markChatConversationResponsesRead,
|
||||||
|
upsertChatConversationRequest,
|
||||||
updateChatConversationContext,
|
updateChatConversationContext,
|
||||||
} from '../services/chat-room-service.js';
|
} from '../services/chat-room-service.js';
|
||||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||||
@@ -308,11 +310,56 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/chat/runtime/jobs/:requestId/rollback', async (request, reply) => {
|
||||||
|
const params = z.object({
|
||||||
|
requestId: z.string().trim().min(1).max(120),
|
||||||
|
}).parse(request.params ?? {});
|
||||||
|
const payload = z
|
||||||
|
.object({
|
||||||
|
sessionId: z.string().trim().min(1).max(120).optional(),
|
||||||
|
})
|
||||||
|
.parse(request.body ?? {});
|
||||||
|
|
||||||
|
chatRuntimeService.appendLog(params.requestId, '사용자 요청으로 최근 실행 롤백을 시작합니다.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await rollbackChatRuntimeRequest({
|
||||||
|
requestId: params.requestId,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await upsertChatConversationRequest(result.sessionId, {
|
||||||
|
requestId: result.requestId,
|
||||||
|
status: 'cancelled',
|
||||||
|
statusMessage: '사용자 요청으로 최근 실행 변경을 롤백했습니다.',
|
||||||
|
});
|
||||||
|
chatRuntimeService.setArchivedJobTerminalStatus(
|
||||||
|
params.requestId,
|
||||||
|
'cancelled',
|
||||||
|
'최근 실행이 롤백되어 상태를 취소로 변경했습니다.',
|
||||||
|
);
|
||||||
|
chatRuntimeService.appendLog(params.requestId, '최근 실행 롤백이 완료되었습니다.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.';
|
||||||
|
chatRuntimeService.appendLog(params.requestId, `최근 실행 롤백 실패: ${message}`);
|
||||||
|
|
||||||
|
return reply.code(409).send({
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/chat/conversations', async (request) => {
|
app.post('/api/chat/conversations', async (request) => {
|
||||||
const payload = z.object({
|
const payload = z.object({
|
||||||
sessionId: z.string().trim().min(1).max(120),
|
sessionId: z.string().trim().min(1).max(120),
|
||||||
title: z.string().trim().max(200).optional(),
|
title: z.string().trim().max(200).optional(),
|
||||||
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||||
|
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||||
contextLabel: z.string().trim().max(200).optional(),
|
contextLabel: z.string().trim().max(200).optional(),
|
||||||
contextDescription: z.string().trim().max(2000).optional(),
|
contextDescription: z.string().trim().max(2000).optional(),
|
||||||
notifyOffline: z.boolean().optional(),
|
notifyOffline: z.boolean().optional(),
|
||||||
@@ -324,6 +371,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
clientId: clientId || null,
|
clientId: clientId || null,
|
||||||
title: payload.title ?? '새 대화',
|
title: payload.title ?? '새 대화',
|
||||||
chatTypeId: payload.chatTypeId ?? null,
|
chatTypeId: payload.chatTypeId ?? null,
|
||||||
|
lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null,
|
||||||
contextLabel: payload.contextLabel ?? null,
|
contextLabel: payload.contextLabel ?? null,
|
||||||
contextDescription: payload.contextDescription ?? null,
|
contextDescription: payload.contextDescription ?? null,
|
||||||
notifyOffline: payload.notifyOffline ?? true,
|
notifyOffline: payload.notifyOffline ?? true,
|
||||||
@@ -438,6 +486,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
const payload = z.object({
|
const payload = z.object({
|
||||||
title: z.string().trim().min(1).max(200).optional(),
|
title: z.string().trim().min(1).max(200).optional(),
|
||||||
chatTypeId: z.string().trim().max(120).optional().nullable(),
|
chatTypeId: z.string().trim().max(120).optional().nullable(),
|
||||||
|
lastChatTypeId: z.string().trim().max(120).optional().nullable(),
|
||||||
contextLabel: z.string().trim().max(200).optional().nullable(),
|
contextLabel: z.string().trim().max(200).optional().nullable(),
|
||||||
contextDescription: z.string().trim().max(2000).optional().nullable(),
|
contextDescription: z.string().trim().max(2000).optional().nullable(),
|
||||||
notifyOffline: z.boolean().optional(),
|
notifyOffline: z.boolean().optional(),
|
||||||
@@ -456,6 +505,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
title: payload.title ?? current.title,
|
title: payload.title ?? current.title,
|
||||||
clientId: current.clientId,
|
clientId: current.clientId,
|
||||||
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
|
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
|
||||||
|
lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId,
|
||||||
contextLabel: payload.contextLabel ?? current.contextLabel,
|
contextLabel: payload.contextLabel ?? current.contextLabel,
|
||||||
contextDescription: payload.contextDescription ?? current.contextDescription,
|
contextDescription: payload.contextDescription ?? current.contextDescription,
|
||||||
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const conversationPayloadSchema = z.object({
|
|||||||
clientId: z.string().trim().max(120).nullable().optional(),
|
clientId: z.string().trim().max(120).nullable().optional(),
|
||||||
title: z.string().trim().max(200).nullable().optional(),
|
title: z.string().trim().max(200).nullable().optional(),
|
||||||
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||||
|
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||||
contextLabel: z.string().trim().max(200).nullable().optional(),
|
contextLabel: z.string().trim().max(200).nullable().optional(),
|
||||||
contextDescription: z.string().trim().max(2000).nullable().optional(),
|
contextDescription: z.string().trim().max(2000).nullable().optional(),
|
||||||
notifyOffline: z.boolean().optional(),
|
notifyOffline: z.boolean().optional(),
|
||||||
@@ -34,6 +35,7 @@ export type ChatConversationItem = {
|
|||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
chatTypeId: string | null;
|
chatTypeId: string | null;
|
||||||
|
lastChatTypeId: string | null;
|
||||||
contextLabel: string | null;
|
contextLabel: string | null;
|
||||||
contextDescription: string | null;
|
contextDescription: string | null;
|
||||||
notifyOffline: boolean;
|
notifyOffline: boolean;
|
||||||
@@ -173,6 +175,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
|
|||||||
clientId: row.client_id == null ? null : String(row.client_id),
|
clientId: row.client_id == null ? null : String(row.client_id),
|
||||||
title: String(row.title ?? '새 대화'),
|
title: String(row.title ?? '새 대화'),
|
||||||
chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
|
chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
|
||||||
|
lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id),
|
||||||
contextLabel: row.context_label == null ? null : String(row.context_label),
|
contextLabel: row.context_label == null ? null : String(row.context_label),
|
||||||
contextDescription: row.context_description == null ? null : String(row.context_description),
|
contextDescription: row.context_description == null ? null : String(row.context_description),
|
||||||
notifyOffline: Boolean(row.notify_offline),
|
notifyOffline: Boolean(row.notify_offline),
|
||||||
@@ -709,6 +712,7 @@ export async function ensureChatConversationTables() {
|
|||||||
table.string('client_id', 120).nullable().index();
|
table.string('client_id', 120).nullable().index();
|
||||||
table.string('title', 200).notNullable().defaultTo('새 대화');
|
table.string('title', 200).notNullable().defaultTo('새 대화');
|
||||||
table.string('chat_type_id', 120).nullable();
|
table.string('chat_type_id', 120).nullable();
|
||||||
|
table.string('last_chat_type_id', 120).nullable();
|
||||||
table.string('context_label', 200).nullable();
|
table.string('context_label', 200).nullable();
|
||||||
table.text('context_description').nullable();
|
table.text('context_description').nullable();
|
||||||
table.boolean('notify_offline').notNullable().defaultTo(false);
|
table.boolean('notify_offline').notNullable().defaultTo(false);
|
||||||
@@ -728,6 +732,7 @@ export async function ensureChatConversationTables() {
|
|||||||
['client_id', (table) => table.string('client_id', 120).nullable().index()],
|
['client_id', (table) => table.string('client_id', 120).nullable().index()],
|
||||||
['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')],
|
['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')],
|
||||||
['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
|
['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
|
||||||
|
['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()],
|
||||||
['context_label', (table) => table.string('context_label', 200).nullable()],
|
['context_label', (table) => table.string('context_label', 200).nullable()],
|
||||||
['context_description', (table) => table.text('context_description').nullable()],
|
['context_description', (table) => table.text('context_description').nullable()],
|
||||||
['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)],
|
['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)],
|
||||||
@@ -1012,6 +1017,7 @@ export async function createChatConversation(payload: z.input<typeof conversatio
|
|||||||
client_id: normalizedClientId,
|
client_id: normalizedClientId,
|
||||||
title: parsed.title?.trim() || '새 대화',
|
title: parsed.title?.trim() || '새 대화',
|
||||||
chat_type_id: parsed.chatTypeId?.trim() || null,
|
chat_type_id: parsed.chatTypeId?.trim() || null,
|
||||||
|
last_chat_type_id: parsed.lastChatTypeId?.trim() || parsed.chatTypeId?.trim() || null,
|
||||||
context_label: parsed.contextLabel?.trim() || null,
|
context_label: parsed.contextLabel?.trim() || null,
|
||||||
context_description: parsed.contextDescription?.trim() || null,
|
context_description: parsed.contextDescription?.trim() || null,
|
||||||
notify_offline: notifyOffline,
|
notify_offline: notifyOffline,
|
||||||
@@ -1051,6 +1057,7 @@ export async function updateChatConversationContext(
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
clientId?: string | null;
|
clientId?: string | null;
|
||||||
chatTypeId?: string | null;
|
chatTypeId?: string | null;
|
||||||
|
lastChatTypeId?: string | null;
|
||||||
contextLabel?: string | null;
|
contextLabel?: string | null;
|
||||||
contextDescription?: string | null;
|
contextDescription?: string | null;
|
||||||
notifyOffline?: boolean | null;
|
notifyOffline?: boolean | null;
|
||||||
@@ -1069,6 +1076,7 @@ export async function updateChatConversationContext(
|
|||||||
title: payload.title?.trim() || current.title || '새 대화',
|
title: payload.title?.trim() || current.title || '새 대화',
|
||||||
client_id: normalizedClientId || current.client_id || null,
|
client_id: normalizedClientId || current.client_id || null,
|
||||||
chat_type_id: payload.chatTypeId?.trim() || null,
|
chat_type_id: payload.chatTypeId?.trim() || null,
|
||||||
|
last_chat_type_id: payload.lastChatTypeId?.trim() || null,
|
||||||
context_label: payload.contextLabel?.trim() || null,
|
context_label: payload.contextLabel?.trim() || null,
|
||||||
context_description: payload.contextDescription?.trim() || null,
|
context_description: payload.contextDescription?.trim() || null,
|
||||||
notify_offline:
|
notify_offline:
|
||||||
@@ -1606,6 +1614,11 @@ export async function appendChatConversationMessage(
|
|||||||
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
|
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
|
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
|
||||||
|
last_chat_type_id:
|
||||||
|
conversation.chatTypeId?.trim() ||
|
||||||
|
currentConversation?.last_chat_type_id ||
|
||||||
|
currentConversation?.chat_type_id ||
|
||||||
|
null,
|
||||||
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
|
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
|
||||||
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
|
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
|
||||||
notify_offline:
|
notify_offline:
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { normalizeRollbackDiffText } from './chat-runtime-rollback-service.js';
|
||||||
|
|
||||||
|
test('normalizeRollbackDiffText strips chat resource prefixes from stored diff paths', () => {
|
||||||
|
const normalized = normalizeRollbackDiffText([
|
||||||
|
'diff --git a/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx b/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx',
|
||||||
|
'--- a/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx',
|
||||||
|
'+++ b/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx',
|
||||||
|
'@@',
|
||||||
|
'+const value = 1;',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
assert.match(normalized, /^diff --git a\/src\/app\/main\/MainHeader\.tsx b\/src\/app\/main\/MainHeader\.tsx/m);
|
||||||
|
assert.match(normalized, /^--- a\/src\/app\/main\/MainHeader\.tsx$/m);
|
||||||
|
assert.match(normalized, /^\+\+\+ b\/src\/app\/main\/MainHeader\.tsx$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeRollbackDiffText keeps the final repo path when resource prefixes were duplicated', () => {
|
||||||
|
const normalized = normalizeRollbackDiffText(
|
||||||
|
'diff --git a/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/api/chat/resources/.codex_chat/chat-room/resource/README.md b/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/api/chat/resources/.codex_chat/chat-room/resource/README.md\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(normalized.trim(), 'diff --git a/README.md b/README.md');
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { CHAT_CONVERSATION_REQUEST_TABLE } from './chat-room-service.js';
|
||||||
|
|
||||||
|
function extractDiffBlocks(text: string) {
|
||||||
|
return Array.from(String(text ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g))
|
||||||
|
.map((match) => (typeof match[1] === 'string' ? match[1].trim() : ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChatRepoPath() {
|
||||||
|
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
|
||||||
|
|
||||||
|
if (!repoPath?.trim()) {
|
||||||
|
throw new Error('롤백 대상 저장소 경로가 설정되지 않았습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve(repoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeGitApplyError(stderr: string) {
|
||||||
|
const normalized = String(stderr ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return '현재 작업트리가 이 요청 시점과 달라 자동 롤백을 적용할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.slice(0, 3).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripChatResourcePrefix(candidate: string) {
|
||||||
|
let normalized = String(candidate ?? '').replace(/\\/g, '/');
|
||||||
|
const marker = '/resource/';
|
||||||
|
|
||||||
|
while (normalized.includes(marker)) {
|
||||||
|
normalized = normalized.slice(normalized.indexOf(marker) + marker.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRollbackDiffLine(line: string) {
|
||||||
|
const normalizedLine = String(line ?? '');
|
||||||
|
|
||||||
|
if (normalizedLine.startsWith('diff --git ')) {
|
||||||
|
return normalizedLine.replace(/^diff --git a\/(.+?) b\/(.+)$/, (_match, left, right) => {
|
||||||
|
return `diff --git a/${stripChatResourcePrefix(left)} b/${stripChatResourcePrefix(right)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLine.startsWith('--- a/')) {
|
||||||
|
return `--- a/${stripChatResourcePrefix(normalizedLine.slice('--- a/'.length))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLine.startsWith('+++ b/')) {
|
||||||
|
return `+++ b/${stripChatResourcePrefix(normalizedLine.slice('+++ b/'.length))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLine.startsWith('rename from ')) {
|
||||||
|
return `rename from ${stripChatResourcePrefix(normalizedLine.slice('rename from '.length))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLine.startsWith('rename to ')) {
|
||||||
|
return `rename to ${stripChatResourcePrefix(normalizedLine.slice('rename to '.length))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRollbackDiffText(diffText: string) {
|
||||||
|
return String(diffText ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => normalizeRollbackDiffLine(line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGitApplyReverse(repoPath: string, diffText: string, checkOnly: boolean) {
|
||||||
|
return await new Promise<{ ok: boolean; stderr: string }>((resolve, reject) => {
|
||||||
|
const args = ['apply', '--reverse', '--whitespace=nowarn'];
|
||||||
|
|
||||||
|
if (checkOnly) {
|
||||||
|
args.push('--check');
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-');
|
||||||
|
|
||||||
|
const child = spawn('git', args, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: ['pipe', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve({
|
||||||
|
ok: code === 0,
|
||||||
|
stderr,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.write(diffText);
|
||||||
|
child.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackChatRuntimeRequest(args: { requestId: string; sessionId?: string | null }) {
|
||||||
|
const normalizedRequestId = args.requestId.trim();
|
||||||
|
const normalizedSessionId = args.sessionId?.trim() || null;
|
||||||
|
|
||||||
|
if (!normalizedRequestId) {
|
||||||
|
throw new Error('롤백할 요청 ID가 비어 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPath = resolveChatRepoPath();
|
||||||
|
const row = await db(CHAT_CONVERSATION_REQUEST_TABLE)
|
||||||
|
.select('session_id', 'request_id', 'status', 'response_text')
|
||||||
|
.where('request_id', normalizedRequestId)
|
||||||
|
.modify((query) => {
|
||||||
|
if (normalizedSessionId) {
|
||||||
|
query.andWhere('session_id', normalizedSessionId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.orderBy('updated_at', 'desc')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('롤백할 Codex Live 실행 기록을 찾지 못했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = String(row.status ?? '').trim();
|
||||||
|
|
||||||
|
if (status === 'cancelled' || status === 'removed') {
|
||||||
|
throw new Error('취소되었거나 제거된 요청은 롤백할 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffBlocks = extractDiffBlocks(String(row.response_text ?? ''));
|
||||||
|
|
||||||
|
if (diffBlocks.length === 0) {
|
||||||
|
throw new Error('이 요청에는 롤백 가능한 diff 기록이 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffText = normalizeRollbackDiffText(`${diffBlocks.join('\n\n')}\n`);
|
||||||
|
const checkResult = await runGitApplyReverse(repoPath, diffText, true);
|
||||||
|
|
||||||
|
if (!checkResult.ok) {
|
||||||
|
throw new Error(summarizeGitApplyError(checkResult.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResult = await runGitApplyReverse(repoPath, diffText, false);
|
||||||
|
|
||||||
|
if (!applyResult.ok) {
|
||||||
|
throw new Error(summarizeGitApplyError(applyResult.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rolledBack: true,
|
||||||
|
requestId: normalizedRequestId,
|
||||||
|
sessionId: String(row.session_id ?? normalizedSessionId ?? ''),
|
||||||
|
diffBlockCount: diffBlocks.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -335,6 +335,32 @@ class ChatRuntimeService {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setArchivedJobTerminalStatus(requestId: string, terminalStatus: ChatRuntimeTerminalStatus, logLine?: string | null) {
|
||||||
|
const current = this.archivedJobs.get(requestId);
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLogs = [...current.logs];
|
||||||
|
const normalizedLogLine = normalizeLogLine(String(logLine ?? ''));
|
||||||
|
|
||||||
|
if (normalizedLogLine) {
|
||||||
|
nextLogs.push(normalizedLogLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: RuntimeJobRecord = {
|
||||||
|
...current,
|
||||||
|
terminalStatus,
|
||||||
|
lastUpdatedAt: nowIso(),
|
||||||
|
logs: nextLogs.slice(-MAX_LOG_LINES),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.archivedJobs.set(requestId, next);
|
||||||
|
this.emit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
if (
|
if (
|
||||||
this.runningJobs.size === 0 &&
|
this.runningJobs.size === 0 &&
|
||||||
|
|||||||
@@ -155,6 +155,53 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources',
|
|||||||
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
|
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rewriteCodexOutputWithChatResources keeps diff paths intact while rewriting prose file paths', async () => {
|
||||||
|
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||||
|
const sourcePath = path.join(repoPath, 'src', 'a.ts');
|
||||||
|
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||||
|
await writeFile(sourcePath, 'export const value = 1;\n', 'utf8');
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
'변경 파일: src/a.ts',
|
||||||
|
'',
|
||||||
|
'```diff',
|
||||||
|
'diff --git a/src/a.ts b/src/a.ts',
|
||||||
|
'--- a/src/a.ts',
|
||||||
|
'+++ b/src/a.ts',
|
||||||
|
'@@',
|
||||||
|
'-export const value = 1;',
|
||||||
|
'+export const value = 2;',
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||||
|
const savedDiffPath = path.join(
|
||||||
|
repoPath,
|
||||||
|
'public',
|
||||||
|
'.codex_chat',
|
||||||
|
'chat-room',
|
||||||
|
'resource',
|
||||||
|
'_generated',
|
||||||
|
'response.diff',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(rewritten, /변경 파일: \/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/a\.ts/);
|
||||||
|
assert.match(rewritten, /diff --git a\/src\/a\.ts b\/src\/a\.ts/);
|
||||||
|
assert.doesNotMatch(rewritten, /diff --git a\/api\/chat\/resources\//);
|
||||||
|
assert.equal(
|
||||||
|
await readFile(savedDiffPath, 'utf8'),
|
||||||
|
[
|
||||||
|
'diff --git a/src/a.ts b/src/a.ts',
|
||||||
|
'--- a/src/a.ts',
|
||||||
|
'+++ b/src/a.ts',
|
||||||
|
'@@',
|
||||||
|
'-export const value = 1;',
|
||||||
|
'+export const value = 2;',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => {
|
test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => {
|
||||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||||
const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx');
|
const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx');
|
||||||
|
|||||||
@@ -1205,6 +1205,24 @@ export function extractDiffCodeBlocks(output: string) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function protectDiffCodeBlocks(output: string) {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => {
|
||||||
|
const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`;
|
||||||
|
blocks.push(match);
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { text, blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDiffCodeBlocks(output: string, blocks: string[]) {
|
||||||
|
return blocks.reduce(
|
||||||
|
(current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block),
|
||||||
|
String(output ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
|
async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
|
||||||
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
|
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
|
||||||
|
|
||||||
@@ -1323,6 +1341,7 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
|
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
|
||||||
|
const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output);
|
||||||
const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
|
const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
|
||||||
const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
|
const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
|
||||||
const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
|
const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
|
||||||
@@ -1330,39 +1349,36 @@ export async function rewriteCodexOutputWithChatResources(output: string, repoPa
|
|||||||
`${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
|
`${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
|
||||||
'g',
|
'g',
|
||||||
);
|
);
|
||||||
const matches = [...output.matchAll(candidatePattern)];
|
const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)];
|
||||||
|
let rewrittenOutput = outputWithoutDiffBlocks;
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const replacementMap = new Map<string, string>();
|
||||||
|
|
||||||
if (matches.length === 0) {
|
for (const match of matches) {
|
||||||
return output;
|
const rawCandidate = match[0]?.trim();
|
||||||
}
|
|
||||||
|
|
||||||
const replacementMap = new Map<string, string>();
|
if (!rawCandidate || replacementMap.has(rawCandidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (const match of matches) {
|
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
|
||||||
const rawCandidate = match[0]?.trim();
|
|
||||||
|
|
||||||
if (!rawCandidate || replacementMap.has(rawCandidate)) {
|
if (stagedUrl) {
|
||||||
continue;
|
replacementMap.set(rawCandidate, stagedUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
|
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
|
||||||
|
|
||||||
if (stagedUrl) {
|
for (const [sourcePath, publicUrl] of replacements) {
|
||||||
replacementMap.set(rawCandidate, stagedUrl);
|
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rewrittenOutput = output;
|
|
||||||
|
|
||||||
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
|
|
||||||
|
|
||||||
for (const [sourcePath, publicUrl] of replacements) {
|
|
||||||
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput);
|
rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput);
|
||||||
|
rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);
|
||||||
|
|
||||||
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, rewrittenOutput);
|
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
|
||||||
return appendDiffResourceLinks(rewrittenOutput, diffUrls);
|
return appendDiffResourceLinks(rewrittenOutput, diffUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1648,6 +1664,7 @@ async function runAgenticCodexReply(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
onProgress?: (text: string) => void,
|
onProgress?: (text: string) => void,
|
||||||
onActivity?: (line: string) => void,
|
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 = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
|
||||||
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
||||||
@@ -1670,8 +1687,21 @@ async function runAgenticCodexReply(
|
|||||||
let lastProgressText = '';
|
let lastProgressText = '';
|
||||||
let completedAgentMessage = '';
|
let completedAgentMessage = '';
|
||||||
let hasIncrementalDelta = false;
|
let hasIncrementalDelta = false;
|
||||||
|
const throwIfCancelled = async () => {
|
||||||
|
if (!isCancellationRequested?.()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cancelRunnerCodexExecution(requestId).catch(() => false);
|
||||||
|
throw new Error('CHAT_RUNTIME_CANCELLED');
|
||||||
|
};
|
||||||
|
|
||||||
|
await throwIfCancelled();
|
||||||
activeChatProcessRegistry.set(requestId, {
|
activeChatProcessRegistry.set(requestId, {
|
||||||
cancel: () => cancelRunnerCodexExecution(requestId),
|
cancel: async () => {
|
||||||
|
const cancelled = await cancelRunnerCodexExecution(requestId);
|
||||||
|
return cancelled || isCancellationRequested?.() === true;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>(async (resolve, reject) => {
|
await new Promise<void>(async (resolve, reject) => {
|
||||||
@@ -1688,6 +1718,7 @@ async function runAgenticCodexReply(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await throwIfCancelled();
|
||||||
const response = await requestCommandRunner('/api/codex-live/execute', {
|
const response = await requestCommandRunner('/api/codex-live/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1705,6 +1736,8 @@ async function runAgenticCodexReply(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await throwIfCancelled();
|
||||||
|
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
|
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
|
||||||
return;
|
return;
|
||||||
@@ -1792,6 +1825,7 @@ async function runAgenticCodexReply(
|
|||||||
};
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
await throwIfCancelled();
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
@@ -2130,8 +2164,17 @@ async function buildCodexReply(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
onProgress?: (text: string) => void,
|
onProgress?: (text: string) => void,
|
||||||
onActivity?: (line: string) => void,
|
onActivity?: (line: string) => void,
|
||||||
|
isCancellationRequested?: () => boolean,
|
||||||
) {
|
) {
|
||||||
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
|
return runAgenticCodexReply(
|
||||||
|
context,
|
||||||
|
input,
|
||||||
|
sessionId,
|
||||||
|
requestId,
|
||||||
|
onProgress,
|
||||||
|
onActivity,
|
||||||
|
isCancellationRequested,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
@@ -2364,6 +2407,25 @@ export class ChatService {
|
|||||||
|
|
||||||
private async cancelRuntimeJob(requestId: string) {
|
private async cancelRuntimeJob(requestId: string) {
|
||||||
const execution = activeChatProcessRegistry.get(requestId);
|
const execution = activeChatProcessRegistry.get(requestId);
|
||||||
|
const detail = chatRuntimeService.getJobDetail(requestId);
|
||||||
|
|
||||||
|
if (!execution && detail.item && detail.terminalStatus == null) {
|
||||||
|
chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.');
|
||||||
|
this.cancelledRequestIds.add(requestId);
|
||||||
|
const session = this.findSessionByRequestId(requestId);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
void upsertChatConversationRequest(session.sessionId, {
|
||||||
|
requestId,
|
||||||
|
status: 'cancelled',
|
||||||
|
statusMessage: '사용자 요청으로 실행 취소를 대기합니다.',
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
this.logger.warn(error, 'failed to persist pending chat request cancellation state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!execution) {
|
if (!execution) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2384,7 +2446,14 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await execution.cancel();
|
const cancelled = await execution.cancel();
|
||||||
|
|
||||||
|
if (!cancelled && this.cancelledRequestIds.has(requestId)) {
|
||||||
|
chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cancelled;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(error, 'failed to cancel chat runtime job');
|
this.logger.warn(error, 'failed to cancel chat runtime job');
|
||||||
chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
|
chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
|
||||||
@@ -3221,6 +3290,7 @@ export class ChatService {
|
|||||||
(activityLine) => {
|
(activityLine) => {
|
||||||
appendActivityLine(activityLine);
|
appendActivityLine(activityLine);
|
||||||
},
|
},
|
||||||
|
() => this.cancelledRequestIds.has(request.requestId),
|
||||||
);
|
);
|
||||||
chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
|
chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
|
||||||
appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');
|
appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ test('listServerCommands uses app as the default test restart service', async ()
|
|||||||
assert.equal(testCommand.serviceName, 'app');
|
assert.equal(testCommand.serviceName, 'app');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('listServerCommands exposes prod restart command', async () => {
|
||||||
|
const commands = await listServerCommands();
|
||||||
|
const prodCommand = commands.find((item) => item.key === 'prod');
|
||||||
|
|
||||||
|
assert.ok(prodCommand);
|
||||||
|
assert.equal(prodCommand.serviceName, 'prod-app');
|
||||||
|
assert.match(prodCommand.commandScript, /\/etc\/commands\/server-command\/restart-prod\.sh$/);
|
||||||
|
});
|
||||||
|
|
||||||
test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => {
|
test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => {
|
||||||
const commands = await listServerCommands();
|
const commands = await listServerCommands();
|
||||||
const testCommand = commands.find((item) => item.key === 'test');
|
const testCommand = commands.find((item) => item.key === 'test');
|
||||||
@@ -45,10 +54,11 @@ test('listServerCommands resolves restart script from main project when project
|
|||||||
assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh');
|
assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test and release restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
|
test('test, release and prod restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
|
||||||
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||||
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
|
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
|
||||||
const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8');
|
const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8');
|
||||||
|
const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');
|
||||||
const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8');
|
const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8');
|
||||||
const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');
|
const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');
|
||||||
|
|
||||||
@@ -58,10 +68,22 @@ test('test and release restart scripts fall back to Docker socket when docker CL
|
|||||||
assert.match(testScript, /restart-via-docker-socket\.mjs/);
|
assert.match(testScript, /restart-via-docker-socket\.mjs/);
|
||||||
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
|
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
|
||||||
assert.match(relScript, /command -v docker >/);
|
assert.match(relScript, /command -v docker >/);
|
||||||
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
assert.match(
|
||||||
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
relScript,
|
||||||
|
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
|
||||||
|
);
|
||||||
assert.match(relScript, /restart-via-docker-socket\.mjs/);
|
assert.match(relScript, /restart-via-docker-socket\.mjs/);
|
||||||
assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/);
|
assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/);
|
||||||
|
assert.match(prodScript, /command -v docker >/);
|
||||||
|
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
|
||||||
|
assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_REMOTE="\$\{SERVER_COMMAND_PROD_GIT_REMOTE:-origin\}"/);
|
||||||
|
assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_BRANCH="\$\{SERVER_COMMAND_PROD_GIT_BRANCH:-main\}"/);
|
||||||
|
assert.match(
|
||||||
|
prodScript,
|
||||||
|
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
|
||||||
|
);
|
||||||
|
assert.match(prodScript, /restart-via-docker-socket\.mjs/);
|
||||||
|
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
|
||||||
assert.match(
|
assert.match(
|
||||||
workServerScript,
|
workServerScript,
|
||||||
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
||||||
@@ -69,6 +91,15 @@ test('test and release restart scripts fall back to Docker socket when docker CL
|
|||||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prod restart script pulls the configured remote main branch before restart', () => {
|
||||||
|
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||||
|
const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');
|
||||||
|
|
||||||
|
assert.match(prodScript, /CURRENT_BRANCH=\$\(git rev-parse --abbrev-ref HEAD 2>\/dev\/null \|\| true\)/);
|
||||||
|
assert.match(prodScript, /git -c "credential\.helper=store --file=\$CREDENTIAL_STORE" pull --ff-only/);
|
||||||
|
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
|
||||||
|
});
|
||||||
|
|
||||||
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
|
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
|
||||||
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export const serverCommandKeys = ['test', 'rel', 'work-server', 'command-runner'] as const;
|
export const serverCommandKeys = ['test', 'rel', 'prod', 'work-server', 'command-runner'] as const;
|
||||||
|
|
||||||
export type ServerCommandKey = (typeof serverCommandKeys)[number];
|
export type ServerCommandKey = (typeof serverCommandKeys)[number];
|
||||||
|
|
||||||
@@ -135,6 +135,26 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
|
|||||||
'/tmp/ai-code-test-app-dist/assets',
|
'/tmp/ai-code-test-app-dist/assets',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
|
||||||
|
const allowLocal = options?.allowLocal ?? false;
|
||||||
|
let latestBuiltAt: string | null = null;
|
||||||
|
|
||||||
|
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
|
||||||
|
const candidates = [
|
||||||
|
allowLocal ? await readLocalBuildTimestamp(targetPath) : null,
|
||||||
|
await readContainerBuildTimestamp(definition, targetPath),
|
||||||
|
].filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!latestBuiltAt || candidate > latestBuiltAt) {
|
||||||
|
latestBuiltAt = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestBuiltAt;
|
||||||
|
}
|
||||||
|
|
||||||
async function readLocalBuildTimestamp(targetPath: string) {
|
async function readLocalBuildTimestamp(targetPath: string) {
|
||||||
try {
|
try {
|
||||||
const targetStat = await stat(targetPath);
|
const targetStat = await stat(targetPath);
|
||||||
@@ -587,6 +607,26 @@ function getServerDefinitions(): ServerDefinition[] {
|
|||||||
},
|
},
|
||||||
restartStrategy: 'wait',
|
restartStrategy: 'wait',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'prod',
|
||||||
|
label: 'PROD',
|
||||||
|
summary: '프로덕션 앱 컨테이너',
|
||||||
|
environment: 'production',
|
||||||
|
publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
|
||||||
|
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
|
||||||
|
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||||
|
serviceName: env.SERVER_COMMAND_PROD_SERVICE,
|
||||||
|
containerName: 'ai-code-app-prod',
|
||||||
|
commandScript: resolveCommandScriptPath('restart-prod.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||||
|
commandWorkingDirectory: mainProjectRoot,
|
||||||
|
commandEnvironment: {
|
||||||
|
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||||
|
SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||||
|
SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_PROD_SERVICE,
|
||||||
|
SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod',
|
||||||
|
},
|
||||||
|
restartStrategy: 'deferred',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'work-server',
|
key: 'work-server',
|
||||||
label: 'WORK-SERVER',
|
label: 'WORK-SERVER',
|
||||||
@@ -1223,21 +1263,39 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function inspectAppContainerBuild(definition: ServerDefinition): Promise<BuildInspectionResult | null> {
|
async function inspectAppContainerBuild(definition: ServerDefinition): Promise<BuildInspectionResult | null> {
|
||||||
if (definition.key !== 'test') {
|
if (definition.key !== 'test' && definition.key !== 'prod') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (definition.key === 'prod') {
|
||||||
|
const testDefinition = getServerDefinition('test');
|
||||||
|
const testBuiltAt = await readAppBuildTimestamp(testDefinition, { allowLocal: true });
|
||||||
|
const prodBuiltAt = await readAppBuildTimestamp(definition);
|
||||||
|
const updateAvailable = Boolean(testBuiltAt && (!prodBuiltAt || prodBuiltAt < testBuiltAt));
|
||||||
|
|
||||||
|
return {
|
||||||
|
runningVersion: null,
|
||||||
|
runningBuiltAt: prodBuiltAt,
|
||||||
|
latestVersion: null,
|
||||||
|
latestBuiltAt: testBuiltAt,
|
||||||
|
latestSourceChangeAt: null,
|
||||||
|
latestSourceChangePath: null,
|
||||||
|
buildRequired: false,
|
||||||
|
updateAvailable,
|
||||||
|
updateSummary:
|
||||||
|
updateAvailable && testBuiltAt
|
||||||
|
? `운영 반영 시각이 TEST보다 이전입니다. TEST ${testBuiltAt}, 운영 ${prodBuiltAt ?? '미확인'}`
|
||||||
|
: prodBuiltAt
|
||||||
|
? `운영 반영 기준: ${prodBuiltAt}`
|
||||||
|
: '운영 빌드 시각을 읽지 못했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const latestSourceChange = await readLatestAppSourceChange();
|
const latestSourceChange = await readLatestAppSourceChange();
|
||||||
const latestSourceChangedAt = latestSourceChange?.changedAt ?? null;
|
const latestSourceChangedAt = latestSourceChange?.changedAt ?? null;
|
||||||
|
const builtAt = await readAppBuildTimestamp(definition, { allowLocal: true });
|
||||||
|
|
||||||
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
|
if (builtAt) {
|
||||||
const builtAt =
|
|
||||||
(await readLocalBuildTimestamp(targetPath)) ?? (await readContainerBuildTimestamp(definition, targetPath));
|
|
||||||
|
|
||||||
if (!builtAt) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runningVersion: null,
|
runningVersion: null,
|
||||||
runningBuiltAt: builtAt,
|
runningBuiltAt: builtAt,
|
||||||
|
|||||||
@@ -28,20 +28,6 @@ const runnerLogTrimIntervalMs = Math.max(
|
|||||||
15_000,
|
15_000,
|
||||||
Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'),
|
Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'),
|
||||||
);
|
);
|
||||||
const cpuWatchdogEnabled = process.env.SERVER_COMMAND_CPU_WATCHDOG_ENABLED?.trim() !== 'false';
|
|
||||||
const cpuWatchdogIntervalMs = Math.max(15_000, Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS?.trim() || '60000'));
|
|
||||||
const cpuWatchdogThresholdPercent = Math.max(
|
|
||||||
10,
|
|
||||||
Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT?.trim() || '120'),
|
|
||||||
);
|
|
||||||
const cpuWatchdogConsecutiveLimit = Math.max(
|
|
||||||
2,
|
|
||||||
Math.round(Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT?.trim() || '8')),
|
|
||||||
);
|
|
||||||
const cpuWatchdogCooldownMs = Math.max(
|
|
||||||
60_000,
|
|
||||||
Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS?.trim() || '1200000'),
|
|
||||||
);
|
|
||||||
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
||||||
const CODEX_HOME_RUNTIME_PATHS = [
|
const CODEX_HOME_RUNTIME_PATHS = [
|
||||||
'auth.json',
|
'auth.json',
|
||||||
@@ -78,6 +64,18 @@ const commandDefinitions = {
|
|||||||
},
|
},
|
||||||
restartStrategy: 'deferred',
|
restartStrategy: 'deferred',
|
||||||
},
|
},
|
||||||
|
prod: {
|
||||||
|
label: 'PROD',
|
||||||
|
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-prod.sh'),
|
||||||
|
workingDirectory: projectRoot,
|
||||||
|
env: {
|
||||||
|
MAIN_PROJECT_ROOT: projectRoot,
|
||||||
|
SERVER_COMMAND_COMPOSE_FILE: path.join(projectRoot, 'docker-compose.yml'),
|
||||||
|
SERVER_COMMAND_SERVICE: process.env.SERVER_COMMAND_PROD_SERVICE?.trim() || 'prod-app',
|
||||||
|
SERVER_COMMAND_CONTAINER_NAME: process.env.SERVER_COMMAND_PROD_CONTAINER_NAME?.trim() || 'ai-code-app-prod',
|
||||||
|
},
|
||||||
|
restartStrategy: 'deferred',
|
||||||
|
},
|
||||||
'work-server': {
|
'work-server': {
|
||||||
label: 'WORK-SERVER',
|
label: 'WORK-SERVER',
|
||||||
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-work-server.sh'),
|
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-work-server.sh'),
|
||||||
@@ -133,46 +131,6 @@ function translateWorkspacePathToHost(inputPath) {
|
|||||||
return normalizedInput;
|
return normalizedInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cpuWatchdogTargets = [
|
|
||||||
{
|
|
||||||
name: 'test-app',
|
|
||||||
containerName: 'ai-code-app-app-1',
|
|
||||||
restartMode: 'command',
|
|
||||||
restartKey: 'test',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'release-app',
|
|
||||||
containerName: 'ai-code-app-release',
|
|
||||||
restartMode: 'command',
|
|
||||||
restartKey: 'rel',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'prod-app',
|
|
||||||
containerName: 'ai-code-app-prod',
|
|
||||||
restartMode: 'docker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'work-server',
|
|
||||||
containerName: 'work-server',
|
|
||||||
restartMode: 'command',
|
|
||||||
restartKey: 'work-server',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const cpuWatchdogState = new Map(
|
|
||||||
cpuWatchdogTargets.map((target) => [
|
|
||||||
target.containerName,
|
|
||||||
{
|
|
||||||
lastCpuPercent: null,
|
|
||||||
breachCount: 0,
|
|
||||||
lastSampleAt: null,
|
|
||||||
lastRestartAt: null,
|
|
||||||
lastRestartReason: null,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
let cpuWatchdogBusy = false;
|
|
||||||
|
|
||||||
function trimOutput(value, maxLength = 400) {
|
function trimOutput(value, maxLength = 400) {
|
||||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -223,26 +181,6 @@ async function writeHeartbeat() {
|
|||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
startedAt,
|
startedAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
cpuWatchdog: {
|
|
||||||
enabled: cpuWatchdogEnabled,
|
|
||||||
intervalMs: cpuWatchdogIntervalMs,
|
|
||||||
thresholdPercent: cpuWatchdogThresholdPercent,
|
|
||||||
consecutiveLimit: cpuWatchdogConsecutiveLimit,
|
|
||||||
cooldownMs: cpuWatchdogCooldownMs,
|
|
||||||
targets: cpuWatchdogTargets.map((target) => {
|
|
||||||
const state = cpuWatchdogState.get(target.containerName);
|
|
||||||
return {
|
|
||||||
name: target.name,
|
|
||||||
containerName: target.containerName,
|
|
||||||
restartMode: target.restartMode,
|
|
||||||
lastCpuPercent: state?.lastCpuPercent ?? null,
|
|
||||||
breachCount: state?.breachCount ?? 0,
|
|
||||||
lastSampleAt: state?.lastSampleAt ?? null,
|
|
||||||
lastRestartAt: state?.lastRestartAt ?? null,
|
|
||||||
lastRestartReason: state?.lastRestartReason ?? null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -251,127 +189,6 @@ async function writeHeartbeat() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCpuPercentage(value) {
|
|
||||||
const numeric = Number(String(value ?? '').replace('%', '').trim());
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartContainerByDocker(containerName) {
|
|
||||||
await execFileAsync('docker', ['restart', containerName], {
|
|
||||||
cwd: projectRoot,
|
|
||||||
timeout: 30_000,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sampleCpuWatchdog() {
|
|
||||||
if (!cpuWatchdogEnabled || cpuWatchdogBusy || cpuWatchdogTargets.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuWatchdogBusy = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync(
|
|
||||||
'docker',
|
|
||||||
[
|
|
||||||
'stats',
|
|
||||||
'--no-stream',
|
|
||||||
'--format',
|
|
||||||
'{{json .}}',
|
|
||||||
...cpuWatchdogTargets.map((target) => target.containerName),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: projectRoot,
|
|
||||||
timeout: 15_000,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const sampledContainers = new Set();
|
|
||||||
|
|
||||||
for (const line of stdout.split('\n')) {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
|
|
||||||
if (!trimmedLine) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(trimmedLine);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerName = String(parsed.Name ?? '').trim();
|
|
||||||
const cpuPercent = parseCpuPercentage(parsed.CPUPerc);
|
|
||||||
const target = cpuWatchdogTargets.find((entry) => entry.containerName === containerName);
|
|
||||||
const state = cpuWatchdogState.get(containerName);
|
|
||||||
|
|
||||||
if (!target || !state) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
sampledContainers.add(containerName);
|
|
||||||
state.lastCpuPercent = cpuPercent;
|
|
||||||
state.lastSampleAt = now;
|
|
||||||
state.breachCount = cpuPercent != null && cpuPercent >= cpuWatchdogThresholdPercent ? state.breachCount + 1 : 0;
|
|
||||||
|
|
||||||
const cooldownPassed =
|
|
||||||
!state.lastRestartAt || Date.now() - new Date(state.lastRestartAt).getTime() >= cpuWatchdogCooldownMs;
|
|
||||||
|
|
||||||
if (state.breachCount < cpuWatchdogConsecutiveLimit || !cooldownPassed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const restartReason = `cpu ${cpuPercent?.toFixed(1) ?? '?'}% sustained for ${
|
|
||||||
state.breachCount
|
|
||||||
} samples`;
|
|
||||||
|
|
||||||
process.stdout.write(
|
|
||||||
`[cpu-watchdog] restarting ${target.containerName} because ${restartReason} (threshold ${cpuWatchdogThresholdPercent}%)\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (target.restartMode === 'command' && target.restartKey) {
|
|
||||||
await runRestartCommand(target.restartKey);
|
|
||||||
} else {
|
|
||||||
await restartContainerByDocker(target.containerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.breachCount = 0;
|
|
||||||
state.lastRestartAt = new Date().toISOString();
|
|
||||||
state.lastRestartReason = restartReason;
|
|
||||||
await writeHeartbeat().catch(() => {
|
|
||||||
// noop
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const target of cpuWatchdogTargets) {
|
|
||||||
if (sampledContainers.has(target.containerName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = cpuWatchdogState.get(target.containerName);
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.lastCpuPercent = null;
|
|
||||||
state.lastSampleAt = now;
|
|
||||||
state.breachCount = 0;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
process.stdout.write(
|
|
||||||
`[cpu-watchdog] sample failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cpuWatchdogBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void trimRunnerLogIfNeeded();
|
void trimRunnerLogIfNeeded();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
void trimRunnerLogIfNeeded();
|
void trimRunnerLogIfNeeded();
|
||||||
@@ -1030,12 +847,5 @@ server.listen(port, host, () => {
|
|||||||
});
|
});
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
heartbeatTimer.unref();
|
heartbeatTimer.unref();
|
||||||
if (cpuWatchdogEnabled) {
|
|
||||||
const cpuWatchdogTimer = setInterval(() => {
|
|
||||||
void sampleCpuWatchdog();
|
|
||||||
}, cpuWatchdogIntervalMs);
|
|
||||||
cpuWatchdogTimer.unref();
|
|
||||||
void sampleCpuWatchdog();
|
|
||||||
}
|
|
||||||
process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`);
|
process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1784,9 +1784,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-rich--markdown {
|
.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;
|
padding: 4px 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__preview-rich--markdown .markdown-preview {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-message__preview-image,
|
.app-chat-message__preview-image,
|
||||||
.app-chat-message__preview-video,
|
.app-chat-message__preview-video,
|
||||||
.app-chat-message__preview-frame {
|
.app-chat-message__preview-frame {
|
||||||
@@ -2175,6 +2186,7 @@
|
|||||||
.app-chat-panel__preview-stage > * {
|
.app-chat-panel__preview-stage > * {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-loading {
|
.app-chat-panel__preview-loading {
|
||||||
@@ -2366,12 +2378,32 @@
|
|||||||
padding: 0 20px 12px;
|
padding: 0 20px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-modal-footer {
|
.app-chat-panel__preview-modal-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
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 .app-chat-panel__preview-rich,
|
||||||
@@ -2387,6 +2419,12 @@
|
|||||||
max-width: none;
|
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 .previewer-ui__editor,
|
||||||
.app-chat-panel__preview-modal .codex-diff-previewer__diff-section,
|
.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-image,
|
||||||
@@ -2403,8 +2441,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.app-chat-panel__preview-modal-footer {
|
.app-chat-panel__preview-modal-title {
|
||||||
justify-content: flex-end;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||||
|
import type { InputRef } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { useAppConfig } from './appConfig';
|
import { useAppConfig } from './appConfig';
|
||||||
@@ -192,6 +193,267 @@ function createConversationPreviewText(text: string) {
|
|||||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreviewTextMatch = {
|
||||||
|
node: Text;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewMatchAnchor = {
|
||||||
|
match: PreviewTextMatch;
|
||||||
|
target: HTMLElement;
|
||||||
|
offsetTop: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectPreviewTextMatches(root: HTMLElement, keyword: string) {
|
||||||
|
const normalizedKeyword = keyword.trim().toLocaleLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedKeyword || typeof document === 'undefined') {
|
||||||
|
return [] as PreviewTextMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
const matches: PreviewTextMatch[] = [];
|
||||||
|
let currentNode = walker.nextNode();
|
||||||
|
|
||||||
|
while (currentNode) {
|
||||||
|
if (currentNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textNode = currentNode as Text;
|
||||||
|
const textContent = textNode.textContent ?? '';
|
||||||
|
const normalizedText = textContent.toLocaleLowerCase();
|
||||||
|
|
||||||
|
if (normalizedText) {
|
||||||
|
let fromIndex = 0;
|
||||||
|
|
||||||
|
while (fromIndex < normalizedText.length) {
|
||||||
|
const matchIndex = normalizedText.indexOf(normalizedKeyword, fromIndex);
|
||||||
|
|
||||||
|
if (matchIndex < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.push({
|
||||||
|
node: textNode,
|
||||||
|
start: matchIndex,
|
||||||
|
end: matchIndex + normalizedKeyword.length,
|
||||||
|
});
|
||||||
|
fromIndex = matchIndex + Math.max(1, normalizedKeyword.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = walker.nextNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusPreviewTextMatch(match: PreviewTextMatch) {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(match.node, match.start);
|
||||||
|
range.setEnd(match.node, match.end);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewMatchTargetElement(match: PreviewTextMatch) {
|
||||||
|
return (
|
||||||
|
match.node.parentElement?.closest<HTMLElement>(
|
||||||
|
'.previewer-ui__line, .markdown-preview__block, .codex-diff-previewer__diff-line, p, li, h1, h2, h3, pre, code, div',
|
||||||
|
) ?? match.node.parentElement
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewScrollContainer(root: HTMLElement) {
|
||||||
|
const preferredSelectors = [
|
||||||
|
'.app-chat-panel__preview-rich--markdown',
|
||||||
|
'.app-chat-panel__preview-rich',
|
||||||
|
'.codex-diff-previewer__diff-body',
|
||||||
|
'.previewer-ui__editor-body',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (root.scrollHeight > root.clientHeight + 4) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const selector of preferredSelectors) {
|
||||||
|
const candidate = root.querySelector<HTMLElement>(selector);
|
||||||
|
|
||||||
|
if (candidate && candidate.scrollHeight > candidate.clientHeight + 4) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) {
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
return targetRect.top - containerRect.top + container.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewMatchOffsetWithinContainer(match: PreviewTextMatch, container: HTMLElement) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(match.node, match.start);
|
||||||
|
range.setEnd(match.node, match.end);
|
||||||
|
|
||||||
|
const rects = range.getClientRects();
|
||||||
|
const firstRect = rects.item(0);
|
||||||
|
|
||||||
|
if (!firstRect) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
return firstRect.top - containerRect.top + container.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewMatchHeight(match: PreviewTextMatch) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(match.node, match.start);
|
||||||
|
range.setEnd(match.node, match.end);
|
||||||
|
|
||||||
|
const rects = range.getClientRects();
|
||||||
|
const firstRect = rects.item(0);
|
||||||
|
|
||||||
|
if (!firstRect) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstRect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewMatchAnchors(matches: PreviewTextMatch[], container: HTMLElement) {
|
||||||
|
const anchors: PreviewMatchAnchor[] = [];
|
||||||
|
|
||||||
|
matches.forEach((match) => {
|
||||||
|
const target = resolvePreviewMatchTargetElement(match);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
anchors.push({
|
||||||
|
match,
|
||||||
|
target,
|
||||||
|
offsetTop: getPreviewMatchOffsetWithinContainer(match, container) ?? getElementOffsetWithinContainer(target, container),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return anchors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewSearchAnchorIndex(
|
||||||
|
anchors: PreviewMatchAnchor[],
|
||||||
|
container: HTMLElement,
|
||||||
|
direction: 'forward' | 'backward',
|
||||||
|
) {
|
||||||
|
if (anchors.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportAnchor = container.scrollTop + container.clientHeight / 2;
|
||||||
|
const threshold = 4;
|
||||||
|
|
||||||
|
if (direction === 'backward') {
|
||||||
|
for (let index = anchors.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (anchors[index].offsetTop < viewportAnchor - threshold) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return anchors.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < anchors.length; index += 1) {
|
||||||
|
if (anchors[index].offsetTop > viewportAnchor + threshold) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewSearchResultIndex(args: {
|
||||||
|
anchors: PreviewMatchAnchor[];
|
||||||
|
container: HTMLElement;
|
||||||
|
direction: 'forward' | 'backward';
|
||||||
|
currentIndex: number;
|
||||||
|
}) {
|
||||||
|
const { anchors, container, direction, currentIndex } = args;
|
||||||
|
|
||||||
|
if (anchors.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex >= 0 && currentIndex < anchors.length) {
|
||||||
|
if (direction === 'backward') {
|
||||||
|
return currentIndex === 0 ? anchors.length - 1 : currentIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentIndex === anchors.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePreviewSearchAnchorIndex(anchors, container, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusPreviewTextMatchInContainer(match: PreviewTextMatch, container: HTMLElement) {
|
||||||
|
if (!focusPreviewTextMatch(match)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchTop = getPreviewMatchOffsetWithinContainer(match, container);
|
||||||
|
|
||||||
|
if (matchTop != null) {
|
||||||
|
const matchHeight = getPreviewMatchHeight(match) ?? 0;
|
||||||
|
const nextScrollTop = Math.max(0, matchTop - container.clientHeight / 2 + matchHeight / 2);
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
top: nextScrollTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolvePreviewMatchTargetElement(match);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTop = getElementOffsetWithinContainer(target, container);
|
||||||
|
const nextScrollTop = Math.max(0, targetTop - container.clientHeight / 2 + target.clientHeight / 2);
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
top: nextScrollTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConversationListPreviewText(preview: string) {
|
function resolveConversationListPreviewText(preview: string) {
|
||||||
const normalized = createConversationPreviewText(preview);
|
const normalized = createConversationPreviewText(preview);
|
||||||
|
|
||||||
@@ -646,12 +908,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const [isMaximized, setIsMaximized] = useState(false);
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
|
const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
|
||||||
const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false);
|
const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false);
|
||||||
|
const [isPreviewFindOpen, setIsPreviewFindOpen] = useState(false);
|
||||||
|
const [previewFindQuery, setPreviewFindQuery] = useState('');
|
||||||
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
|
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
|
||||||
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
|
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const composerRef = useRef<TextAreaRef | null>(null);
|
const composerRef = useRef<TextAreaRef | null>(null);
|
||||||
|
const previewFindInputRef = useRef<InputRef | null>(null);
|
||||||
|
const previewSearchRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
||||||
|
const previewSearchMatchIndexRef = useRef(-1);
|
||||||
|
const previewSearchKeyRef = useRef('');
|
||||||
const titleClusterRef = useRef<HTMLDivElement | null>(null);
|
const titleClusterRef = useRef<HTMLDivElement | null>(null);
|
||||||
const copyFeedbackTimerRef = useRef<number | null>(null);
|
const copyFeedbackTimerRef = useRef<number | null>(null);
|
||||||
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
|
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
|
||||||
@@ -661,6 +930,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
|
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||||
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
||||||
const handledRequestedSessionIdRef = useRef('');
|
const handledRequestedSessionIdRef = useRef('');
|
||||||
|
const isClosingConversationRef = useRef(false);
|
||||||
const lastChatTypeSessionIdRef = useRef('');
|
const lastChatTypeSessionIdRef = useRef('');
|
||||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||||
@@ -746,6 +1016,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
clientId: null,
|
clientId: null,
|
||||||
title: '새 대화',
|
title: '새 대화',
|
||||||
chatTypeId: selectedChatType?.id ?? null,
|
chatTypeId: selectedChatType?.id ?? null,
|
||||||
|
lastChatTypeId: selectedChatType?.id ?? null,
|
||||||
contextLabel: selectedChatType?.name ?? null,
|
contextLabel: selectedChatType?.name ?? null,
|
||||||
contextDescription: selectedChatType?.description ?? null,
|
contextDescription: selectedChatType?.description ?? null,
|
||||||
notifyOffline: true,
|
notifyOffline: true,
|
||||||
@@ -771,6 +1042,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
sessionId,
|
sessionId,
|
||||||
title: '새 대화',
|
title: '새 대화',
|
||||||
chatTypeId: selectedChatType?.id ?? null,
|
chatTypeId: selectedChatType?.id ?? null,
|
||||||
|
lastChatTypeId: selectedChatType?.id ?? null,
|
||||||
contextLabel: selectedChatType?.name,
|
contextLabel: selectedChatType?.name,
|
||||||
contextDescription: selectedChatType?.description,
|
contextDescription: selectedChatType?.description,
|
||||||
notifyOffline: true,
|
notifyOffline: true,
|
||||||
@@ -1344,6 +1616,130 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
}, [activePreview, messageApi]);
|
}, [activePreview, messageApi]);
|
||||||
|
|
||||||
|
const canSearchActivePreview =
|
||||||
|
Boolean(activePreview) &&
|
||||||
|
!isPreviewLoading &&
|
||||||
|
!previewError.trim() &&
|
||||||
|
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
|
||||||
|
|
||||||
|
const resetActivePreviewSearchState = useCallback(() => {
|
||||||
|
previewSearchMatchesRef.current = [];
|
||||||
|
previewSearchMatchIndexRef.current = -1;
|
||||||
|
previewSearchKeyRef.current = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearActivePreviewSearchSelection = useCallback(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPreviewModalOpen) {
|
||||||
|
setIsPreviewFindOpen(false);
|
||||||
|
setPreviewFindQuery('');
|
||||||
|
resetActivePreviewSearchState();
|
||||||
|
clearActivePreviewSearchSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreviewFindOpen) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
previewFindInputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetActivePreviewSearchState();
|
||||||
|
clearActivePreviewSearchSelection();
|
||||||
|
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetActivePreviewSearchState();
|
||||||
|
}, [previewFindQuery, resetActivePreviewSearchState]);
|
||||||
|
|
||||||
|
const handlePreviewSearchRootPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isMobileViewport || !isPreviewFindOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputElement = previewFindInputRef.current?.input;
|
||||||
|
|
||||||
|
if (!inputElement || document.activeElement !== inputElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target instanceof Node && inputElement.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
inputElement.blur();
|
||||||
|
},
|
||||||
|
[isMobileViewport, isPreviewFindOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFindActivePreview = useCallback(
|
||||||
|
(direction: 'forward' | 'backward' = 'forward') => {
|
||||||
|
const keyword = previewFindQuery.trim();
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
void messageApi.info('찾을 단어를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = previewSearchRootRef.current;
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
void messageApi.error('미리보기 본문을 아직 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchKey = `${activePreview?.id ?? 'preview'}:${keyword.toLocaleLowerCase()}`;
|
||||||
|
const matches =
|
||||||
|
previewSearchKeyRef.current === searchKey
|
||||||
|
? previewSearchMatchesRef.current
|
||||||
|
: collectPreviewTextMatches(root, keyword);
|
||||||
|
|
||||||
|
if (previewSearchKeyRef.current !== searchKey) {
|
||||||
|
previewSearchKeyRef.current = searchKey;
|
||||||
|
previewSearchMatchesRef.current = matches;
|
||||||
|
previewSearchMatchIndexRef.current = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
void messageApi.info('일치하는 단어를 찾지 못했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollContainer = resolvePreviewScrollContainer(root);
|
||||||
|
const anchors = buildPreviewMatchAnchors(matches, scrollContainer);
|
||||||
|
const nextIndex = resolvePreviewSearchResultIndex({
|
||||||
|
anchors,
|
||||||
|
container: scrollContainer,
|
||||||
|
direction,
|
||||||
|
currentIndex: previewSearchMatchIndexRef.current,
|
||||||
|
});
|
||||||
|
const nextAnchor = nextIndex >= 0 ? anchors[nextIndex] : null;
|
||||||
|
|
||||||
|
if (!nextAnchor || !focusPreviewTextMatchInContainer(nextAnchor.match, scrollContainer)) {
|
||||||
|
void messageApi.error('검색 결과 위치로 이동하지 못했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewSearchMatchIndexRef.current = nextIndex;
|
||||||
|
},
|
||||||
|
[activePreview?.id, messageApi, previewFindQuery],
|
||||||
|
);
|
||||||
|
|
||||||
const markConversationReadLocally = (sessionId: string) => {
|
const markConversationReadLocally = (sessionId: string) => {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
@@ -1532,7 +1928,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}, [location.pathname, location.search, navigate]);
|
}, [location.pathname, location.search, navigate]);
|
||||||
|
|
||||||
const openConversationSession = (sessionId: string) => {
|
const openConversationSession = (sessionId: string) => {
|
||||||
|
isClosingConversationRef.current = false;
|
||||||
replaceChatSessionInUrl(sessionId);
|
replaceChatSessionInUrl(sessionId);
|
||||||
|
clearRequestedRuntimeLogInUrl();
|
||||||
|
setActiveView('chat');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
|
const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
|
||||||
const hasCachedMessages = cachedMessages.length > 0;
|
const hasCachedMessages = cachedMessages.length > 0;
|
||||||
@@ -1566,6 +1965,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
clientId: null,
|
clientId: null,
|
||||||
title: '대화 내용을 불러오는 중입니다.',
|
title: '대화 내용을 불러오는 중입니다.',
|
||||||
chatTypeId: null,
|
chatTypeId: null,
|
||||||
|
lastChatTypeId: null,
|
||||||
contextLabel: null,
|
contextLabel: null,
|
||||||
contextDescription: null,
|
contextDescription: null,
|
||||||
notifyOffline: true,
|
notifyOffline: true,
|
||||||
@@ -1842,7 +2242,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
|
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
if (hasSessionChanged) {
|
if (hasSessionChanged) {
|
||||||
const lastUsedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId);
|
const lastUsedChatTypeId =
|
||||||
|
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
|
||||||
|
|
||||||
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
|
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
|
||||||
if (selectedChatTypeId !== lastUsedChatTypeId) {
|
if (selectedChatTypeId !== lastUsedChatTypeId) {
|
||||||
@@ -1865,7 +2266,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
||||||
}, [activeSessionId, availableChatTypes, selectedChatTypeId]);
|
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSessionId || !selectedChatTypeId) {
|
if (!activeSessionId || !selectedChatTypeId) {
|
||||||
@@ -1873,7 +2274,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
|
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
|
||||||
}, [activeSessionId, selectedChatTypeId]);
|
setConversationItems((previous) =>
|
||||||
|
previous.map((item) =>
|
||||||
|
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
|
||||||
|
? { ...item, lastChatTypeId: selectedChatTypeId }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
|
||||||
|
|
||||||
|
if (currentLastChatTypeId === selectedChatTypeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void chatGateway.updateConversation(activeSessionId, {
|
||||||
|
lastChatTypeId: selectedChatTypeId,
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore background sync failures and keep local in-memory fallback.
|
||||||
|
});
|
||||||
|
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
||||||
@@ -1925,7 +2345,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestedSessionId && !requestedChatView) {
|
||||||
|
setActiveView('chat');
|
||||||
|
}
|
||||||
|
|
||||||
if (!requestedSessionId) {
|
if (!requestedSessionId) {
|
||||||
|
isClosingConversationRef.current = false;
|
||||||
handledRequestedSessionIdRef.current = '';
|
handledRequestedSessionIdRef.current = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1934,21 +2359,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) {
|
if (isClosingConversationRef.current && requestedSessionId === activeSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestedSessionId === activeSessionId &&
|
||||||
|
handledRequestedSessionIdRef.current === requestedSessionId &&
|
||||||
|
activeView === 'chat' &&
|
||||||
|
!isConversationPaneClosed &&
|
||||||
|
(!isMobileViewport || isMobileConversationView)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handledRequestedSessionIdRef.current = requestedSessionId;
|
handledRequestedSessionIdRef.current = requestedSessionId;
|
||||||
|
|
||||||
if (requestedSessionId === activeSessionId) {
|
if (requestedSessionId === activeSessionId) {
|
||||||
if (isMobileViewport && !isConversationPaneClosed) {
|
openConversationSession(requestedSessionId);
|
||||||
setIsMobileConversationView(true);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openConversationSession(requestedSessionId);
|
openConversationSession(requestedSessionId);
|
||||||
}, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]);
|
}, [
|
||||||
|
activeSessionId,
|
||||||
|
activeView,
|
||||||
|
conversationItems,
|
||||||
|
isConversationListLoading,
|
||||||
|
isConversationPaneClosed,
|
||||||
|
isMobileConversationView,
|
||||||
|
isMobileViewport,
|
||||||
|
location.pathname,
|
||||||
|
requestedChatView,
|
||||||
|
requestedSessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestedSessionId) {
|
if (requestedSessionId) {
|
||||||
@@ -2329,6 +2773,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
icon={<CloseOutlined />}
|
icon={<CloseOutlined />}
|
||||||
aria-label="대화창 닫기"
|
aria-label="대화창 닫기"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
isClosingConversationRef.current = true;
|
||||||
handledRequestedSessionIdRef.current = '';
|
handledRequestedSessionIdRef.current = '';
|
||||||
replaceChatSessionInUrl('');
|
replaceChatSessionInUrl('');
|
||||||
setIsConversationPaneClosed(true);
|
setIsConversationPaneClosed(true);
|
||||||
@@ -2594,17 +3039,30 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={isPreviewModalOpen && Boolean(activePreview)}
|
open={isPreviewModalOpen && Boolean(activePreview)}
|
||||||
title={activePreview ? `${activePreview.label} preview` : 'preview'}
|
title={
|
||||||
footer={
|
|
||||||
activePreview ? (
|
activePreview ? (
|
||||||
<div className="app-chat-panel__preview-modal-footer">
|
<div className="app-chat-panel__preview-modal-title">
|
||||||
<Space wrap>
|
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{canSearchActivePreview ? (
|
||||||
|
<Button
|
||||||
|
type={isPreviewFindOpen ? 'default' : 'text'}
|
||||||
|
aria-label="단어 찾기"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPreviewFindOpen((current) => !current);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Button type="text" aria-label="복사" icon={<CopyOutlined />} onClick={() => void handleCopyActivePreview()} />
|
<Button type="text" aria-label="복사" icon={<CopyOutlined />} onClick={() => void handleCopyActivePreview()} />
|
||||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadActivePreview} />
|
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadActivePreview} />
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : (
|
||||||
|
'preview'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
footer={null}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setIsPreviewModalOpen(false);
|
setIsPreviewModalOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -2620,13 +3078,52 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
|
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal">
|
{canSearchActivePreview && isPreviewFindOpen ? (
|
||||||
|
<div className="app-chat-panel__preview-modal-findbar">
|
||||||
|
<Input
|
||||||
|
ref={previewFindInputRef}
|
||||||
|
size="small"
|
||||||
|
value={previewFindQuery}
|
||||||
|
placeholder="단어 찾기"
|
||||||
|
allowClear
|
||||||
|
onChange={(event) => {
|
||||||
|
setPreviewFindQuery(event.target.value);
|
||||||
|
}}
|
||||||
|
onPressEnter={(event) => {
|
||||||
|
handleFindActivePreview(event.shiftKey ? 'backward' : 'forward');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
handleFindActivePreview('backward');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
handleFindActivePreview('forward');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
ref={previewSearchRootRef}
|
||||||
|
className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root"
|
||||||
|
onPointerDownCapture={handlePreviewSearchRootPointerDown}
|
||||||
|
>
|
||||||
<ChatPreviewBody
|
<ChatPreviewBody
|
||||||
target={activePreview}
|
target={activePreview}
|
||||||
previewText={previewText}
|
previewText={previewText}
|
||||||
isPreviewLoading={isPreviewLoading}
|
isPreviewLoading={isPreviewLoading}
|
||||||
previewError={previewError}
|
previewError={previewError}
|
||||||
previewContentType={previewContentType}
|
previewContentType={previewContentType}
|
||||||
|
maxMarkdownBlocks={undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -869,6 +870,7 @@ export function MainHeader({
|
|||||||
void contentExpanded;
|
void contentExpanded;
|
||||||
void onToggleContentExpanded;
|
void onToggleContentExpanded;
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
@@ -911,9 +913,12 @@ export function MainHeader({
|
|||||||
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
|
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
|
||||||
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
|
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||||
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
|
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
|
||||||
|
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
|
||||||
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
||||||
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
|
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
|
||||||
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null);
|
const [serverRestartingKey, setServerRestartingKey] = useState<
|
||||||
|
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
|
||||||
|
>(null);
|
||||||
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
|
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
|
||||||
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
|
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||||
const { registeredToken, hasAccess } = useTokenAccess();
|
const { registeredToken, hasAccess } = useTokenAccess();
|
||||||
@@ -941,9 +946,11 @@ export function MainHeader({
|
|||||||
: 'app-header__status-dot--inactive';
|
: 'app-header__status-dot--inactive';
|
||||||
const testServerPendingUpdateCount =
|
const testServerPendingUpdateCount =
|
||||||
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
|
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
|
||||||
|
const prodServerPendingUpdateCount =
|
||||||
|
prodServerStatus && (prodServerStatus.updateAvailable || prodServerStatus.buildRequired) ? 1 : 0;
|
||||||
const workServerPendingUpdateCount =
|
const workServerPendingUpdateCount =
|
||||||
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
||||||
const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount;
|
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||||
const settingsStatusClassName =
|
const settingsStatusClassName =
|
||||||
totalPendingUpdateCount >= 2
|
totalPendingUpdateCount >= 2
|
||||||
? 'app-header__status-dot--inactive'
|
? 'app-header__status-dot--inactive'
|
||||||
@@ -989,6 +996,8 @@ export function MainHeader({
|
|||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
searchParams.set('topMenu', 'chat');
|
searchParams.set('topMenu', 'chat');
|
||||||
searchParams.set('sessionId', sessionId);
|
searchParams.set('sessionId', sessionId);
|
||||||
|
searchParams.delete('chatView');
|
||||||
|
searchParams.delete('runtimeRequestId');
|
||||||
navigate({
|
navigate({
|
||||||
pathname: buildChatPath('live'),
|
pathname: buildChatPath('live'),
|
||||||
search: `?${searchParams.toString()}`,
|
search: `?${searchParams.toString()}`,
|
||||||
@@ -1482,18 +1491,22 @@ export function MainHeader({
|
|||||||
const refreshServerStatuses = async () => {
|
const refreshServerStatuses = async () => {
|
||||||
const items = await fetchServerCommands();
|
const items = await fetchServerCommands();
|
||||||
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
|
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
|
||||||
|
const nextProdServerStatus = items.find((item) => item.key === 'prod') ?? null;
|
||||||
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
|
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
|
||||||
setTestServerStatus(nextTestServerStatus);
|
setTestServerStatus(nextTestServerStatus);
|
||||||
|
setProdServerStatus(nextProdServerStatus);
|
||||||
setWorkServerStatus(nextWorkServerStatus);
|
setWorkServerStatus(nextWorkServerStatus);
|
||||||
return {
|
return {
|
||||||
test: nextTestServerStatus,
|
test: nextTestServerStatus,
|
||||||
|
prod: nextProdServerStatus,
|
||||||
'work-server': nextWorkServerStatus,
|
'work-server': nextWorkServerStatus,
|
||||||
} satisfies Record<'test' | 'work-server', ServerCommandItem | null>;
|
} satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshUpdateTargets = async (silent = false) => {
|
const refreshUpdateTargets = async (silent = false) => {
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
setTestServerStatus(null);
|
setTestServerStatus(null);
|
||||||
|
setProdServerStatus(null);
|
||||||
setWorkServerStatus(null);
|
setWorkServerStatus(null);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
||||||
@@ -1515,7 +1528,7 @@ export function MainHeader({
|
|||||||
if (!silent) {
|
if (!silent) {
|
||||||
setUpdateCheckFeedback({
|
setUpdateCheckFeedback({
|
||||||
tone: 'error',
|
tone: 'error',
|
||||||
message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
|
message: error instanceof Error ? error.message : 'TEST/PROD/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1524,7 +1537,7 @@ export function MainHeader({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => {
|
const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => {
|
||||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||||
await waitForDuration(2500);
|
await waitForDuration(2500);
|
||||||
|
|
||||||
@@ -1549,7 +1562,10 @@ export function MainHeader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus };
|
return {
|
||||||
|
ok: false,
|
||||||
|
item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetClientState = async () => {
|
const handleResetClientState = async () => {
|
||||||
@@ -1589,14 +1605,19 @@ export function MainHeader({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const restartServerWithVerification = async (key: 'test' | 'work-server', busyKey: 'test' | 'work-server' | 'all') => {
|
const restartServerWithVerification = async (
|
||||||
const baseline = key === 'test' ? testServerStatus : workServerStatus;
|
key: 'test' | 'prod' | 'work-server',
|
||||||
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
|
busyKey: 'test' | 'prod' | 'work-server' | 'all',
|
||||||
|
) => {
|
||||||
|
const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus;
|
||||||
|
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
|
||||||
|
|
||||||
const result = await restartServerCommand(key);
|
const result = await restartServerCommand(key);
|
||||||
|
|
||||||
if (key === 'test') {
|
if (key === 'test') {
|
||||||
setTestServerStatus(result.item);
|
setTestServerStatus(result.item);
|
||||||
|
} else if (key === 'prod') {
|
||||||
|
setProdServerStatus(result.item);
|
||||||
} else {
|
} else {
|
||||||
setWorkServerStatus(result.item);
|
setWorkServerStatus(result.item);
|
||||||
}
|
}
|
||||||
@@ -1627,7 +1648,7 @@ export function MainHeader({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestartSingleServer = async (key: 'test' | 'work-server') => {
|
const handleRestartSingleServer = async (key: 'test' | 'prod' | 'work-server') => {
|
||||||
if (!hasAccess || serverRestartingKey) {
|
if (!hasAccess || serverRestartingKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1639,7 +1660,7 @@ export function MainHeader({
|
|||||||
try {
|
try {
|
||||||
return await restartServerWithVerification(key, key);
|
return await restartServerWithVerification(key, key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
|
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
|
||||||
setServerRestartFeedback({
|
setServerRestartFeedback({
|
||||||
tone: 'error',
|
tone: 'error',
|
||||||
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
|
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
|
||||||
@@ -1650,6 +1671,67 @@ export function MainHeader({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmRestartProdServer = () => {
|
||||||
|
if (!hasAccess || serverRestartingKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalApi.confirm({
|
||||||
|
title: 'PROD 빌드 반영',
|
||||||
|
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
|
||||||
|
okText: '빌드 및 재기동',
|
||||||
|
cancelText: '취소',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await handleRestartSingleServer('prod');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestartCommandRunner = async () => {
|
||||||
|
if (!hasAccess || serverRestartingKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerRestartCopyFeedback(null);
|
||||||
|
setServerRestartFeedback(null);
|
||||||
|
setServerRestartingKey('command-runner');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await restartServerCommand('command-runner');
|
||||||
|
setServerRestartFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message:
|
||||||
|
result.restartState === 'accepted'
|
||||||
|
? 'Command runner 배포 및 재기동 요청을 접수했습니다.'
|
||||||
|
: 'Command runner 배포 및 재기동을 완료했습니다.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setServerRestartFeedback({
|
||||||
|
tone: 'error',
|
||||||
|
message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setServerRestartingKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRestartCommandRunner = () => {
|
||||||
|
if (!hasAccess || serverRestartingKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalApi.confirm({
|
||||||
|
title: 'Command runner 배포 및 재기동',
|
||||||
|
content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?',
|
||||||
|
okText: '배포 및 재기동',
|
||||||
|
cancelText: '취소',
|
||||||
|
onOk: async () => {
|
||||||
|
await handleRestartCommandRunner();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleRestartBothServers = async () => {
|
const handleRestartBothServers = async () => {
|
||||||
if (!hasAccess || serverRestartingKey) {
|
if (!hasAccess || serverRestartingKey) {
|
||||||
return;
|
return;
|
||||||
@@ -1737,7 +1819,7 @@ export function MainHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResetNotificationIdentity = () => {
|
const handleResetNotificationIdentity = () => {
|
||||||
Modal.confirm({
|
modalApi.confirm({
|
||||||
title: '알림 클라이언트 초기화',
|
title: '알림 클라이언트 초기화',
|
||||||
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
||||||
okText: '초기화',
|
okText: '초기화',
|
||||||
@@ -2718,6 +2800,7 @@ export function MainHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{modalContextHolder}
|
||||||
<Header className="app-header">
|
<Header className="app-header">
|
||||||
<Space size={12} className="app-header__row">
|
<Space size={12} className="app-header__row">
|
||||||
<Space size={12} className="app-header__menu-side">
|
<Space size={12} className="app-header__menu-side">
|
||||||
@@ -3027,6 +3110,24 @@ export function MainHeader({
|
|||||||
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
|
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
|
||||||
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
|
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
|
||||||
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
|
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
|
||||||
|
<Divider style={{ marginBlock: 4 }} />
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Text strong>Command runner</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
별도 명시적 요청이 있을 때만 command runner 배포 및 재기동을 실행합니다.
|
||||||
|
</Text>
|
||||||
|
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={serverRestartingKey === 'command-runner' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||||
|
loading={serverRestartingKey === 'command-runner'}
|
||||||
|
disabled={!canRestartServers}
|
||||||
|
onClick={handleConfirmRestartCommandRunner}
|
||||||
|
>
|
||||||
|
command runner 배포 및 재기동
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSettingsModal === 'notification' ? (
|
{activeSettingsModal === 'notification' ? (
|
||||||
@@ -3154,6 +3255,17 @@ export function MainHeader({
|
|||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)}
|
소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
운영
|
||||||
|
<span
|
||||||
|
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(prodServerStatus)}`}
|
||||||
|
aria-label={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
||||||
|
title={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
||||||
|
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">{formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}</Text>
|
||||||
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
||||||
<Button
|
<Button
|
||||||
block
|
block
|
||||||
@@ -3188,6 +3300,7 @@ export function MainHeader({
|
|||||||
<Text strong style={{ marginTop: 8 }}>
|
<Text strong style={{ marginTop: 8 }}>
|
||||||
서버 재기동
|
서버 재기동
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text type="secondary">전체 재기동은 TEST와 WORK 서버만 순서대로 진행합니다.</Text>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -3234,6 +3347,24 @@ export function MainHeader({
|
|||||||
전체 재기동
|
전체 재기동
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
<Text strong style={{ marginTop: 8 }}>
|
||||||
|
PROD 빌드 반영
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">운영 마지막 확인: {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
block
|
||||||
|
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||||
|
loading={serverRestartingKey === 'prod'}
|
||||||
|
disabled={!canRestartServers}
|
||||||
|
onClick={handleConfirmRestartProdServer}
|
||||||
|
>
|
||||||
|
PROD 빌드 반영
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ function InlineMessagePreview({
|
|||||||
isPreviewLoading={isPreviewLoading}
|
isPreviewLoading={isPreviewLoading}
|
||||||
previewError={previewError}
|
previewError={previewError}
|
||||||
previewContentType={previewContentType}
|
previewContentType={previewContentType}
|
||||||
|
maxMarkdownBlocks={12}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type ChatGateway = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
title: string;
|
title: string;
|
||||||
chatTypeId?: string | null;
|
chatTypeId?: string | null;
|
||||||
|
lastChatTypeId?: string | null;
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
contextDescription?: string;
|
contextDescription?: string;
|
||||||
notifyOffline?: boolean;
|
notifyOffline?: boolean;
|
||||||
@@ -44,7 +45,10 @@ export type ChatGateway = {
|
|||||||
updateConversation: (
|
updateConversation: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: Partial<
|
payload: Partial<
|
||||||
Pick<ChatConversationSummary, 'title' | 'chatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'>
|
Pick<
|
||||||
|
ChatConversationSummary,
|
||||||
|
'title' | 'chatTypeId' | 'lastChatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'
|
||||||
|
>
|
||||||
>,
|
>,
|
||||||
) => Promise<ChatConversationSummary>;
|
) => Promise<ChatConversationSummary>;
|
||||||
deleteConversation: (sessionId: string) => Promise<void>;
|
deleteConversation: (sessionId: string) => Promise<void>;
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ function InlineMessagePreview({
|
|||||||
isPreviewLoading={isLoading}
|
isPreviewLoading={isLoading}
|
||||||
previewError={previewError}
|
previewError={previewError}
|
||||||
previewContentType={previewContentType}
|
previewContentType={previewContentType}
|
||||||
|
maxMarkdownBlocks={12}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ type ChatPreviewBodyProps = {
|
|||||||
isPreviewLoading: boolean;
|
isPreviewLoading: boolean;
|
||||||
previewError: string;
|
previewError: string;
|
||||||
previewContentType?: string;
|
previewContentType?: string;
|
||||||
|
maxMarkdownBlocks?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
||||||
@@ -236,6 +237,7 @@ export function ChatPreviewBody({
|
|||||||
isPreviewLoading,
|
isPreviewLoading,
|
||||||
previewError,
|
previewError,
|
||||||
previewContentType,
|
previewContentType,
|
||||||
|
maxMarkdownBlocks,
|
||||||
}: ChatPreviewBodyProps) {
|
}: ChatPreviewBodyProps) {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
||||||
@@ -294,7 +296,10 @@ export function ChatPreviewBody({
|
|||||||
if (target.kind === 'markdown') {
|
if (target.kind === 'markdown') {
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
||||||
<MarkdownPreviewContent content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'} maxBlocks={12} />
|
<MarkdownPreviewContent
|
||||||
|
content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'}
|
||||||
|
maxBlocks={maxMarkdownBlocks}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons';
|
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons';
|
||||||
import { Button, Drawer, Empty, Modal, Space, Typography } from 'antd';
|
import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
cancelChatRuntimeJob,
|
cancelChatRuntimeJob,
|
||||||
fetchChatRuntimeJobDetail,
|
fetchChatRuntimeJobDetail,
|
||||||
removeChatRuntimeJob,
|
removeChatRuntimeJob,
|
||||||
|
rollbackChatRuntimeJob,
|
||||||
} from './chatUtils';
|
} from './chatUtils';
|
||||||
import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types';
|
import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types';
|
||||||
|
|
||||||
@@ -157,10 +158,14 @@ function RecentRuntimeList({
|
|||||||
items,
|
items,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onOpenLog,
|
onOpenLog,
|
||||||
|
onRollbackJob,
|
||||||
|
pendingActionRequestId,
|
||||||
}: {
|
}: {
|
||||||
items: ChatRuntimeSnapshot['recent'];
|
items: ChatRuntimeSnapshot['recent'];
|
||||||
onSelectSession: (sessionId: string) => void;
|
onSelectSession: (sessionId: string) => void;
|
||||||
onOpenLog: (requestId: string) => void;
|
onOpenLog: (requestId: string) => void;
|
||||||
|
onRollbackJob: (requestId: string, sessionId: string) => void;
|
||||||
|
pendingActionRequestId: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="app-chat-runtime__section app-chat-runtime__section--recent">
|
<section className="app-chat-runtime__section app-chat-runtime__section--recent">
|
||||||
@@ -181,22 +186,35 @@ function RecentRuntimeList({
|
|||||||
<Text strong>{buildTerminalLabel(item.terminalStatus)}</Text>
|
<Text strong>{buildTerminalLabel(item.terminalStatus)}</Text>
|
||||||
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Space size={8} wrap className="app-chat-runtime__job-actions">
|
||||||
size="small"
|
<Button
|
||||||
onClick={() => {
|
size="small"
|
||||||
onOpenLog(item.requestId);
|
onClick={() => {
|
||||||
}}
|
onOpenLog(item.requestId);
|
||||||
>
|
}}
|
||||||
로그
|
>
|
||||||
</Button>
|
로그
|
||||||
<Button
|
</Button>
|
||||||
size="small"
|
<Button
|
||||||
onClick={() => {
|
size="small"
|
||||||
onSelectSession(item.sessionId);
|
icon={<UndoOutlined />}
|
||||||
}}
|
disabled={item.terminalStatus !== 'completed'}
|
||||||
>
|
loading={pendingActionRequestId === item.requestId}
|
||||||
채팅방 이동
|
onClick={() => {
|
||||||
</Button>
|
onRollbackJob(item.requestId, item.sessionId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
롤백
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSession(item.sessionId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
채팅방 이동
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
||||||
<div className="app-chat-runtime__job-meta">
|
<div className="app-chat-runtime__job-meta">
|
||||||
@@ -233,6 +251,8 @@ export function ChatRuntimeDashboard({
|
|||||||
onRequestedLogHandled?: () => void;
|
onRequestedLogHandled?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const sessions = snapshot?.sessions ?? [];
|
const sessions = snapshot?.sessions ?? [];
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||||
const [selectedDetail, setSelectedDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
const [selectedDetail, setSelectedDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||||
const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
||||||
const [logLoadError, setLogLoadError] = useState('');
|
const [logLoadError, setLogLoadError] = useState('');
|
||||||
@@ -240,6 +260,23 @@ export function ChatRuntimeDashboard({
|
|||||||
const [pendingActionRequestId, setPendingActionRequestId] = useState<string | null>(null);
|
const [pendingActionRequestId, setPendingActionRequestId] = useState<string | null>(null);
|
||||||
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
|
||||||
|
const confirmAction = (options: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
okText: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
modalApi.confirm({
|
||||||
|
title: options.title,
|
||||||
|
content: options.content,
|
||||||
|
okText: options.okText,
|
||||||
|
cancelText: options.cancelText ?? '닫기',
|
||||||
|
onOk: () => resolve(true),
|
||||||
|
onCancel: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const loadLogDetail = async (requestId: string) => {
|
const loadLogDetail = async (requestId: string) => {
|
||||||
setIsLogLoading(true);
|
setIsLogLoading(true);
|
||||||
setLogLoadError('');
|
setLogLoadError('');
|
||||||
@@ -261,15 +298,10 @@ export function ChatRuntimeDashboard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async (requestId: string) => {
|
const handleCancel = async (requestId: string) => {
|
||||||
const confirmed = await new Promise<boolean>((resolve) => {
|
const confirmed = await confirmAction({
|
||||||
Modal.confirm({
|
title: '실행 중 요청을 취소할까요?',
|
||||||
title: '실행 중 요청을 취소할까요?',
|
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
|
||||||
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
|
okText: '취소 실행',
|
||||||
okText: '취소 실행',
|
|
||||||
cancelText: '닫기',
|
|
||||||
onOk: () => resolve(true),
|
|
||||||
onCancel: () => resolve(false),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@@ -280,21 +312,22 @@ export function ChatRuntimeDashboard({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await cancelChatRuntimeJob(requestId);
|
await cancelChatRuntimeJob(requestId);
|
||||||
|
messageApi.success('취소 요청을 보냈습니다.');
|
||||||
|
if (selectedDetail?.item?.requestId === requestId) {
|
||||||
|
await loadLogDetail(requestId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '실행 취소 요청에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setPendingActionRequestId(null);
|
setPendingActionRequestId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async (requestId: string) => {
|
const handleRemove = async (requestId: string) => {
|
||||||
const confirmed = await new Promise<boolean>((resolve) => {
|
const confirmed = await confirmAction({
|
||||||
Modal.confirm({
|
title: '대기열 요청을 제거할까요?',
|
||||||
title: '대기열 요청을 제거할까요?',
|
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
|
||||||
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
|
okText: '제거',
|
||||||
okText: '제거',
|
|
||||||
cancelText: '닫기',
|
|
||||||
onOk: () => resolve(true),
|
|
||||||
onCancel: () => resolve(false),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@@ -305,6 +338,38 @@ export function ChatRuntimeDashboard({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await removeChatRuntimeJob(requestId);
|
await removeChatRuntimeJob(requestId);
|
||||||
|
messageApi.success('대기 요청을 제거했습니다.');
|
||||||
|
if (selectedDetail?.item?.requestId === requestId) {
|
||||||
|
await loadLogDetail(requestId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '대기 요청 제거에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setPendingActionRequestId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollback = async (requestId: string, sessionId: string) => {
|
||||||
|
const confirmed = await confirmAction({
|
||||||
|
title: '최근 실행 변경을 롤백할까요?',
|
||||||
|
content: '이 실행이 남긴 diff만 역적용합니다. 이후 다른 세션이 같은 라인을 수정했다면 실패할 수 있습니다.',
|
||||||
|
okText: '롤백',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingActionRequestId(requestId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rollbackChatRuntimeJob(requestId, sessionId);
|
||||||
|
messageApi.success('최근 실행 변경을 롤백했습니다.');
|
||||||
|
if (selectedDetail?.item?.requestId === requestId) {
|
||||||
|
await loadLogDetail(requestId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setPendingActionRequestId(null);
|
setPendingActionRequestId(null);
|
||||||
}
|
}
|
||||||
@@ -362,6 +427,8 @@ export function ChatRuntimeDashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
|
{modalContextHolder}
|
||||||
<div className="app-chat-runtime">
|
<div className="app-chat-runtime">
|
||||||
<div className="app-chat-runtime__summary-strip">
|
<div className="app-chat-runtime__summary-strip">
|
||||||
<div className="app-chat-runtime__summary-card">
|
<div className="app-chat-runtime__summary-card">
|
||||||
@@ -431,7 +498,13 @@ export function ChatRuntimeDashboard({
|
|||||||
onRemoveJob={handleRemove}
|
onRemoveJob={handleRemove}
|
||||||
pendingActionRequestId={pendingActionRequestId}
|
pendingActionRequestId={pendingActionRequestId}
|
||||||
/>
|
/>
|
||||||
<RecentRuntimeList items={snapshot?.recent ?? []} onSelectSession={onSelectSession} onOpenLog={openLog} />
|
<RecentRuntimeList
|
||||||
|
items={snapshot?.recent ?? []}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onOpenLog={openLog}
|
||||||
|
onRollbackJob={handleRollback}
|
||||||
|
pendingActionRequestId={pendingActionRequestId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1003,6 +1003,19 @@ export async function removeChatRuntimeJob(requestId: string) {
|
|||||||
return response.removed;
|
return response.removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rollbackChatRuntimeJob(requestId: string, sessionId?: string | null) {
|
||||||
|
const response = await requestChatApi<{ ok: boolean; rolledBack: boolean }>(
|
||||||
|
`/runtime/jobs/${encodeURIComponent(requestId)}/rollback`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: sessionId?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.rolledBack;
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadChatComposerFile(sessionId: string, file: File) {
|
export async function uploadChatComposerFile(sessionId: string, file: File) {
|
||||||
const normalizedSessionId = sessionId.trim();
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
|
||||||
@@ -1028,6 +1041,7 @@ export async function createChatConversationRoom(args: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
chatTypeId?: string | null;
|
chatTypeId?: string | null;
|
||||||
|
lastChatTypeId?: string | null;
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
contextDescription?: string;
|
contextDescription?: string;
|
||||||
notifyOffline?: boolean;
|
notifyOffline?: boolean;
|
||||||
@@ -1040,6 +1054,7 @@ export async function createChatConversationRoom(args: {
|
|||||||
sessionId: args.sessionId,
|
sessionId: args.sessionId,
|
||||||
title: args.title ?? '새 대화',
|
title: args.title ?? '새 대화',
|
||||||
chatTypeId: args.chatTypeId ?? null,
|
chatTypeId: args.chatTypeId ?? null,
|
||||||
|
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
|
||||||
contextLabel: args.contextLabel ?? null,
|
contextLabel: args.contextLabel ?? null,
|
||||||
contextDescription: args.contextDescription ?? null,
|
contextDescription: args.contextDescription ?? null,
|
||||||
notifyOffline,
|
notifyOffline,
|
||||||
@@ -1076,6 +1091,7 @@ export async function updateChatConversationRoom(
|
|||||||
payload: {
|
payload: {
|
||||||
title?: string;
|
title?: string;
|
||||||
chatTypeId?: string | null;
|
chatTypeId?: string | null;
|
||||||
|
lastChatTypeId?: string | null;
|
||||||
contextLabel?: string | null;
|
contextLabel?: string | null;
|
||||||
contextDescription?: string | null;
|
contextDescription?: string | null;
|
||||||
notifyOffline?: boolean;
|
notifyOffline?: boolean;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type ChatConversationSummary = {
|
|||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
chatTypeId: string | null;
|
chatTypeId: string | null;
|
||||||
|
lastChatTypeId: string | null;
|
||||||
contextLabel: string | null;
|
contextLabel: string | null;
|
||||||
contextDescription: string | null;
|
contextDescription: string | null;
|
||||||
notifyOffline: boolean;
|
notifyOffline: boolean;
|
||||||
|
|||||||
@@ -93,6 +93,25 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewer-ui__findbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -4px -4px 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewer-ui__findbar .ant-input-affix-wrapper {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.previewer-ui__action-button {
|
.previewer-ui__action-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -124,6 +143,12 @@
|
|||||||
0 12px 28px rgba(15, 23, 42, 0.16);
|
0 12px 28px rgba(15, 23, 42, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewer-ui--expanded .previewer-ui__header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
.previewer-ui--expanded .previewer-ui__body {
|
.previewer-ui--expanded .previewer-ui__body {
|
||||||
height: calc(100vh - 53px) !important;
|
height: calc(100vh - 53px) !important;
|
||||||
}
|
}
|
||||||
@@ -305,3 +330,24 @@
|
|||||||
.previewer-ui__token--option {
|
.previewer-ui__token--option {
|
||||||
color: #c586c0;
|
color: #c586c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.previewer-ui__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewer-ui__toolbar {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewer-ui__findbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewer-ui__findbar .ant-btn {
|
||||||
|
flex: 1 1 calc(50% - 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
import {
|
||||||
import { Button, Empty, Select, message } from 'antd';
|
CopyOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
FullscreenExitOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Empty, Input, Select, message } from 'antd';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { InlineImage } from '../common/InlineImage';
|
import { InlineImage } from '../common/InlineImage';
|
||||||
import { CodexDiffBlock } from './CodexDiffBlock';
|
import { CodexDiffBlock } from './CodexDiffBlock';
|
||||||
import type { PreviewerUIProps } from './types';
|
import type { PreviewerUIProps } from './types';
|
||||||
@@ -283,13 +289,17 @@ export function PreviewerUI({
|
|||||||
}: PreviewerUIProps) {
|
}: PreviewerUIProps) {
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isFindOpen, setIsFindOpen] = useState(false);
|
||||||
|
const [findQuery, setFindQuery] = useState('');
|
||||||
|
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
|
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
|
||||||
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
|
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
|
||||||
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
|
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
|
||||||
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
|
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
|
||||||
const canCopy = copyable && resolvedCopyValue.trim().length > 0;
|
const canCopy = copyable && resolvedCopyValue.trim().length > 0;
|
||||||
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
|
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
|
||||||
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
|
const canFind = type !== 'image' && type !== 'empty';
|
||||||
|
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || canFind || Boolean(toolbar);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpanded || typeof document === 'undefined') {
|
if (!isExpanded || typeof document === 'undefined') {
|
||||||
@@ -312,6 +322,18 @@ export function PreviewerUI({
|
|||||||
};
|
};
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
setIsFindOpen(false);
|
||||||
|
setFindQuery('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFindOpen) {
|
||||||
|
window.setTimeout(() => findInputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [isExpanded, isFindOpen]);
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
if (!canCopy) {
|
if (!canCopy) {
|
||||||
return;
|
return;
|
||||||
@@ -333,6 +355,44 @@ export function PreviewerUI({
|
|||||||
setIsExpanded((previous) => !previous);
|
setIsExpanded((previous) => !previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFind(direction: 'forward' | 'backward' = 'forward') {
|
||||||
|
const keyword = findQuery.trim();
|
||||||
|
if (!keyword) {
|
||||||
|
messageApi.info('찾을 단어를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserWindow = typeof window === 'undefined' ? null : (window as Window & {
|
||||||
|
find?: (
|
||||||
|
text: string,
|
||||||
|
caseSensitive?: boolean,
|
||||||
|
backwards?: boolean,
|
||||||
|
wrapAround?: boolean,
|
||||||
|
wholeWord?: boolean,
|
||||||
|
searchInFrames?: boolean,
|
||||||
|
showDialog?: boolean,
|
||||||
|
) => boolean;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!browserWindow || typeof browserWindow.find !== 'function') {
|
||||||
|
messageApi.error('이 브라우저에서는 단어 찾기를 지원하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = browserWindow.find(keyword, false, direction === 'backward', true, false, false, false);
|
||||||
|
if (!matched) {
|
||||||
|
messageApi.info('일치하는 단어를 찾지 못했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFind() {
|
||||||
|
if (!canFind) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFindOpen((previous) => !previous);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!canDownload) {
|
if (!canDownload) {
|
||||||
return;
|
return;
|
||||||
@@ -405,6 +465,16 @@ export function PreviewerUI({
|
|||||||
onClick={() => void toggleFullscreen()}
|
onClick={() => void toggleFullscreen()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canFind ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
className="previewer-ui__action-button"
|
||||||
|
aria-label="단어 찾기"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={toggleFind}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{toolbar}
|
{toolbar}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -433,6 +503,27 @@ export function PreviewerUI({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="previewer-ui__body previewer-ui__scroll" style={{ height }}>
|
<div className="previewer-ui__body previewer-ui__scroll" style={{ height }}>
|
||||||
{!showHeader && shouldShowActions ? <div className="previewer-ui__floating-toolbar">{actionContent}</div> : null}
|
{!showHeader && shouldShowActions ? <div className="previewer-ui__floating-toolbar">{actionContent}</div> : null}
|
||||||
|
{isExpanded && isFindOpen ? (
|
||||||
|
<div className="previewer-ui__findbar">
|
||||||
|
<Input
|
||||||
|
ref={(node) => {
|
||||||
|
findInputRef.current = node?.input ?? null;
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
value={findQuery}
|
||||||
|
placeholder="단어 찾기"
|
||||||
|
allowClear
|
||||||
|
onChange={(event) => setFindQuery(event.target.value)}
|
||||||
|
onPressEnter={(event) => handleFind(event.shiftKey ? 'backward' : 'forward')}
|
||||||
|
/>
|
||||||
|
<Button size="small" onClick={() => handleFind('backward')}>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={() => handleFind('forward')}>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{renderContent({
|
{renderContent({
|
||||||
type,
|
type,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export function SearchCommandModal({
|
|||||||
submitHint,
|
submitHint,
|
||||||
}: SearchCommandModalProps) {
|
}: SearchCommandModalProps) {
|
||||||
const inputRef = useRef<InputRef | null>(null);
|
const inputRef = useRef<InputRef | null>(null);
|
||||||
const selectionLockRef = useRef(false);
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [isMobileViewport, setIsMobileViewport] = useState(() => {
|
const [isMobileViewport, setIsMobileViewport] = useState(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -47,11 +46,10 @@ export function SearchCommandModal({
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const submitOption = (option: SearchKeywordOption | undefined) => {
|
const submitOption = (option: SearchKeywordOption | undefined) => {
|
||||||
if (!option || selectionLockRef.current) {
|
if (!option) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionLockRef.current = true;
|
|
||||||
onSelectOption(option);
|
onSelectOption(option);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -127,7 +125,6 @@ export function SearchCommandModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionLockRef.current = false;
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
inputRef.current?.focus({
|
inputRef.current?.focus({
|
||||||
cursor: 'all',
|
cursor: 'all',
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function ServerCommandPage() {
|
|||||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||||
|
prod: { output: null, executedAt: '', restartState: 'completed' },
|
||||||
'work-server': { output: null, executedAt: '', restartState: 'completed' },
|
'work-server': { output: null, executedAt: '', restartState: 'completed' },
|
||||||
'command-runner': { output: null, executedAt: '', restartState: 'completed' },
|
'command-runner': { output: null, executedAt: '', restartState: 'completed' },
|
||||||
});
|
});
|
||||||
@@ -211,7 +212,7 @@ export function ServerCommandPage() {
|
|||||||
Server Command
|
Server Command
|
||||||
</Title>
|
</Title>
|
||||||
<Paragraph className="server-command-page__copy">
|
<Paragraph className="server-command-page__copy">
|
||||||
TEST, REL, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
|
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function normalizeServerCommandItem(value: unknown): ServerCommandItem {
|
|||||||
const item = value as Partial<Record<keyof ServerCommandItem, unknown>>;
|
const item = value as Partial<Record<keyof ServerCommandItem, unknown>>;
|
||||||
const key = typeof item.key === 'string' ? item.key : '';
|
const key = typeof item.key === 'string' ? item.key : '';
|
||||||
|
|
||||||
if (key !== 'test' && key !== 'rel' && key !== 'work-server' && key !== 'command-runner') {
|
if (key !== 'test' && key !== 'rel' && key !== 'prod' && key !== 'work-server' && key !== 'command-runner') {
|
||||||
throw new Error('지원하지 않는 서버 키입니다.');
|
throw new Error('지원하지 않는 서버 키입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ServerCommandKey = 'test' | 'rel' | 'work-server' | 'command-runner';
|
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
|
||||||
|
|
||||||
export type ServerCommandItem = {
|
export type ServerCommandItem = {
|
||||||
key: ServerCommandKey;
|
key: ServerCommandKey;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -18,14 +17,12 @@ type SearchLayerContextValue = SearchLayerSnapshot & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SearchLayerContext = createContext<SearchLayerContextValue | null>(null);
|
const SearchLayerContext = createContext<SearchLayerContextValue | null>(null);
|
||||||
const WINDOW_SELECTION_DEDUP_MS = 500;
|
|
||||||
|
|
||||||
export function SearchLayerProvider({ children }: PropsWithChildren) {
|
export function SearchLayerProvider({ children }: PropsWithChildren) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [options, setOptions] = useState<SearchKeywordOption[]>([]);
|
const [options, setOptions] = useState<SearchKeywordOption[]>([]);
|
||||||
const [mode, setMode] = useState<SearchOpenMode>('navigate');
|
const [mode, setMode] = useState<SearchOpenMode>('navigate');
|
||||||
const [windowSelections, setWindowSelections] = useState<SearchWindowSelection[]>([]);
|
const [windowSelections, setWindowSelections] = useState<SearchWindowSelection[]>([]);
|
||||||
const lastWindowSelectionRef = useRef<{ id: string; at: number } | null>(null);
|
|
||||||
|
|
||||||
const value = useMemo<SearchLayerContextValue>(
|
const value = useMemo<SearchLayerContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -35,7 +32,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) {
|
|||||||
windowSelections,
|
windowSelections,
|
||||||
setOptions,
|
setOptions,
|
||||||
openSearch: (nextMode = 'navigate') => {
|
openSearch: (nextMode = 'navigate') => {
|
||||||
lastWindowSelectionRef.current = null;
|
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
},
|
},
|
||||||
@@ -72,22 +68,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) {
|
|||||||
onClose={value.closeSearch}
|
onClose={value.closeSearch}
|
||||||
onSelectOption={(option) => {
|
onSelectOption={(option) => {
|
||||||
if (mode === 'window') {
|
if (mode === 'window') {
|
||||||
const now = Date.now();
|
|
||||||
const previousSelection = lastWindowSelectionRef.current;
|
|
||||||
|
|
||||||
if (
|
|
||||||
previousSelection &&
|
|
||||||
previousSelection.id === option.id &&
|
|
||||||
now - previousSelection.at <= WINDOW_SELECTION_DEDUP_MS
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastWindowSelectionRef.current = {
|
|
||||||
id: option.id,
|
|
||||||
at: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setWindowSelections((previous) => [
|
setWindowSelections((previous) => [
|
||||||
...previous,
|
...previous,
|
||||||
|
|||||||
Reference in New Issue
Block a user