Fix chat type persistence and board flow
This commit is contained in:
@@ -55,6 +55,7 @@
|
|||||||
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
|
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
|
||||||
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
|
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
|
||||||
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
|
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
|
||||||
|
* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
|
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
|
||||||
|
|
||||||
현재 문서는 자동화 브랜치 전략 자체를 고정 규칙으로 설명하지 않습니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
|
자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비하고, 작업 결과를 `release` 반영 후 `main`까지 반영하는 흐름입니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
|
||||||
|
|
||||||
## 구현 위치
|
## 구현 위치
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
|
|||||||
|
|
||||||
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
|
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
|
||||||
|
|
||||||
자동화 작업의 Git 절차나 동기화 순서는 이 문서에 고정 규칙으로 적지 않습니다. 필요 시 실제 worker 구현과 현재 설정값을 함께 확인합니다.
|
자동화 작업의 세부 Git 절차와 예외 처리 순서는 실제 worker 구현과 현재 설정값을 함께 확인합니다.
|
||||||
|
|
||||||
## 차트 집계 방식
|
## 차트 집계 방식
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|||||||
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
|
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
|
||||||
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
|
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
|
||||||
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
|
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
|
||||||
|
SUPERVISOR_SCRIPT="${SERVER_COMMAND_RUNNER_SUPERVISOR_SCRIPT:-$PROJECT_ROOT/scripts/server-command-runner-supervisor.sh}"
|
||||||
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
|
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
|
||||||
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
|
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
|
||||||
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
|
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}"
|
||||||
|
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
|
||||||
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
|
||||||
@@ -27,17 +29,36 @@ if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
|
if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
|
||||||
if [ -n "$RUNNER_PIDS" ]; then
|
chmod +x "$SUPERVISOR_SCRIPT"
|
||||||
kill $RUNNER_PIDS || true
|
fi
|
||||||
|
|
||||||
|
if [ -f "$SUPERVISOR_PID_FILE" ]; then
|
||||||
|
SUPERVISOR_PID=$(cat "$SUPERVISOR_PID_FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then
|
||||||
|
kill -HUP "$SUPERVISOR_PID"
|
||||||
|
echo "server-command-runner reload requested"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$SUPERVISOR_PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
LEGACY_RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
|
||||||
|
if [ -n "$LEGACY_RUNNER_PIDS" ]; then
|
||||||
|
kill $LEGACY_RUNNER_PIDS || true
|
||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
setsid env \
|
setsid env \
|
||||||
|
PROJECT_ROOT="$PROJECT_ROOT" \
|
||||||
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
|
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
|
||||||
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" \
|
||||||
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
SERVER_COMMAND_RUNNER_SCRIPT="$RUNNER_SCRIPT" \
|
||||||
|
SERVER_COMMAND_RUNNER_NODE_BIN="$RUNNER_NODE_BIN" \
|
||||||
|
SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE="$SUPERVISOR_PID_FILE" \
|
||||||
|
"$SUPERVISOR_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
||||||
|
|
||||||
echo "server-command-runner restart requested"
|
echo "server-command-runner restart requested"
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ set -eu
|
|||||||
|
|
||||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||||
|
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
|
|
||||||
|
if docker inspect work-server >/dev/null 2>&1; then
|
||||||
|
RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false')
|
||||||
|
SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '')
|
||||||
|
case "$SUPERVISOR_CMD" in
|
||||||
|
*work-server-supervisor*)
|
||||||
|
if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then
|
||||||
|
echo "work-server reload requested"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ create table if not exists board_posts (
|
|||||||
id serial primary key,
|
id serial primary key,
|
||||||
title varchar(200) not null,
|
title varchar(200) not null,
|
||||||
content text not null,
|
content text not null,
|
||||||
|
attachments_json text not null default '[]',
|
||||||
automation_plan_item_id integer null,
|
automation_plan_item_id integer null,
|
||||||
automation_received_at timestamptz null,
|
automation_received_at timestamptz null,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
|
|||||||
4
etc/servers/work-server/.dockerignore
Normal file
4
etc/servers/work-server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.docker
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log*
|
||||||
@@ -11,4 +11,7 @@ COPY src ./src
|
|||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
COPY scripts/container-supervisor.sh /usr/local/bin/work-server-supervisor
|
||||||
|
RUN chmod +x /usr/local/bin/work-server-supervisor
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/work-server-supervisor"]
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:3100:3100'
|
- '127.0.0.1:3100:3100'
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- work-server-node-modules:/app/node_modules
|
||||||
- ../../../:/workspace/main-project
|
- ../../../:/workspace/main-project
|
||||||
- ../../../.auto_codex:/workspace/auto_codex
|
- ../../../.auto_codex:/workspace/auto_codex
|
||||||
- ../../../scripts:/workspace/repo-scripts:ro
|
- ../../../scripts:/workspace/repo-scripts:ro
|
||||||
@@ -50,3 +52,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
work-backend:
|
work-backend:
|
||||||
name: work-backend
|
name: work-backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
work-server-node-modules:
|
||||||
|
|||||||
96
etc/servers/work-server/scripts/container-supervisor.sh
Executable file
96
etc/servers/work-server/scripts/container-supervisor.sh
Executable file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_ROOT="${APP_ROOT:-/app}"
|
||||||
|
STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}"
|
||||||
|
LOCK_FILE="$APP_ROOT/package-lock.json"
|
||||||
|
LOCK_HASH_FILE="$STATE_DIR/package-lock.sha256"
|
||||||
|
CHILD_PID=""
|
||||||
|
STOP_REQUESTED="0"
|
||||||
|
RELOAD_REQUESTED="0"
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
cd "$APP_ROOT"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[work-server-supervisor] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dependencies() {
|
||||||
|
if [ ! -f "$LOCK_FILE" ]; then
|
||||||
|
log "package-lock.json not found; skipping npm ci"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_HASH=$(sha256sum "$LOCK_FILE" | awk '{print $1}')
|
||||||
|
PREVIOUS_HASH=""
|
||||||
|
|
||||||
|
if [ -f "$LOCK_HASH_FILE" ]; then
|
||||||
|
PREVIOUS_HASH=$(cat "$LOCK_HASH_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$APP_ROOT/node_modules" ] || [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then
|
||||||
|
log "installing dependencies"
|
||||||
|
npm ci --legacy-peer-deps
|
||||||
|
printf '%s' "$CURRENT_HASH" >"$LOCK_HASH_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_runtime() {
|
||||||
|
ensure_dependencies
|
||||||
|
log "building latest source"
|
||||||
|
npm run build
|
||||||
|
}
|
||||||
|
|
||||||
|
start_child() {
|
||||||
|
log "starting server process"
|
||||||
|
npm run start &
|
||||||
|
CHILD_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
request_reload() {
|
||||||
|
log "reload requested"
|
||||||
|
if prepare_runtime; then
|
||||||
|
RELOAD_REQUESTED="1"
|
||||||
|
if [ -n "$CHILD_PID" ]; then
|
||||||
|
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "reload aborted because build failed; keeping current process"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
request_stop() {
|
||||||
|
STOP_REQUESTED="1"
|
||||||
|
log "shutdown requested"
|
||||||
|
if [ -n "$CHILD_PID" ]; then
|
||||||
|
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'request_reload' HUP
|
||||||
|
trap 'request_stop' INT TERM
|
||||||
|
|
||||||
|
prepare_runtime
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
start_child
|
||||||
|
set +e
|
||||||
|
wait "$CHILD_PID"
|
||||||
|
EXIT_CODE=$?
|
||||||
|
set -e
|
||||||
|
CHILD_PID=""
|
||||||
|
|
||||||
|
if [ "$STOP_REQUESTED" = "1" ]; then
|
||||||
|
exit "$EXIT_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RELOAD_REQUESTED" = "1" ]; then
|
||||||
|
RELOAD_REQUESTED="0"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "server exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
@@ -32,6 +32,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'general-inquiry',
|
||||||
|
name: '일반 문의',
|
||||||
|
description:
|
||||||
|
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function ensureAppConfigTable() {
|
async function ensureAppConfigTable() {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
|||||||
id: 'none',
|
id: 'none',
|
||||||
name: '기본유형',
|
name: '기본유형',
|
||||||
description:
|
description:
|
||||||
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
|
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||||
behaviorType: 'none',
|
behaviorType: 'none',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||||
@@ -82,7 +82,7 @@ function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
|
|||||||
byId.set(defaultItem.id, {
|
byId.set(defaultItem.id, {
|
||||||
...existingItem,
|
...existingItem,
|
||||||
name: defaultItem.name,
|
name: defaultItem.name,
|
||||||
description: defaultItem.description,
|
description: existingItem.description || defaultItem.description,
|
||||||
behaviorType: defaultItem.behaviorType,
|
behaviorType: defaultItem.behaviorType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
|
|||||||
|
|
||||||
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
|
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
|
buildBoardPostPlanNote(
|
||||||
name: '자동화 메모',
|
' 알림 개선 ',
|
||||||
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
'본문 첫 줄\n본문 둘째 줄\n',
|
||||||
}),
|
[],
|
||||||
|
{
|
||||||
|
name: '자동화 메모',
|
||||||
|
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||||
|
},
|
||||||
|
),
|
||||||
[
|
[
|
||||||
'# 자동화 작업메모',
|
'# 자동화 작업메모',
|
||||||
'',
|
'',
|
||||||
@@ -27,10 +32,15 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
|
|||||||
|
|
||||||
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
|
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildBoardPostPlanNote('작업', '본문', {
|
buildBoardPostPlanNote(
|
||||||
name: '빈 context 유형',
|
'작업',
|
||||||
description: ' ',
|
'본문',
|
||||||
}),
|
[],
|
||||||
|
{
|
||||||
|
name: '빈 context 유형',
|
||||||
|
description: ' ',
|
||||||
|
},
|
||||||
|
),
|
||||||
[
|
[
|
||||||
'# 자동화 작업메모',
|
'# 자동화 작업메모',
|
||||||
'',
|
'',
|
||||||
@@ -48,6 +58,42 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildBoardPostPlanNote(
|
||||||
|
'작업',
|
||||||
|
'본문',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'attachment-1',
|
||||||
|
name: 'spec.png',
|
||||||
|
path: 'public/.codex_chat/test/resource/uploads/spec.png',
|
||||||
|
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
|
||||||
|
size: 1280,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'# 자동화 작업메모',
|
||||||
|
'',
|
||||||
|
'- 게시판 제목: 작업',
|
||||||
|
'- 메모 출처: board_posts 자동화 접수',
|
||||||
|
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
|
||||||
|
'',
|
||||||
|
'## 자동화 유형 context',
|
||||||
|
'선택된 자동화 유형 context 없음',
|
||||||
|
'',
|
||||||
|
'## 요청 본문',
|
||||||
|
'본문',
|
||||||
|
'',
|
||||||
|
'## 첨부 파일',
|
||||||
|
'- spec.png: public/.codex_chat/test/resource/uploads/spec.png',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
|
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
|
||||||
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
|
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
|
||||||
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ export const BOARD_POSTS_TABLE = 'board_posts';
|
|||||||
export const boardPostPayloadSchema = z.object({
|
export const boardPostPayloadSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
content: z.string().min(1).max(200000),
|
content: z.string().min(1).max(200000),
|
||||||
|
attachments: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1).max(120),
|
||||||
|
name: z.string().trim().min(1).max(255),
|
||||||
|
path: z.string().trim().min(1).max(2000),
|
||||||
|
publicUrl: z.string().trim().min(1).max(2000),
|
||||||
|
size: z.coerce.number().int().min(0).max(10 * 1024 * 1024),
|
||||||
|
mimeType: z.string().trim().min(1).max(200),
|
||||||
|
}),
|
||||||
|
).max(20).default([]),
|
||||||
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,6 +35,7 @@ export type BoardPostItem = {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'];
|
||||||
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
||||||
automationPlanItemId: number | null;
|
automationPlanItemId: number | null;
|
||||||
automationReceivedAt: string | null;
|
automationReceivedAt: string | null;
|
||||||
@@ -57,12 +68,22 @@ function createPreview(content: string) {
|
|||||||
|
|
||||||
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
||||||
const content = String(row.content ?? '');
|
const content = String(row.content ?? '');
|
||||||
|
const rawAttachments = row.attachments_json ?? row.attachments ?? '[]';
|
||||||
|
let attachments: BoardPostItem['attachments'] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments;
|
||||||
|
attachments = boardPostPayloadSchema.shape.attachments.parse(parsed);
|
||||||
|
} catch {
|
||||||
|
attachments = [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(row.id ?? 0),
|
id: Number(row.id ?? 0),
|
||||||
title: String(row.title ?? ''),
|
title: String(row.title ?? ''),
|
||||||
content,
|
content,
|
||||||
preview: createPreview(content),
|
preview: createPreview(content),
|
||||||
|
attachments,
|
||||||
automationType: resolveStoredAutomationTypeId(row),
|
automationType: resolveStoredAutomationTypeId(row),
|
||||||
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
|
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
|
||||||
? null
|
? null
|
||||||
@@ -79,7 +100,33 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
|
|||||||
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null) {
|
function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloadSchema>['attachments']) {
|
||||||
|
const lines = attachments
|
||||||
|
.map((attachment) => {
|
||||||
|
const label = attachment.name.trim() || attachment.path.trim().split('/').pop() || '첨부 파일';
|
||||||
|
const path = attachment.path.trim();
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `- ${label}: ${path}`;
|
||||||
|
})
|
||||||
|
.filter((line): line is string => Boolean(line));
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['## 첨부 파일', ...lines];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBoardPostPlanNote(
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
|
||||||
|
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null,
|
||||||
|
) {
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
const normalizedContent = content.trim();
|
const normalizedContent = content.trim();
|
||||||
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
|
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
|
||||||
@@ -98,6 +145,7 @@ export function buildBoardPostPlanNote(title: string, content: string, automatio
|
|||||||
'',
|
'',
|
||||||
'## 요청 본문',
|
'## 요청 본문',
|
||||||
normalizedContent,
|
normalizedContent,
|
||||||
|
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
|
||||||
]
|
]
|
||||||
.filter((line): line is string => line !== null)
|
.filter((line): line is string => line !== null)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
@@ -174,6 +222,7 @@ export async function ensureBoardPostsTable() {
|
|||||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||||
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
|
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
|
||||||
|
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
|
||||||
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
||||||
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
||||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||||
@@ -230,6 +279,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
|
|||||||
const insertQuery = db(BOARD_POSTS_TABLE).insert({
|
const insertQuery = db(BOARD_POSTS_TABLE).insert({
|
||||||
title: parsedPayload.title,
|
title: parsedPayload.title,
|
||||||
content: parsedPayload.content,
|
content: parsedPayload.content,
|
||||||
|
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||||
automation_type: automationType.behaviorType,
|
automation_type: automationType.behaviorType,
|
||||||
automation_type_id: automationType.id,
|
automation_type_id: automationType.id,
|
||||||
created_at: db.fn.now(),
|
created_at: db.fn.now(),
|
||||||
@@ -276,11 +326,12 @@ export async function receiveBoardPostAutomation(id: number) {
|
|||||||
|
|
||||||
const title = String(currentRow.title ?? '').trim();
|
const title = String(currentRow.title ?? '').trim();
|
||||||
const content = String(currentRow.content ?? '').trim();
|
const content = String(currentRow.content ?? '').trim();
|
||||||
|
const attachments = mapBoardPostRow(currentRow).attachments;
|
||||||
const workId = `board-post-${id}`;
|
const workId = `board-post-${id}`;
|
||||||
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
|
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
|
||||||
const insertQuery = trx(PLAN_TABLE).insert({
|
const insertQuery = trx(PLAN_TABLE).insert({
|
||||||
work_id: workId,
|
work_id: workId,
|
||||||
note: buildBoardPostPlanNote(title, content, automationType),
|
note: buildBoardPostPlanNote(title, content, attachments, automationType),
|
||||||
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
||||||
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
|
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
|
||||||
status: '등록',
|
status: '등록',
|
||||||
@@ -344,6 +395,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
|
|||||||
.update({
|
.update({
|
||||||
title: parsedPayload.title,
|
title: parsedPayload.title,
|
||||||
content: parsedPayload.content,
|
content: parsedPayload.content,
|
||||||
|
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||||
automation_type: automationType.behaviorType,
|
automation_type: automationType.behaviorType,
|
||||||
automation_type_id: automationType.id,
|
automation_type_id: automationType.id,
|
||||||
updated_at: trx.fn.now(),
|
updated_at: trx.fn.now(),
|
||||||
|
|||||||
@@ -1070,15 +1070,25 @@ export async function updateChatConversationContext(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null;
|
||||||
|
const requestedChatTypeId = payload.chatTypeId?.trim() || null;
|
||||||
|
const nextChatTypeId = currentChatTypeId || requestedChatTypeId || null;
|
||||||
|
const requestedContextLabel = payload.contextLabel?.trim() || null;
|
||||||
|
const requestedContextDescription = payload.contextDescription?.trim() || null;
|
||||||
|
|
||||||
await db(CHAT_CONVERSATION_TABLE)
|
await db(CHAT_CONVERSATION_TABLE)
|
||||||
.where({ session_id: sessionId.trim() })
|
.where({ session_id: sessionId.trim() })
|
||||||
.update({
|
.update({
|
||||||
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: nextChatTypeId,
|
||||||
last_chat_type_id: payload.lastChatTypeId?.trim() || null,
|
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
|
||||||
context_label: payload.contextLabel?.trim() || null,
|
context_label:
|
||||||
context_description: payload.contextDescription?.trim() || null,
|
currentChatTypeId != null ? current.context_label || null : requestedContextLabel || current.context_label || null,
|
||||||
|
context_description:
|
||||||
|
currentChatTypeId != null
|
||||||
|
? current.context_description || null
|
||||||
|
: requestedContextDescription || current.context_description || null,
|
||||||
notify_offline:
|
notify_offline:
|
||||||
normalizedClientId == null && payload.notifyOffline != null
|
normalizedClientId == null && payload.notifyOffline != null
|
||||||
? payload.notifyOffline
|
? payload.notifyOffline
|
||||||
@@ -1613,14 +1623,21 @@ export async function appendChatConversationMessage(
|
|||||||
.update({
|
.update({
|
||||||
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: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null,
|
||||||
last_chat_type_id:
|
last_chat_type_id:
|
||||||
conversation.chatTypeId?.trim() ||
|
|
||||||
currentConversation?.last_chat_type_id ||
|
|
||||||
currentConversation?.chat_type_id ||
|
currentConversation?.chat_type_id ||
|
||||||
|
currentConversation?.last_chat_type_id ||
|
||||||
|
conversation.chatTypeId?.trim() ||
|
||||||
|
conversation.lastChatTypeId?.trim() ||
|
||||||
null,
|
null,
|
||||||
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
|
context_label:
|
||||||
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
|
currentConversation?.chat_type_id || currentConversation?.context_label
|
||||||
|
? currentConversation?.context_label || null
|
||||||
|
: conversation.contextLabel?.trim() || null,
|
||||||
|
context_description:
|
||||||
|
currentConversation?.chat_type_id || currentConversation?.context_description
|
||||||
|
? currentConversation?.context_description || null
|
||||||
|
: conversation.contextDescription?.trim() || null,
|
||||||
notify_offline:
|
notify_offline:
|
||||||
conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline,
|
conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline,
|
||||||
updated_at: db.fn.now(),
|
updated_at: db.fn.now(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
collectOfflineNotificationClientIds,
|
collectOfflineNotificationClientIds,
|
||||||
createActivityLogMessage,
|
createActivityLogMessage,
|
||||||
extractDiffCodeBlocks,
|
extractDiffCodeBlocks,
|
||||||
|
extractCodexStreamText,
|
||||||
fitActivityLogLines,
|
fitActivityLogLines,
|
||||||
isAutomationRegistrationCountRequest,
|
isAutomationRegistrationCountRequest,
|
||||||
resolveResponseTimestamp,
|
resolveResponseTimestamp,
|
||||||
@@ -116,6 +117,45 @@ test('createActivityLogMessage keeps fitted activity history instead of the late
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('extractCodexStreamText ignores command execution item completions', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractCodexStreamText({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item_1',
|
||||||
|
type: 'command_execution',
|
||||||
|
command: '/bin/bash -lc pwd',
|
||||||
|
aggregated_output: '/workspace/main-project\n',
|
||||||
|
exit_code: 0,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
type: 'item.completed',
|
||||||
|
completedText: '',
|
||||||
|
deltaText: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractCodexStreamText keeps completed agent messages', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractCodexStreamText({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item_2',
|
||||||
|
type: 'agent_message',
|
||||||
|
text: '최종 응답입니다.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
type: 'item.completed',
|
||||||
|
completedText: '최종 응답입니다.',
|
||||||
|
deltaText: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
|
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
|
||||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
|
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1041,11 +1041,25 @@ function collectCodexTextFragments(value: unknown): string[] {
|
|||||||
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
|
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCompletedAgentMessageText(item: unknown) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (record.type !== 'agent_message') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectCodexTextFragments(record.text ?? record.content ?? record.message).join('');
|
||||||
|
}
|
||||||
|
|
||||||
export function extractCodexStreamText(parsed: Record<string, unknown>) {
|
export function extractCodexStreamText(parsed: Record<string, unknown>) {
|
||||||
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
||||||
|
|
||||||
if (type === 'item.completed') {
|
if (type === 'item.completed') {
|
||||||
const completedText = collectCodexTextFragments(parsed.item).join('');
|
const completedText = extractCompletedAgentMessageText(parsed.item);
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
completedText,
|
completedText,
|
||||||
@@ -3186,9 +3200,19 @@ export class ChatService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const progressMessages = buildProgressMessages(request.text);
|
const progressMessages = buildProgressMessages(request.text);
|
||||||
let progressIndex = 0;
|
let progressIndex = progressMessages.length > 1 ? 1 : 0;
|
||||||
|
let lastProgressMessage = progressMessages[0] ?? '';
|
||||||
let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
|
let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
|
||||||
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)];
|
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)];
|
||||||
|
|
||||||
|
if (!nextMessage || nextMessage === lastProgressMessage) {
|
||||||
|
if (progressIndex >= progressMessages.length - 1) {
|
||||||
|
stopProgressTimer();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastProgressMessage = nextMessage;
|
||||||
chatRuntimeService.appendLog(request.requestId, nextMessage);
|
chatRuntimeService.appendLog(request.requestId, nextMessage);
|
||||||
appendActivityLine(`# 진행: ${nextMessage}`);
|
appendActivityLine(`# 진행: ${nextMessage}`);
|
||||||
|
|
||||||
@@ -3199,6 +3223,8 @@ export class ChatService {
|
|||||||
|
|
||||||
if (progressIndex < progressMessages.length - 1) {
|
if (progressIndex < progressMessages.length - 1) {
|
||||||
progressIndex += 1;
|
progressIndex += 1;
|
||||||
|
} else {
|
||||||
|
stopProgressTimer();
|
||||||
}
|
}
|
||||||
}, 2200);
|
}, 2200);
|
||||||
|
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: {
|
|||||||
const createdPost = await createBoardPost({
|
const createdPost = await createBoardPost({
|
||||||
title: buildErrorLogBoardPostTitle(candidate),
|
title: buildErrorLogBoardPostTitle(candidate),
|
||||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||||
|
attachments: [],
|
||||||
automationType: 'none',
|
automationType: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ const runnerLogTrimIntervalMs = Math.max(
|
|||||||
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 STREAM_CAPTURE_LIMIT = 256 * 1024;
|
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
||||||
|
const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max(
|
||||||
|
30_000,
|
||||||
|
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '90000'),
|
||||||
|
);
|
||||||
|
const CODEX_LIVE_MAX_EXECUTION_MS = Math.max(
|
||||||
|
CODEX_LIVE_IDLE_TIMEOUT_MS,
|
||||||
|
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`),
|
||||||
|
);
|
||||||
const CODEX_HOME_RUNTIME_PATHS = [
|
const CODEX_HOME_RUNTIME_PATHS = [
|
||||||
'auth.json',
|
'auth.json',
|
||||||
'config.toml',
|
'config.toml',
|
||||||
@@ -342,12 +350,24 @@ function collectCodexTextFragments(value) {
|
|||||||
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
|
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCompletedAgentMessageText(item) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type !== 'agent_message') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectCodexTextFragments(item.text ?? item.content ?? item.message).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function extractCodexStreamText(parsed) {
|
function extractCodexStreamText(parsed) {
|
||||||
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
||||||
|
|
||||||
if (type === 'item.completed') {
|
if (type === 'item.completed') {
|
||||||
return {
|
return {
|
||||||
completedText: collectCodexTextFragments(parsed.item).join(''),
|
completedText: extractCompletedAgentMessageText(parsed.item),
|
||||||
deltaText: '',
|
deltaText: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -504,6 +524,9 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
let jsonLineBuffer = '';
|
let jsonLineBuffer = '';
|
||||||
let completedText = '';
|
let completedText = '';
|
||||||
let responseClosed = false;
|
let responseClosed = false;
|
||||||
|
let idleTimer = null;
|
||||||
|
let executionTimer = null;
|
||||||
|
let terminationRequested = false;
|
||||||
|
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
'content-type': 'application/x-ndjson; charset=utf-8',
|
'content-type': 'application/x-ndjson; charset=utf-8',
|
||||||
@@ -540,6 +563,68 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearExecutionTimers = () => {
|
||||||
|
if (idleTimer) {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executionTimer) {
|
||||||
|
clearTimeout(executionTimer);
|
||||||
|
executionTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestTermination = (message) => {
|
||||||
|
if (terminationRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminationRequested = true;
|
||||||
|
clearExecutionTimers();
|
||||||
|
|
||||||
|
if (!responseClosed) {
|
||||||
|
sendJsonLine(response, {
|
||||||
|
type: 'error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
responseClosed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 3000).unref?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshIdleTimer = () => {
|
||||||
|
if (terminationRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idleTimer) {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
requestTermination(
|
||||||
|
`Codex Live 실행이 ${Math.round(CODEX_LIVE_IDLE_TIMEOUT_MS / 1000)}초 동안 출력이 없어 중단되었습니다.`,
|
||||||
|
);
|
||||||
|
}, CODEX_LIVE_IDLE_TIMEOUT_MS);
|
||||||
|
idleTimer.unref?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
executionTimer = setTimeout(() => {
|
||||||
|
requestTermination(
|
||||||
|
`Codex Live 실행이 ${Math.round(CODEX_LIVE_MAX_EXECUTION_MS / 1000)}초를 넘어 중단되었습니다.`,
|
||||||
|
);
|
||||||
|
}, CODEX_LIVE_MAX_EXECUTION_MS);
|
||||||
|
executionTimer.unref?.();
|
||||||
|
refreshIdleTimer();
|
||||||
|
|
||||||
const handleCodexJsonLine = (line) => {
|
const handleCodexJsonLine = (line) => {
|
||||||
let parsed;
|
let parsed;
|
||||||
|
|
||||||
@@ -552,6 +637,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
const activityLog = extractCodexActivityLog(parsed);
|
const activityLog = extractCodexActivityLog(parsed);
|
||||||
|
|
||||||
if (activityLog) {
|
if (activityLog) {
|
||||||
|
refreshIdleTimer();
|
||||||
sendJsonLine(response, {
|
sendJsonLine(response, {
|
||||||
type: 'activity',
|
type: 'activity',
|
||||||
line: activityLog,
|
line: activityLog,
|
||||||
@@ -561,6 +647,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
|
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
|
||||||
|
|
||||||
if (nextCompletedText) {
|
if (nextCompletedText) {
|
||||||
|
refreshIdleTimer();
|
||||||
completedText = nextCompletedText;
|
completedText = nextCompletedText;
|
||||||
sendJsonLine(response, {
|
sendJsonLine(response, {
|
||||||
type: 'completed',
|
type: 'completed',
|
||||||
@@ -570,6 +657,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (deltaText) {
|
if (deltaText) {
|
||||||
|
refreshIdleTimer();
|
||||||
sendJsonLine(response, {
|
sendJsonLine(response, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
text: deltaText,
|
text: deltaText,
|
||||||
@@ -582,9 +670,11 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
|
|
||||||
response.on('close', () => {
|
response.on('close', () => {
|
||||||
responseClosed = true;
|
responseClosed = true;
|
||||||
|
clearExecutionTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout?.on('data', (chunk) => {
|
child.stdout?.on('data', (chunk) => {
|
||||||
|
refreshIdleTimer();
|
||||||
const text = String(chunk);
|
const text = String(chunk);
|
||||||
stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT);
|
stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT);
|
||||||
jsonLineBuffer += text;
|
jsonLineBuffer += text;
|
||||||
@@ -612,6 +702,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.stderr?.on('data', (chunk) => {
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
refreshIdleTimer();
|
||||||
const text = String(chunk);
|
const text = String(chunk);
|
||||||
stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT);
|
stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT);
|
||||||
text
|
text
|
||||||
@@ -631,6 +722,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', async (error) => {
|
child.on('error', async (error) => {
|
||||||
|
clearExecutionTimers();
|
||||||
if (!responseClosed) {
|
if (!responseClosed) {
|
||||||
sendJsonLine(response, {
|
sendJsonLine(response, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -643,6 +735,7 @@ async function runCodexLiveExecution(payload, response) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', async (code) => {
|
child.on('close', async (code) => {
|
||||||
|
clearExecutionTimers();
|
||||||
const trailingLine = jsonLineBuffer.trim();
|
const trailingLine = jsonLineBuffer.trim();
|
||||||
if (trailingLine) {
|
if (trailingLine) {
|
||||||
handleCodexJsonLine(trailingLine);
|
handleCodexJsonLine(trailingLine);
|
||||||
|
|||||||
69
scripts/server-command-runner-supervisor.sh
Executable file
69
scripts/server-command-runner-supervisor.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"
|
||||||
|
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
|
||||||
|
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
|
||||||
|
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
|
||||||
|
CHILD_PID=""
|
||||||
|
STOP_REQUESTED="0"
|
||||||
|
RELOAD_REQUESTED="0"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[server-command-runner-supervisor] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$SUPERVISOR_PID_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_child() {
|
||||||
|
log "starting runner child"
|
||||||
|
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" &
|
||||||
|
CHILD_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
request_reload() {
|
||||||
|
RELOAD_REQUESTED="1"
|
||||||
|
log "reload requested"
|
||||||
|
if [ -n "$CHILD_PID" ]; then
|
||||||
|
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
request_stop() {
|
||||||
|
STOP_REQUESTED="1"
|
||||||
|
log "shutdown requested"
|
||||||
|
if [ -n "$CHILD_PID" ]; then
|
||||||
|
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'request_reload' HUP
|
||||||
|
trap 'request_stop' INT TERM
|
||||||
|
trap 'cleanup' EXIT
|
||||||
|
|
||||||
|
printf '%s\n' "$$" >"$SUPERVISOR_PID_FILE"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
start_child
|
||||||
|
set +e
|
||||||
|
wait "$CHILD_PID"
|
||||||
|
EXIT_CODE=$?
|
||||||
|
set -e
|
||||||
|
CHILD_PID=""
|
||||||
|
|
||||||
|
if [ "$STOP_REQUESTED" = "1" ]; then
|
||||||
|
exit "$EXIT_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RELOAD_REQUESTED" = "1" ]; then
|
||||||
|
RELOAD_REQUESTED="0"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "runner exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ArrowsAltOutlined,
|
ArrowsAltOutlined,
|
||||||
ArrowLeftOutlined,
|
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
SaveOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ShrinkOutlined,
|
ShrinkOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||||
import {
|
import {
|
||||||
@@ -78,8 +79,13 @@ export function AutomationTypeManagementPage() {
|
|||||||
}, [automationTypes, selectedAutomationTypeId]);
|
}, [automationTypes, selectedAutomationTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (detailMode !== 'detail') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.resetFields();
|
||||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
|
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
|
||||||
}, [form, isCreating, selectedAutomationType]);
|
}, [detailMode, form, isCreating, selectedAutomationType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailMode !== 'detail') {
|
if (detailMode !== 'detail') {
|
||||||
@@ -163,6 +169,41 @@ export function AutomationTypeManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const detailHeaderActions = (
|
||||||
|
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||||
|
<Tooltip title={isCreating ? '등록' : '수정 저장'}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={isSaving}
|
||||||
|
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||||
|
onClick={() => {
|
||||||
|
void form.submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="새 입력">
|
||||||
|
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
|
||||||
|
</Tooltip>
|
||||||
|
{!isCreating && selectedAutomationType ? (
|
||||||
|
<Tooltip title="삭제">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
shape="circle"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={isSaving}
|
||||||
|
aria-label="삭제"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
<Tooltip title="목록 가기">
|
||||||
|
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return (
|
return (
|
||||||
<Card title="자동화 유형 관리" className="chat-type-management-page">
|
<Card title="자동화 유형 관리" className="chat-type-management-page">
|
||||||
@@ -250,13 +291,11 @@ export function AutomationTypeManagementPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
|
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
|
||||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||||
|
extra={detailHeaderActions}
|
||||||
>
|
>
|
||||||
<div className="chat-type-management-page__editor">
|
<div className="chat-type-management-page__editor">
|
||||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||||
<div className="chat-type-management-page__list-header">
|
|
||||||
<Title level={5}>{isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
className="chat-type-management-page__editor-form"
|
className="chat-type-management-page__editor-form"
|
||||||
@@ -290,172 +329,150 @@ export function AutomationTypeManagementPage() {
|
|||||||
<Form.Item name="id" hidden>
|
<Form.Item name="id" hidden>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
<div className="chat-type-management-page__editor-scroll">
|
||||||
<Form.Item
|
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
<Form.Item
|
||||||
label="유형명"
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||||
name="name"
|
label="유형명"
|
||||||
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
|
name="name"
|
||||||
>
|
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
|
||||||
<Input placeholder="예: 자동화 메모" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
|
||||||
label="사용 여부"
|
|
||||||
name="enabled"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
<div className="chat-type-management-page__markdown-field">
|
|
||||||
<Text strong className="chat-type-management-page__field-label">
|
|
||||||
설명
|
|
||||||
</Text>
|
|
||||||
<div className="chat-type-management-page__markdown-editor">
|
|
||||||
<div className="chat-type-management-page__editor-toolbar">
|
|
||||||
{isMobileViewport ? (
|
|
||||||
<Segmented
|
|
||||||
className="chat-type-management-page__mobile-toggle"
|
|
||||||
options={[
|
|
||||||
{ label: '입력', value: 'edit' },
|
|
||||||
{ label: '미리보기', value: 'preview' },
|
|
||||||
]}
|
|
||||||
value={mobileView}
|
|
||||||
onChange={(value) => {
|
|
||||||
setMobileView(value as 'edit' | 'preview');
|
|
||||||
setMaximizedPane('none');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Space size={8} wrap>
|
|
||||||
<Button
|
|
||||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
|
||||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
|
||||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`chat-type-management-page__markdown-grid${
|
|
||||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<Input placeholder="예: 자동화 메모" />
|
||||||
className={`chat-type-management-page__markdown-pane${
|
</Form.Item>
|
||||||
isMobileViewport && mobileView === 'preview'
|
<Form.Item
|
||||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||||
: ''
|
label="사용 여부"
|
||||||
}${
|
name="enabled"
|
||||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
valuePropName="checked"
|
||||||
}`}
|
>
|
||||||
>
|
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||||
<div className="chat-type-management-page__markdown-pane-header">
|
</Form.Item>
|
||||||
<Text type="secondary">입력</Text>
|
</div>
|
||||||
{isMobileViewport ? (
|
<div className="chat-type-management-page__markdown-field">
|
||||||
|
<Text strong className="chat-type-management-page__field-label">
|
||||||
|
설명
|
||||||
|
</Text>
|
||||||
|
<div className="chat-type-management-page__markdown-editor">
|
||||||
|
<div className="chat-type-management-page__editor-toolbar">
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<Segmented
|
||||||
|
className="chat-type-management-page__mobile-toggle"
|
||||||
|
options={[
|
||||||
|
{ label: '입력', value: 'edit' },
|
||||||
|
{ label: '미리보기', value: 'preview' },
|
||||||
|
]}
|
||||||
|
value={mobileView}
|
||||||
|
onChange={(value) => {
|
||||||
|
setMobileView(value as 'edit' | 'preview');
|
||||||
|
setMaximizedPane('none');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Space size={8} wrap>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
<Button
|
||||||
</div>
|
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||||
<Form.Item name="description" noStyle>
|
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
<Input.TextArea
|
onClick={() => {
|
||||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||||
className="chat-type-management-page__markdown-textarea"
|
}}
|
||||||
placeholder={
|
>
|
||||||
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
|
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||||
}
|
</Button>
|
||||||
/>
|
</Space>
|
||||||
</Form.Item>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`chat-type-management-page__markdown-pane${
|
className={`chat-type-management-page__markdown-grid${
|
||||||
isMobileViewport && mobileView === 'edit'
|
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
|
||||||
: ''
|
|
||||||
}${
|
|
||||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="chat-type-management-page__markdown-preview">
|
<div
|
||||||
|
className={`chat-type-management-page__markdown-pane${
|
||||||
|
isMobileViewport && mobileView === 'preview'
|
||||||
|
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||||
|
: ''
|
||||||
|
}${
|
||||||
|
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="chat-type-management-page__markdown-pane-header">
|
<div className="chat-type-management-page__markdown-pane-header">
|
||||||
<Text type="secondary">미리보기</Text>
|
<Text type="secondary">입력</Text>
|
||||||
{isMobileViewport ? (
|
{isMobileViewport ? (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-type-management-page__markdown-preview-body">
|
<Form.Item name="description" noStyle>
|
||||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
<Input.TextArea
|
||||||
{({ getFieldValue }) => {
|
autoSize={isMobileViewport ? false : { minRows: 12, maxRows: 24 }}
|
||||||
const description = String(getFieldValue('description') ?? '').trim();
|
className="chat-type-management-page__markdown-textarea"
|
||||||
|
placeholder={
|
||||||
|
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`chat-type-management-page__markdown-pane${
|
||||||
|
isMobileViewport && mobileView === 'edit'
|
||||||
|
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||||
|
: ''
|
||||||
|
}${
|
||||||
|
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="chat-type-management-page__markdown-preview">
|
||||||
|
<div className="chat-type-management-page__markdown-pane-header">
|
||||||
|
<Text type="secondary">미리보기</Text>
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||||
|
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="chat-type-management-page__markdown-preview-body">
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const description = String(getFieldValue('description') ?? '').trim();
|
||||||
|
|
||||||
return description ? (
|
return description ? (
|
||||||
<MarkdownPreviewContent content={description} />
|
<MarkdownPreviewContent content={description} />
|
||||||
) : (
|
) : (
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={`chat-type-management-page__form-actions${
|
|
||||||
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Space wrap>
|
|
||||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
|
||||||
{isCreating ? '등록' : '수정 저장'}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
|
||||||
새 입력
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
<Space wrap>
|
|
||||||
{!isCreating && selectedAutomationType ? (
|
|
||||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
|
||||||
목록
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,21 +19,31 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card,
|
||||||
|
.chat-type-management-page__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-head {
|
.chat-type-management-page .ant-card-head {
|
||||||
min-height: 52px;
|
min-height: 44px;
|
||||||
padding: 0 14px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-head-title,
|
.chat-type-management-page .ant-card-head-title,
|
||||||
.chat-type-management-page .ant-card-extra {
|
.chat-type-management-page .ant-card-extra {
|
||||||
padding: 10px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page .ant-card-body {
|
.chat-type-management-page .ant-card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 12px 14px;
|
padding: 4px 14px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__list,
|
.chat-type-management-page__list,
|
||||||
@@ -40,7 +52,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -57,12 +69,23 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-type-management-page__editor-form .ant-form-item {
|
.chat-type-management-page__editor-form .ant-form-item {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__list-header {
|
.chat-type-management-page__list-header {
|
||||||
@@ -73,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__list-header .ant-typography {
|
.chat-type-management-page__list-header .ant-typography {
|
||||||
margin-bottom: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__item {
|
.chat-type-management-page__item {
|
||||||
@@ -137,15 +160,28 @@
|
|||||||
.chat-type-management-page__editor-toolbar {
|
.chat-type-management-page__editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions .ant-btn {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__markdown-grid {
|
.chat-type-management-page__markdown-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
||||||
gap: 6px;
|
gap: 12px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -173,6 +209,15 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-type-management-page__markdown-pane--desktop-hidden {
|
.chat-type-management-page__markdown-pane--desktop-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -191,13 +236,13 @@
|
|||||||
|
|
||||||
.chat-type-management-page__markdown-textarea {
|
.chat-type-management-page__markdown-textarea {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
min-height: 180px;
|
min-height: 360px;
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__markdown-textarea textarea {
|
.chat-type-management-page__markdown-textarea textarea {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
min-height: 180px;
|
min-height: 360px;
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
@@ -209,10 +254,10 @@
|
|||||||
border: 1px solid #f0f0f0;
|
border: 1px solid #f0f0f0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
padding: 10px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,23 +271,11 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__form-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__form-actions--compact {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-type-management-page__meta-grid {
|
.chat-type-management-page__meta-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 8px 12px;
|
gap: 6px 14px;
|
||||||
align-items: start;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__meta-grid--hidden {
|
.chat-type-management-page__meta-grid--hidden {
|
||||||
@@ -251,16 +284,45 @@
|
|||||||
|
|
||||||
.chat-type-management-page__meta-item {
|
.chat-type-management-page__meta-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||||
padding-bottom: 4px;
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item .ant-form-item-control-input {
|
||||||
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--name {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||||
@@ -293,6 +355,23 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-type-management-page__list-header {
|
.chat-type-management-page__list-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@@ -305,7 +384,18 @@
|
|||||||
.chat-type-management-page .ant-card-head-title,
|
.chat-type-management-page .ant-card-head-title,
|
||||||
.chat-type-management-page .ant-card-extra,
|
.chat-type-management-page .ant-card-extra,
|
||||||
.chat-type-management-page .ant-card-body {
|
.chat-type-management-page .ant-card-body {
|
||||||
padding: 10px;
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page .ant-card-head-title,
|
||||||
|
.chat-type-management-page .ant-card-extra {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__editor-scroll {
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__mobile-toggle {
|
.chat-type-management-page__mobile-toggle {
|
||||||
@@ -314,28 +404,83 @@
|
|||||||
|
|
||||||
.chat-type-management-page__editor-toolbar {
|
.chat-type-management-page__editor-toolbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__meta-grid {
|
.chat-type-management-page__meta-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 6px;
|
gap: 6px 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__markdown-grid {
|
.chat-type-management-page__markdown-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
|
||||||
|
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__markdown-pane--mobile-hidden {
|
.chat-type-management-page__markdown-pane--mobile-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane,
|
||||||
|
.chat-type-management-page__markdown-field,
|
||||||
|
.chat-type-management-page__markdown-editor,
|
||||||
|
.chat-type-management-page__markdown-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||||
|
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-type-management-page__markdown-textarea,
|
.chat-type-management-page__markdown-textarea,
|
||||||
.chat-type-management-page__markdown-textarea textarea,
|
.chat-type-management-page__markdown-textarea textarea,
|
||||||
.chat-type-management-page__markdown-preview-body {
|
.chat-type-management-page__markdown-preview-body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-type-management-page__form-actions {
|
.chat-type-management-page__markdown-textarea {
|
||||||
flex-wrap: wrap;
|
height: 100% !important;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-textarea textarea {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-preview {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__markdown-preview-body {
|
||||||
|
max-height: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-management-page__header-actions .ant-btn {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ArrowsAltOutlined,
|
ArrowsAltOutlined,
|
||||||
ArrowLeftOutlined,
|
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
SaveOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ShrinkOutlined,
|
ShrinkOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||||
import {
|
import {
|
||||||
@@ -82,8 +83,13 @@ export function ChatTypeManagementPage() {
|
|||||||
}, [chatTypes, selectedChatTypeId]);
|
}, [chatTypes, selectedChatTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (detailMode !== 'detail') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.resetFields();
|
||||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
|
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
|
||||||
}, [form, isCreating, selectedChatType]);
|
}, [detailMode, form, isCreating, selectedChatType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailMode !== 'detail') {
|
if (detailMode !== 'detail') {
|
||||||
@@ -145,7 +151,7 @@ export function ChatTypeManagementPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) {
|
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +173,52 @@ export function ChatTypeManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const detailHeaderActions = (
|
||||||
|
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||||
|
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={isSaving}
|
||||||
|
aria-label={isCreating ? '저장' : '수정 저장'}
|
||||||
|
onClick={() => {
|
||||||
|
void form.submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="새 입력">
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={isSaving}
|
||||||
|
aria-label="새 입력"
|
||||||
|
onClick={openCreateForm}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{!isCreating && selectedChatType ? (
|
||||||
|
<Tooltip title="비활성화">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
shape="circle"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={isSaving}
|
||||||
|
aria-label="비활성화"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
<Tooltip title="목록 가기">
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={<UnorderedListOutlined />}
|
||||||
|
aria-label="목록 가기"
|
||||||
|
onClick={closeDetail}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return (
|
return (
|
||||||
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
|
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
|
||||||
@@ -267,13 +319,11 @@ export function ChatTypeManagementPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
||||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||||
|
extra={detailHeaderActions}
|
||||||
>
|
>
|
||||||
<div className="chat-type-management-page__editor">
|
<div className="chat-type-management-page__editor">
|
||||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||||
<div className="chat-type-management-page__list-header">
|
|
||||||
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
className="chat-type-management-page__editor-form"
|
className="chat-type-management-page__editor-form"
|
||||||
@@ -301,187 +351,165 @@ export function ChatTypeManagementPage() {
|
|||||||
<Form.Item name="id" hidden>
|
<Form.Item name="id" hidden>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
<div className="chat-type-management-page__editor-scroll">
|
||||||
<Form.Item
|
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
<Form.Item
|
||||||
label="컨텍스트명"
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||||
name="name"
|
label="컨텍스트명"
|
||||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
name="name"
|
||||||
>
|
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||||
<Input placeholder="예: 운영 문의" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
|
||||||
label="권한 대상"
|
|
||||||
name="permissions"
|
|
||||||
>
|
|
||||||
<Checkbox.Group
|
|
||||||
options={[
|
|
||||||
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
|
||||||
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
|
||||||
label="사용 여부"
|
|
||||||
name="enabled"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
<div className="chat-type-management-page__markdown-field">
|
|
||||||
<Text strong className="chat-type-management-page__field-label">
|
|
||||||
기본 문맥 설명
|
|
||||||
</Text>
|
|
||||||
<div className="chat-type-management-page__markdown-editor">
|
|
||||||
<div className="chat-type-management-page__editor-toolbar">
|
|
||||||
{isMobileViewport ? (
|
|
||||||
<Segmented
|
|
||||||
className="chat-type-management-page__mobile-toggle"
|
|
||||||
options={[
|
|
||||||
{ label: '입력', value: 'edit' },
|
|
||||||
{ label: '미리보기', value: 'preview' },
|
|
||||||
]}
|
|
||||||
value={mobileView}
|
|
||||||
onChange={(value) => {
|
|
||||||
setMobileView(value as 'edit' | 'preview');
|
|
||||||
setMaximizedPane('none');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Space size={8} wrap>
|
|
||||||
<Button
|
|
||||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
|
||||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
|
||||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`chat-type-management-page__markdown-grid${
|
|
||||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<Input placeholder="예: 운영 문의" />
|
||||||
className={`chat-type-management-page__markdown-pane${
|
</Form.Item>
|
||||||
isMobileViewport && mobileView === 'preview'
|
<Form.Item
|
||||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||||
: ''
|
label="사용 권한"
|
||||||
}${
|
name="permissions"
|
||||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
>
|
||||||
}`}
|
<Checkbox.Group
|
||||||
>
|
options={[
|
||||||
<div className="chat-type-management-page__markdown-pane-header">
|
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
||||||
<Text type="secondary">입력</Text>
|
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
||||||
{isMobileViewport ? (
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||||
|
label="사용 여부"
|
||||||
|
name="enabled"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div className="chat-type-management-page__markdown-field">
|
||||||
|
<Text strong className="chat-type-management-page__field-label">
|
||||||
|
기본 문맥 설명
|
||||||
|
</Text>
|
||||||
|
<div className="chat-type-management-page__markdown-editor">
|
||||||
|
<div className="chat-type-management-page__editor-toolbar">
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<Segmented
|
||||||
|
className="chat-type-management-page__mobile-toggle"
|
||||||
|
options={[
|
||||||
|
{ label: '입력', value: 'edit' },
|
||||||
|
{ label: '미리보기', value: 'preview' },
|
||||||
|
]}
|
||||||
|
value={mobileView}
|
||||||
|
onChange={(value) => {
|
||||||
|
setMobileView(value as 'edit' | 'preview');
|
||||||
|
setMaximizedPane('none');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Space size={8} wrap>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
<Button
|
||||||
</div>
|
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||||
<Form.Item name="description" noStyle>
|
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
<Input.TextArea
|
onClick={() => {
|
||||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||||
className="chat-type-management-page__markdown-textarea"
|
}}
|
||||||
placeholder={
|
>
|
||||||
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
|
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||||
}
|
</Button>
|
||||||
/>
|
</Space>
|
||||||
</Form.Item>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`chat-type-management-page__markdown-pane${
|
className={`chat-type-management-page__markdown-grid${
|
||||||
isMobileViewport && mobileView === 'edit'
|
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
|
||||||
: ''
|
|
||||||
}${
|
|
||||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="chat-type-management-page__markdown-preview">
|
<div
|
||||||
|
className={`chat-type-management-page__markdown-pane${
|
||||||
|
isMobileViewport && mobileView === 'preview'
|
||||||
|
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||||
|
: ''
|
||||||
|
}${
|
||||||
|
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="chat-type-management-page__markdown-pane-header">
|
<div className="chat-type-management-page__markdown-pane-header">
|
||||||
<Text type="secondary">미리보기</Text>
|
<Text type="secondary">입력</Text>
|
||||||
{isMobileViewport ? (
|
{isMobileViewport ? (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
{maximizedPane === 'edit' ? '축소' : '최대화'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-type-management-page__markdown-preview-body">
|
<Form.Item name="description" noStyle>
|
||||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
<Input.TextArea
|
||||||
{({ getFieldValue }) => {
|
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||||
const description = String(getFieldValue('description') ?? '').trim();
|
className="chat-type-management-page__markdown-textarea"
|
||||||
|
placeholder={
|
||||||
|
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`chat-type-management-page__markdown-pane${
|
||||||
|
isMobileViewport && mobileView === 'edit'
|
||||||
|
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||||
|
: ''
|
||||||
|
}${
|
||||||
|
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="chat-type-management-page__markdown-preview">
|
||||||
|
<div className="chat-type-management-page__markdown-pane-header">
|
||||||
|
<Text type="secondary">미리보기</Text>
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||||
|
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{maximizedPane === 'preview' ? '축소' : '최대화'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="chat-type-management-page__markdown-preview-body">
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const description = String(getFieldValue('description') ?? '').trim();
|
||||||
|
|
||||||
return description ? (
|
return description ? (
|
||||||
<MarkdownPreviewContent content={description} />
|
<MarkdownPreviewContent content={description} />
|
||||||
) : (
|
) : (
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description="미리보기할 문맥 설명이 없습니다."
|
description="미리보기할 문맥 설명이 없습니다."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={`chat-type-management-page__form-actions${
|
|
||||||
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Space wrap>
|
|
||||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
|
||||||
{isCreating ? '등록' : '수정 저장'}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
|
||||||
새 입력
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
<Space wrap>
|
|
||||||
{!isCreating && selectedChatType ? (
|
|
||||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
|
||||||
목록
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2131,12 +2131,12 @@
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
width: min(240px, calc(100% - 16px));
|
width: min(420px, calc(100% - 16px));
|
||||||
max-height: min(28vh, 180px);
|
max-height: min(58vh, 520px);
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
background: rgba(246, 248, 252, 0.96);
|
background: rgba(246, 248, 252, 0.96);
|
||||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2145,7 +2145,7 @@
|
|||||||
.app-chat-panel__resource-strip-list {
|
.app-chat-panel__resource-strip-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2171,22 +2171,18 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__resource-chip {
|
.app-chat-panel__resource-strip .app-chat-preview-card {
|
||||||
display: inline-flex;
|
margin: 0;
|
||||||
flex-direction: row;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
.app-chat-panel__resource-strip .app-chat-preview-card__body {
|
||||||
gap: 8px;
|
padding-top: 8px;
|
||||||
min-width: 0;
|
}
|
||||||
width: 100%;
|
|
||||||
padding: 6px 8px;
|
.app-chat-panel__resource-strip .app-chat-panel__preview-rich,
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
.app-chat-panel__resource-strip .previewer-ui__editor,
|
||||||
border-radius: 10px;
|
.app-chat-panel__resource-strip .previewer-ui__editor-body {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
min-height: 0;
|
||||||
color: #0f172a;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__preview-stage {
|
.app-chat-panel__preview-stage {
|
||||||
@@ -2596,10 +2592,6 @@
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-panel__resource-chip {
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel__preview-image,
|
.app-chat-panel__preview-image,
|
||||||
.app-chat-panel__preview-video,
|
.app-chat-panel__preview-video,
|
||||||
.app-chat-panel__preview-frame,
|
.app-chat-panel__preview-frame,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
EyeOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
FullscreenExitOutlined,
|
FullscreenExitOutlined,
|
||||||
FullscreenOutlined,
|
FullscreenOutlined,
|
||||||
@@ -34,6 +35,7 @@ import { useConversationViewportController } from './chatV2/hooks/useConversatio
|
|||||||
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
||||||
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
|
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
|
||||||
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
||||||
|
import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls';
|
||||||
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
|
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
|
||||||
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
||||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
||||||
@@ -47,9 +49,7 @@ import {
|
|||||||
createChatMessage,
|
createChatMessage,
|
||||||
createLocalMessage,
|
createLocalMessage,
|
||||||
ErrorLogViewer,
|
ErrorLogViewer,
|
||||||
getStoredChatSessionLastTypeId,
|
|
||||||
isPreparingChatReplyText,
|
isPreparingChatReplyText,
|
||||||
setStoredChatSessionLastTypeId,
|
|
||||||
sortChatConversationSummaries,
|
sortChatConversationSummaries,
|
||||||
upsertChatMessage,
|
upsertChatMessage,
|
||||||
useErrorLogs,
|
useErrorLogs,
|
||||||
@@ -666,7 +666,7 @@ function isPreviewRouteUrl(url: string) {
|
|||||||
const parsed = new URL(url, window.location.origin);
|
const parsed = new URL(url, window.location.origin);
|
||||||
const pathname = parsed.pathname.toLowerCase();
|
const pathname = parsed.pathname.toLowerCase();
|
||||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||||
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -725,14 +725,28 @@ function buildPreviewLabel(url: string, source: PreviewItem['source']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
|
||||||
|
if (!item || item.kind !== 'code') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||||
|
const pathname = parsed.pathname.toLowerCase();
|
||||||
|
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||||
|
} catch {
|
||||||
|
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
|
||||||
|
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractPreviewItems(messages: ChatMessage[]) {
|
function extractPreviewItems(messages: ChatMessage[]) {
|
||||||
const urlPattern = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const items: PreviewItem[] = [];
|
const items: PreviewItem[] = [];
|
||||||
const orderedMessages = [...messages].reverse();
|
const orderedMessages = [...messages].reverse();
|
||||||
|
|
||||||
orderedMessages.forEach((message) => {
|
orderedMessages.forEach((message) => {
|
||||||
const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)];
|
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
||||||
|
|
||||||
matches.forEach((matchedUrl) => {
|
matches.forEach((matchedUrl) => {
|
||||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||||
@@ -895,7 +909,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
[chatTypes, userRoles],
|
[chatTypes, userRoles],
|
||||||
);
|
);
|
||||||
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
|
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
|
||||||
const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
||||||
|
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
|
||||||
const requestedSessionId = getSessionIdFromSearch(location.search);
|
const requestedSessionId = getSessionIdFromSearch(location.search);
|
||||||
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
|
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
|
||||||
const requestedChatView = getRequestedChatViewFromSearch(location.search);
|
const requestedChatView = getRequestedChatViewFromSearch(location.search);
|
||||||
@@ -940,6 +955,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
||||||
const previewSearchMatchIndexRef = useRef(-1);
|
const previewSearchMatchIndexRef = useRef(-1);
|
||||||
const previewSearchKeyRef = useRef('');
|
const previewSearchKeyRef = useRef('');
|
||||||
|
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
|
||||||
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[]>([]);
|
||||||
@@ -950,7 +966,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
||||||
const handledRequestedSessionIdRef = useRef('');
|
const handledRequestedSessionIdRef = useRef('');
|
||||||
const isClosingConversationRef = useRef(false);
|
const isClosingConversationRef = useRef(false);
|
||||||
const lastChatTypeSessionIdRef = useRef('');
|
|
||||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||||
@@ -966,19 +981,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentContext: ChatViewContext = {
|
|
||||||
pageId: currentPage.id,
|
|
||||||
pageTitle: currentPage.title,
|
|
||||||
topMenu: currentPage.topMenu,
|
|
||||||
focusedComponentId,
|
|
||||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
||||||
isStandaloneMode: isStandaloneDisplayMode(),
|
|
||||||
pageVisibilityState:
|
|
||||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
|
||||||
chatTypeId: selectedChatType?.id ?? null,
|
|
||||||
chatTypeLabel: selectedChatType?.name ?? '',
|
|
||||||
chatTypeDescription: selectedChatType?.description ?? '',
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
conversationItems,
|
conversationItems,
|
||||||
setConversationItems,
|
setConversationItems,
|
||||||
@@ -1029,14 +1031,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const handleCreateConversation = async () => {
|
const handleCreateConversation = async () => {
|
||||||
const sessionId = createConversationSessionId();
|
const sessionId = createConversationSessionId();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const nextConversationChatType =
|
||||||
|
selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null);
|
||||||
const optimisticItem: ChatConversationSummary = {
|
const optimisticItem: ChatConversationSummary = {
|
||||||
sessionId,
|
sessionId,
|
||||||
clientId: null,
|
clientId: null,
|
||||||
title: '새 대화',
|
title: '새 대화',
|
||||||
chatTypeId: selectedChatType?.id ?? null,
|
chatTypeId: nextConversationChatType?.id ?? null,
|
||||||
lastChatTypeId: selectedChatType?.id ?? null,
|
lastChatTypeId: nextConversationChatType?.id ?? null,
|
||||||
contextLabel: selectedChatType?.name ?? null,
|
contextLabel: nextConversationChatType?.name ?? null,
|
||||||
contextDescription: selectedChatType?.description ?? null,
|
contextDescription: nextConversationChatType?.description ?? null,
|
||||||
notifyOffline: true,
|
notifyOffline: true,
|
||||||
hasUnreadResponse: false,
|
hasUnreadResponse: false,
|
||||||
currentRequestId: null,
|
currentRequestId: null,
|
||||||
@@ -1059,10 +1063,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
const item = await chatGateway.createConversation({
|
const item = await chatGateway.createConversation({
|
||||||
sessionId,
|
sessionId,
|
||||||
title: '새 대화',
|
title: '새 대화',
|
||||||
chatTypeId: selectedChatType?.id ?? null,
|
chatTypeId: nextConversationChatType?.id ?? null,
|
||||||
lastChatTypeId: selectedChatType?.id ?? null,
|
lastChatTypeId: nextConversationChatType?.id ?? null,
|
||||||
contextLabel: selectedChatType?.name,
|
contextLabel: nextConversationChatType?.name,
|
||||||
contextDescription: selectedChatType?.description,
|
contextDescription: nextConversationChatType?.description,
|
||||||
notifyOffline: true,
|
notifyOffline: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1384,6 +1388,63 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const previewItems = useMemo(
|
||||||
|
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const isTabletAppLayout = isMobileViewport;
|
||||||
|
const chatMessages = useMemo(
|
||||||
|
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
||||||
|
const activeConversation = useMemo(
|
||||||
|
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||||
|
[activeSessionId, conversationItems],
|
||||||
|
);
|
||||||
|
const persistedActiveChatTypeId =
|
||||||
|
activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null;
|
||||||
|
const effectiveChatType = useMemo(() => {
|
||||||
|
if (persistedActiveChatTypeId) {
|
||||||
|
const persistedChatType = chatTypes.find((item) => item.id === persistedActiveChatTypeId);
|
||||||
|
if (persistedChatType) {
|
||||||
|
return persistedChatType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: persistedActiveChatTypeId,
|
||||||
|
name: activeConversation?.contextLabel?.trim() || persistedActiveChatTypeId,
|
||||||
|
description: activeConversation?.contextDescription?.trim() || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedChatType;
|
||||||
|
}, [
|
||||||
|
activeConversation?.contextDescription,
|
||||||
|
activeConversation?.contextLabel,
|
||||||
|
chatTypes,
|
||||||
|
persistedActiveChatTypeId,
|
||||||
|
selectedChatType,
|
||||||
|
]);
|
||||||
|
const effectiveChatTypeId = effectiveChatType?.id ?? null;
|
||||||
|
const effectiveRegisteredChatType =
|
||||||
|
effectiveChatType ? chatTypes.find((item) => item.id === effectiveChatType.id) ?? null : null;
|
||||||
|
const isEffectiveChatTypeAllowed = effectiveRegisteredChatType
|
||||||
|
? canUseChatType(effectiveRegisteredChatType, userRoles)
|
||||||
|
: false;
|
||||||
|
const currentContext: ChatViewContext = {
|
||||||
|
pageId: currentPage.id,
|
||||||
|
pageTitle: currentPage.title,
|
||||||
|
topMenu: currentPage.topMenu,
|
||||||
|
focusedComponentId,
|
||||||
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||||
|
isStandaloneMode: isStandaloneDisplayMode(),
|
||||||
|
pageVisibilityState:
|
||||||
|
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||||
|
chatTypeId: effectiveChatType?.id ?? null,
|
||||||
|
chatTypeLabel: effectiveChatType?.name ?? '',
|
||||||
|
chatTypeDescription: effectiveChatType?.description ?? '',
|
||||||
|
};
|
||||||
const { socketRef, connectionState } = chatConnectionGateway.useConnection({
|
const { socketRef, connectionState } = chatConnectionGateway.useConnection({
|
||||||
sessionId: activeSessionId,
|
sessionId: activeSessionId,
|
||||||
currentContext,
|
currentContext,
|
||||||
@@ -1410,21 +1471,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
activeView,
|
activeView,
|
||||||
hasAccess,
|
hasAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewItems = useMemo(
|
|
||||||
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
|
|
||||||
[messages],
|
|
||||||
);
|
|
||||||
const isTabletAppLayout = isMobileViewport;
|
|
||||||
const chatMessages = useMemo(
|
|
||||||
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
|
|
||||||
[messages],
|
|
||||||
);
|
|
||||||
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
|
||||||
const activeConversation = useMemo(
|
|
||||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
|
||||||
[activeSessionId, conversationItems],
|
|
||||||
);
|
|
||||||
const activeRuntimeStatus = useMemo(
|
const activeRuntimeStatus = useMemo(
|
||||||
() => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId),
|
() => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId),
|
||||||
[runtimeSnapshot, activeSessionId],
|
[runtimeSnapshot, activeSessionId],
|
||||||
@@ -1576,7 +1622,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeView,
|
activeView,
|
||||||
previewItems,
|
previewItems,
|
||||||
selectedChatTypeId: selectedChatType?.id ?? null,
|
selectedChatTypeId,
|
||||||
composerRef,
|
composerRef,
|
||||||
setActiveSystemStatus,
|
setActiveSystemStatus,
|
||||||
setComposerAttachments,
|
setComposerAttachments,
|
||||||
@@ -1634,10 +1680,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
}, [activePreview, messageApi]);
|
}, [activePreview, messageApi]);
|
||||||
|
|
||||||
|
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
|
||||||
|
|
||||||
const canSearchActivePreview =
|
const canSearchActivePreview =
|
||||||
Boolean(activePreview) &&
|
Boolean(activePreview) &&
|
||||||
!isPreviewLoading &&
|
!isPreviewLoading &&
|
||||||
!previewError.trim() &&
|
!previewError.trim() &&
|
||||||
|
!(isActivePreviewHtml && isHtmlPreviewMode) &&
|
||||||
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
|
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
|
||||||
|
|
||||||
const resetActivePreviewSearchState = useCallback(() => {
|
const resetActivePreviewSearchState = useCallback(() => {
|
||||||
@@ -1673,6 +1722,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
clearActivePreviewSearchSelection();
|
clearActivePreviewSearchSelection();
|
||||||
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsHtmlPreviewMode(false);
|
||||||
|
}, [activePreview?.id, isPreviewModalOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetActivePreviewSearchState();
|
resetActivePreviewSearchState();
|
||||||
}, [previewFindQuery, resetActivePreviewSearchState]);
|
}, [previewFindQuery, resetActivePreviewSearchState]);
|
||||||
@@ -2254,25 +2307,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasSessionChanged = lastChatTypeSessionIdRef.current !== activeSessionId;
|
|
||||||
lastChatTypeSessionIdRef.current = activeSessionId;
|
|
||||||
|
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
if (hasSessionChanged) {
|
if (!activeConversation) {
|
||||||
const lastUsedChatTypeId =
|
return;
|
||||||
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
|
}
|
||||||
|
|
||||||
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
|
const persistedChatTypeId =
|
||||||
if (selectedChatTypeId !== lastUsedChatTypeId) {
|
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
|
||||||
setSelectedChatTypeId(lastUsedChatTypeId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultChatTypeId = availableChatTypes[0]?.id ?? null;
|
if (persistedChatTypeId) {
|
||||||
|
if (selectedChatTypeId !== persistedChatTypeId) {
|
||||||
if (selectedChatTypeId !== defaultChatTypeId) {
|
setSelectedChatTypeId(persistedChatTypeId);
|
||||||
setSelectedChatTypeId(defaultChatTypeId);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2283,34 +2328,38 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
||||||
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSessionId || !selectedChatTypeId) {
|
if (!activeSessionId || !selectedChatTypeId || !selectedChatType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
|
const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null;
|
||||||
setConversationItems((previous) =>
|
if (currentChatTypeId) {
|
||||||
previous.map((item) =>
|
|
||||||
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
|
|
||||||
? { ...item, lastChatTypeId: selectedChatTypeId }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
|
|
||||||
|
|
||||||
if (currentLastChatTypeId === selectedChatTypeId) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void chatGateway.updateConversation(activeSessionId, {
|
void chatGateway.updateConversation(activeSessionId, {
|
||||||
|
chatTypeId: selectedChatTypeId,
|
||||||
lastChatTypeId: selectedChatTypeId,
|
lastChatTypeId: selectedChatTypeId,
|
||||||
|
contextLabel: selectedChatType.name,
|
||||||
|
contextDescription: selectedChatType.description,
|
||||||
|
}).then((item) => {
|
||||||
|
setConversationItems((previous) =>
|
||||||
|
previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
|
||||||
|
);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Ignore background sync failures and keep local in-memory fallback.
|
// Ignore background sync failures and keep local in-memory fallback.
|
||||||
});
|
});
|
||||||
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
|
}, [
|
||||||
|
activeConversation?.chatTypeId,
|
||||||
|
activeConversation?.lastChatTypeId,
|
||||||
|
activeSessionId,
|
||||||
|
selectedChatType,
|
||||||
|
selectedChatTypeId,
|
||||||
|
setConversationItems,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
||||||
@@ -2600,7 +2649,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
draft,
|
draft,
|
||||||
composerAttachments,
|
composerAttachments,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
selectedChatType,
|
selectedChatType: effectiveChatType
|
||||||
|
? {
|
||||||
|
id: effectiveChatType.id,
|
||||||
|
name: effectiveChatType.name,
|
||||||
|
description: effectiveChatType.description,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
socketRef,
|
socketRef,
|
||||||
composerRef,
|
composerRef,
|
||||||
messagesRef,
|
messagesRef,
|
||||||
@@ -2614,7 +2669,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
setIsSystemStatusPending,
|
setIsSystemStatusPending,
|
||||||
setShowScrollToBottom,
|
setShowScrollToBottom,
|
||||||
setPendingContextConfirm,
|
setPendingContextConfirm,
|
||||||
setStoredChatSessionLastTypeId,
|
|
||||||
upsertRequestItem,
|
upsertRequestItem,
|
||||||
syncConversationPreviewForRequest,
|
syncConversationPreviewForRequest,
|
||||||
updatePendingMessageStatus,
|
updatePendingMessageStatus,
|
||||||
@@ -2924,7 +2978,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
isPullToLoadArmed={isPullToLoadArmed}
|
isPullToLoadArmed={isPullToLoadArmed}
|
||||||
pullToLoadDistance={pullToLoadDistance}
|
pullToLoadDistance={pullToLoadDistance}
|
||||||
requestStateMap={activeRequestMap}
|
requestStateMap={activeRequestMap}
|
||||||
selectedChatTypeId={selectedChatType?.id ?? null}
|
selectedChatTypeId={effectiveChatTypeId}
|
||||||
queuedRequests={activeQueuedComposerRequests.map((item, index) => ({
|
queuedRequests={activeQueuedComposerRequests.map((item, index) => ({
|
||||||
requestId: item.requestId,
|
requestId: item.requestId,
|
||||||
order: index + 1,
|
order: index + 1,
|
||||||
@@ -2933,7 +2987,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
chatTypeOptions={chatTypeOptions}
|
chatTypeOptions={chatTypeOptions}
|
||||||
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
|
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
|
||||||
isResourceStripOpen={isResourceStripOpen}
|
isResourceStripOpen={isResourceStripOpen}
|
||||||
isComposerDisabled={!selectedChatType}
|
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
|
||||||
|
isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())}
|
||||||
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
||||||
onViewportScroll={handleViewportScroll}
|
onViewportScroll={handleViewportScroll}
|
||||||
onViewportTouchEnd={handleViewportTouchEnd}
|
onViewportTouchEnd={handleViewportTouchEnd}
|
||||||
@@ -2944,7 +2999,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
onRemoveComposerAttachment={(attachmentId) => {
|
onRemoveComposerAttachment={(attachmentId) => {
|
||||||
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
|
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
|
||||||
}}
|
}}
|
||||||
onSelectChatType={setSelectedChatTypeId}
|
onSelectChatType={(nextChatTypeId) => {
|
||||||
|
if (activeConversation?.chatTypeId?.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedChatTypeId(nextChatTypeId);
|
||||||
|
}}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onSendImmediate={handleSendImmediate}
|
onSendImmediate={handleSendImmediate}
|
||||||
onClearDraft={() => {
|
onClearDraft={() => {
|
||||||
@@ -3061,6 +3122,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
<div className="app-chat-panel__preview-modal-title">
|
<div className="app-chat-panel__preview-modal-title">
|
||||||
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
|
{isActivePreviewHtml ? (
|
||||||
|
<Button
|
||||||
|
type={isHtmlPreviewMode ? 'default' : 'text'}
|
||||||
|
aria-label={isHtmlPreviewMode ? 'HTML 소스 보기' : 'HTML 미리보기'}
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setIsHtmlPreviewMode((current) => !current);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isHtmlPreviewMode ? '소스' : '미리보기'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{canSearchActivePreview ? (
|
{canSearchActivePreview ? (
|
||||||
<Button
|
<Button
|
||||||
type={isPreviewFindOpen ? 'default' : 'text'}
|
type={isPreviewFindOpen ? 'default' : 'text'}
|
||||||
@@ -3141,6 +3214,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
|||||||
previewError={previewError}
|
previewError={previewError}
|
||||||
previewContentType={previewContentType}
|
previewContentType={previewContentType}
|
||||||
maxMarkdownBlocks={undefined}
|
maxMarkdownBlocks={undefined}
|
||||||
|
renderHtmlAsFrame={isActivePreviewHtml && isHtmlPreviewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -961,16 +961,28 @@ export function MainHeader({
|
|||||||
const workServerPendingUpdateCount =
|
const workServerPendingUpdateCount =
|
||||||
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
||||||
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||||
|
const hasBuildRequiredUpdate =
|
||||||
|
Boolean(testServerStatus?.buildRequired) ||
|
||||||
|
Boolean(prodServerStatus?.buildRequired) ||
|
||||||
|
Boolean(workServerStatus?.buildRequired);
|
||||||
const totalAutomationShortcutCount =
|
const totalAutomationShortcutCount =
|
||||||
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
|
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
|
||||||
const settingsStatusClassName =
|
const settingsStatusClassName =
|
||||||
totalPendingUpdateCount >= 2
|
hasBuildRequiredUpdate
|
||||||
|
? 'app-header__status-dot--inactive'
|
||||||
|
: totalPendingUpdateCount >= 2
|
||||||
? 'app-header__status-dot--inactive'
|
? 'app-header__status-dot--inactive'
|
||||||
: totalPendingUpdateCount === 1
|
: totalPendingUpdateCount === 1
|
||||||
? 'app-header__status-dot--warning'
|
? 'app-header__status-dot--warning'
|
||||||
: 'app-header__status-dot--active';
|
: 'app-header__status-dot--active';
|
||||||
const settingsStatusLabel =
|
const settingsStatusLabel =
|
||||||
totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태';
|
hasBuildRequiredUpdate
|
||||||
|
? '커밋 미반영 업데이트 존재'
|
||||||
|
: totalPendingUpdateCount >= 2
|
||||||
|
? '모든 업데이트 존재'
|
||||||
|
: totalPendingUpdateCount === 1
|
||||||
|
? '업데이트 1건 존재'
|
||||||
|
: '최신 상태';
|
||||||
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
||||||
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
|
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
|
||||||
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
|
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
|
||||||
@@ -2832,7 +2844,7 @@ export function MainHeader({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={connectionIndicatorClassName}
|
className={`${connectionIndicatorClassName} app-header__connection-indicator--labelled`}
|
||||||
aria-label={chatConnectionLabel}
|
aria-label={chatConnectionLabel}
|
||||||
title={chatConnectionLabel}
|
title={chatConnectionLabel}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -2843,6 +2855,12 @@ export function MainHeader({
|
|||||||
<ApiOutlined />
|
<ApiOutlined />
|
||||||
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
|
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
|
||||||
</span>
|
</span>
|
||||||
|
<span className="app-header__connection-copy">
|
||||||
|
<span className="app-header__connection-title">트랜잭션</span>
|
||||||
|
<span className="app-header__connection-meta">
|
||||||
|
{hasPendingRuntimeWork ? `실행 ${runningRuntimeCount} · 대기 ${queuedRuntimeCount}` : '바로 열기'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{runningRuntimeCount > 0 ? (
|
{runningRuntimeCount > 0 ? (
|
||||||
<span
|
<span
|
||||||
className={connectionCountBadgeClassName}
|
className={connectionCountBadgeClassName}
|
||||||
|
|||||||
@@ -83,6 +83,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__connection-indicator--labelled {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 124px;
|
||||||
|
padding: 0 12px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__connection-indicator:hover {
|
.app-header__connection-indicator:hover {
|
||||||
background: #f3f7ff;
|
background: #f3f7ff;
|
||||||
}
|
}
|
||||||
@@ -140,6 +148,28 @@
|
|||||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22);
|
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__connection-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__connection-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #182230;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__connection-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes app-header-connection-badge-pulse {
|
@keyframes app-header-connection-badge-pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -487,6 +517,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main-layout:has(.chat-type-management-page) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -734,6 +770,17 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__connection-indicator--labelled {
|
||||||
|
min-width: 32px;
|
||||||
|
width: 32px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__connection-copy {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__runtime-summary {
|
.app-header__runtime-summary {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -764,6 +811,11 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main-layout:has(.chat-type-management-page) {
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main-window-layer {
|
.app-main-window-layer {
|
||||||
inset: 8px;
|
inset: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
|||||||
id: 'none',
|
id: 'none',
|
||||||
name: '기본유형',
|
name: '기본유형',
|
||||||
description:
|
description:
|
||||||
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
|
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||||
behaviorType: 'none',
|
behaviorType: 'none',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ export type ChatTypeInput = {
|
|||||||
const CHAT_TYPES_API_PATH = '/chat-types';
|
const CHAT_TYPES_API_PATH = '/chat-types';
|
||||||
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
|
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
|
||||||
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
|
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
|
||||||
const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
|
|
||||||
const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids';
|
|
||||||
const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids';
|
|
||||||
|
|
||||||
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
|
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
|
||||||
guest: '게스트',
|
guest: '게스트',
|
||||||
'token-user': '토큰 사용자',
|
'token-user': '토큰 사용자',
|
||||||
@@ -50,6 +46,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: '2026-04-16T00:00:00.000Z',
|
updatedAt: '2026-04-16T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'general-inquiry',
|
||||||
|
name: '일반 문의',
|
||||||
|
description:
|
||||||
|
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||||
|
permissions: ['token-user'],
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeText(value: string | null | undefined) {
|
function normalizeText(value: string | null | undefined) {
|
||||||
@@ -218,68 +223,6 @@ async function requestChatTypes<T>(init?: RequestInit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLegacyDeletedChatTypeIds() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return new Set<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rawDeletedIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
|
|
||||||
const rawLegacyDeletedDefaultIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
|
|
||||||
const deletedIds = [rawDeletedIds, rawLegacyDeletedDefaultIds]
|
|
||||||
.flatMap((raw) => {
|
|
||||||
if (!raw) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
})
|
|
||||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return new Set(deletedIds);
|
|
||||||
} catch {
|
|
||||||
return new Set<string>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLegacyChatTypes() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
|
|
||||||
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedIds = readLegacyDeletedChatTypeIds();
|
|
||||||
const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
|
|
||||||
return normalized;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLegacyChatTypeStorage() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChatTypesFromServer() {
|
async function fetchChatTypesFromServer() {
|
||||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -300,7 +243,6 @@ async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitChatTypesChange();
|
emitChatTypesChange();
|
||||||
clearLegacyChatTypeStorage();
|
|
||||||
return sanitizeChatTypes(response.chatTypes);
|
return sanitizeChatTypes(response.chatTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +275,17 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
|
|||||||
return sanitizeChatTypes(chatTypes);
|
return sanitizeChatTypes(chatTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
|
return sanitizeChatTypes(
|
||||||
|
chatTypes.map((item) =>
|
||||||
|
item.id === normalizedId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
enabled: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
||||||
@@ -359,13 +311,7 @@ export function useChatTypeRegistry() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const serverChatTypes = await fetchChatTypesFromServer();
|
const serverChatTypes = await fetchChatTypesFromServer();
|
||||||
let resolvedChatTypes = serverChatTypes;
|
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
|
||||||
|
|
||||||
if (resolvedChatTypes == null) {
|
|
||||||
const legacyChatTypes = readLegacyChatTypes();
|
|
||||||
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
|
|
||||||
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setChatTypesState(resolvedChatTypes);
|
setChatTypesState(resolvedChatTypes);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
type ChatPreviewKind,
|
type ChatPreviewKind,
|
||||||
type ChatPreviewTarget,
|
type ChatPreviewTarget,
|
||||||
} from '../../mainChatPanel/ChatPreviewBody';
|
} from '../../mainChatPanel/ChatPreviewBody';
|
||||||
|
import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls';
|
||||||
|
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers';
|
||||||
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
|
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
|
||||||
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
|
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
|
||||||
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||||
@@ -35,12 +37,12 @@ type ConversationRoomPaneProps = {
|
|||||||
|
|
||||||
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
||||||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
|
|
||||||
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
||||||
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
|
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
|
||||||
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
||||||
|
|
||||||
type MessageRenderPayload = {
|
type MessageRenderPayload = {
|
||||||
|
previewSourceText: string;
|
||||||
visibleText: string;
|
visibleText: string;
|
||||||
diffBlocks: string[];
|
diffBlocks: string[];
|
||||||
};
|
};
|
||||||
@@ -132,7 +134,7 @@ function downloadTextFile(content: string, fileName: string, mimeType = 'text/pl
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
|
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
|
||||||
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
|
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const targets: ChatPreviewTarget[] = [];
|
const targets: ChatPreviewTarget[] = [];
|
||||||
|
|
||||||
@@ -220,12 +222,10 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
|
|||||||
.map((match) => match[1]?.trim())
|
.map((match) => match[1]?.trim())
|
||||||
.filter((value): value is string => Boolean(value));
|
.filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
const visibleText = text
|
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||||
.replace(DIFF_CODE_BLOCK_PATTERN, '')
|
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return { visibleText, diffBlocks };
|
return { previewSourceText, visibleText, diffBlocks };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyCollapsibleMessage(text: string) {
|
function isLikelyCollapsibleMessage(text: string) {
|
||||||
@@ -574,8 +574,8 @@ export function ConversationRoomPane({
|
|||||||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||||||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||||||
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
||||||
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||||
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
|
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||||
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
||||||
const shouldRenderStandalonePreview =
|
const shouldRenderStandalonePreview =
|
||||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ type UseConversationComposerControllerOptions = {
|
|||||||
setIsSystemStatusPending: (value: boolean) => void;
|
setIsSystemStatusPending: (value: boolean) => void;
|
||||||
setShowScrollToBottom: (value: boolean) => void;
|
setShowScrollToBottom: (value: boolean) => void;
|
||||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||||
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
|
|
||||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
||||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||||
@@ -95,7 +94,6 @@ export function useConversationComposerController({
|
|||||||
setIsSystemStatusPending,
|
setIsSystemStatusPending,
|
||||||
setShowScrollToBottom,
|
setShowScrollToBottom,
|
||||||
setPendingContextConfirm,
|
setPendingContextConfirm,
|
||||||
setStoredChatSessionLastTypeId,
|
|
||||||
upsertRequestItem,
|
upsertRequestItem,
|
||||||
syncConversationPreviewForRequest,
|
syncConversationPreviewForRequest,
|
||||||
updatePendingMessageStatus,
|
updatePendingMessageStatus,
|
||||||
@@ -181,8 +179,6 @@ export function useConversationComposerController({
|
|||||||
failed: false,
|
failed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
|
|
||||||
|
|
||||||
if (mode === 'queue') {
|
if (mode === 'queue') {
|
||||||
const queuedAt = new Date().toISOString();
|
const queuedAt = new Date().toISOString();
|
||||||
const optimisticUserMessage: ChatMessage = {
|
const optimisticUserMessage: ChatMessage = {
|
||||||
@@ -302,7 +298,6 @@ export function useConversationComposerController({
|
|||||||
setIsSystemStatusPending,
|
setIsSystemStatusPending,
|
||||||
setMessages,
|
setMessages,
|
||||||
setShowScrollToBottom,
|
setShowScrollToBottom,
|
||||||
setStoredChatSessionLastTypeId,
|
|
||||||
shouldStickToBottomRef,
|
shouldStickToBottomRef,
|
||||||
socketRef,
|
socketRef,
|
||||||
syncConversationPreviewForRequest,
|
syncConversationPreviewForRequest,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { InlineImage } from '../../../components/common/InlineImage';
|
import { InlineImage } from '../../../components/common/InlineImage';
|
||||||
import { CodexDiffBlock } from '../../../components/previewer';
|
import { CodexDiffBlock } from '../../../components/previewer';
|
||||||
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||||
|
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||||
import { copyPreviewContent, copyText } from './chatUtils';
|
import { copyPreviewContent, copyText } from './chatUtils';
|
||||||
@@ -83,7 +84,6 @@ type PreviewFetchError = Error & {
|
|||||||
status?: number;
|
status?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
|
|
||||||
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
||||||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||||
@@ -92,6 +92,7 @@ const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
|||||||
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
type MessageRenderPayload = {
|
type MessageRenderPayload = {
|
||||||
|
previewSourceText: string;
|
||||||
visibleText: string;
|
visibleText: string;
|
||||||
diffBlocks: string[];
|
diffBlocks: string[];
|
||||||
};
|
};
|
||||||
@@ -169,6 +170,21 @@ function buildPreviewFileName(item: PreviewOption) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
|
||||||
|
switch (kind) {
|
||||||
|
case 'image':
|
||||||
|
case 'video':
|
||||||
|
case 'markdown':
|
||||||
|
case 'code':
|
||||||
|
case 'diff':
|
||||||
|
case 'document':
|
||||||
|
case 'pdf':
|
||||||
|
return kind;
|
||||||
|
default:
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||||
let responseMessage = '';
|
let responseMessage = '';
|
||||||
@@ -199,7 +215,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||||
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
|
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const targets: InlinePreviewTarget[] = [];
|
const targets: InlinePreviewTarget[] = [];
|
||||||
|
|
||||||
@@ -293,9 +309,11 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
|
|||||||
.map((match) => match[1]?.trim())
|
.map((match) => match[1]?.trim())
|
||||||
.filter((value): value is string => Boolean(value));
|
.filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
|
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||||
|
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
previewSourceText,
|
||||||
visibleText,
|
visibleText,
|
||||||
diffBlocks,
|
diffBlocks,
|
||||||
};
|
};
|
||||||
@@ -688,6 +706,7 @@ type ChatConversationViewProps = {
|
|||||||
previewItems: PreviewOption[];
|
previewItems: PreviewOption[];
|
||||||
isResourceStripOpen: boolean;
|
isResourceStripOpen: boolean;
|
||||||
isComposerDisabled: boolean;
|
isComposerDisabled: boolean;
|
||||||
|
isChatTypeSelectionLocked: boolean;
|
||||||
isComposerAttachmentUploading: boolean;
|
isComposerAttachmentUploading: boolean;
|
||||||
onViewportScroll: () => void;
|
onViewportScroll: () => void;
|
||||||
onViewportTouchEnd: () => void;
|
onViewportTouchEnd: () => void;
|
||||||
@@ -733,6 +752,7 @@ export function ChatConversationView({
|
|||||||
previewItems,
|
previewItems,
|
||||||
isResourceStripOpen,
|
isResourceStripOpen,
|
||||||
isComposerDisabled,
|
isComposerDisabled,
|
||||||
|
isChatTypeSelectionLocked,
|
||||||
isComposerAttachmentUploading,
|
isComposerAttachmentUploading,
|
||||||
onViewportScroll,
|
onViewportScroll,
|
||||||
onViewportTouchEnd,
|
onViewportTouchEnd,
|
||||||
@@ -756,6 +776,7 @@ export function ChatConversationView({
|
|||||||
}: ChatConversationViewProps) {
|
}: ChatConversationViewProps) {
|
||||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||||
|
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
|
||||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||||
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
||||||
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
||||||
@@ -1191,17 +1212,22 @@ export function ChatConversationView({
|
|||||||
</label>
|
</label>
|
||||||
<div className="app-chat-panel__resource-strip-list">
|
<div className="app-chat-panel__resource-strip-list">
|
||||||
{visiblePreviewItems.map((item) => (
|
{visiblePreviewItems.map((item) => (
|
||||||
<button
|
<InlineMessagePreview
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
target={{
|
||||||
className="app-chat-panel__resource-chip"
|
label: item.label,
|
||||||
onClick={() => {
|
url: item.url,
|
||||||
onOpenPreview(item.id);
|
kind: normalizePreviewOptionKind(item.kind),
|
||||||
}}
|
}}
|
||||||
>
|
isExpanded={expandedResourcePreviewKey === item.id}
|
||||||
<span>{item.label}</span>
|
hasModalPreview
|
||||||
<span>{item.kind}</span>
|
onOpenModalPreview={() => {
|
||||||
</button>
|
onOpenPreview(item.id, { fullscreen: true });
|
||||||
|
}}
|
||||||
|
onToggle={() => {
|
||||||
|
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1248,13 +1274,13 @@ export function ChatConversationView({
|
|||||||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||||||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||||||
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
||||||
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||||
|
|
||||||
if (isActivityLogMessage(message)) {
|
if (isActivityLogMessage(message)) {
|
||||||
return renderActivityCard(message);
|
return renderActivityCard(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
|
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||||
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
||||||
const shouldRenderStandalonePreview =
|
const shouldRenderStandalonePreview =
|
||||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||||
@@ -1498,7 +1524,7 @@ export function ChatConversationView({
|
|||||||
),
|
),
|
||||||
}))}
|
}))}
|
||||||
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
||||||
disabled={chatTypeOptions.length === 0}
|
disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked}
|
||||||
onChange={onSelectChatType}
|
onChange={onSelectChatType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -203,6 +203,33 @@ function canRenderFramePreview(url: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHtmlFrameDocument(html: string, sourceUrl: string) {
|
||||||
|
const trimmed = html.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return '<!doctype html><html><body></body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHref = (() => {
|
||||||
|
try {
|
||||||
|
return new URL('.', sourceUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const baseTag = `<base href="${baseHref}">`;
|
||||||
|
|
||||||
|
if (/<head(\s|>)/i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/<head(\s*[^>]*)>/i, (match) => `${match}${baseTag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/<html(\s|>)/i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/<html(\s*[^>]*)>/i, (match) => `${match}<head>${baseTag}</head>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!doctype html><html><head>${baseTag}</head><body>${trimmed}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
type ChatPreviewBodyProps = {
|
type ChatPreviewBodyProps = {
|
||||||
target: ChatPreviewTarget | null;
|
target: ChatPreviewTarget | null;
|
||||||
previewText: string;
|
previewText: string;
|
||||||
@@ -210,6 +237,7 @@ type ChatPreviewBodyProps = {
|
|||||||
previewError: string;
|
previewError: string;
|
||||||
previewContentType?: string;
|
previewContentType?: string;
|
||||||
maxMarkdownBlocks?: number;
|
maxMarkdownBlocks?: number;
|
||||||
|
renderHtmlAsFrame?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
||||||
@@ -238,6 +266,7 @@ export function ChatPreviewBody({
|
|||||||
previewError,
|
previewError,
|
||||||
previewContentType,
|
previewContentType,
|
||||||
maxMarkdownBlocks,
|
maxMarkdownBlocks,
|
||||||
|
renderHtmlAsFrame = false,
|
||||||
}: ChatPreviewBodyProps) {
|
}: ChatPreviewBodyProps) {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
||||||
@@ -307,6 +336,16 @@ export function ChatPreviewBody({
|
|||||||
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
|
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
|
||||||
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
||||||
|
|
||||||
|
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
title={target.label}
|
||||||
|
srcDoc={buildHtmlFrameDocument(previewText, target.url)}
|
||||||
|
className="app-chat-panel__preview-frame"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
|
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||||
|
|||||||
26
src/app/main/mainChatPanel/inlinePreviewUrls.ts
Normal file
26
src/app/main/mainChatPanel/inlinePreviewUrls.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const AUTO_DETECTED_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s<>)\]]+|\/[A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]+)/g;
|
||||||
|
|
||||||
|
export function extractAutoDetectedPreviewUrls(text: string) {
|
||||||
|
const normalized = String(text ?? '');
|
||||||
|
const urls: string[] = [];
|
||||||
|
|
||||||
|
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
|
||||||
|
const value = match[0]?.trim();
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = match.index ?? -1;
|
||||||
|
const previousChar = startIndex > 0 ? normalized[startIndex - 1] : '';
|
||||||
|
|
||||||
|
// Ignore HTML closing tags like </div> that were being misread as same-origin routes.
|
||||||
|
if (previousChar === '<') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
urls.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
|
DownOutlined,
|
||||||
CheckSquareOutlined,
|
CheckSquareOutlined,
|
||||||
CompressOutlined,
|
CloseOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
ArrowsAltOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
ExpandOutlined,
|
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
PaperClipOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
|
ShrinkOutlined,
|
||||||
|
UpOutlined,
|
||||||
|
UploadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import type { ChangeEvent, RefObject } from 'react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
|
||||||
import {
|
import {
|
||||||
buildAutomationTypeOptions,
|
buildAutomationTypeOptions,
|
||||||
resolveAutomationTypeLabel,
|
resolveAutomationTypeLabel,
|
||||||
@@ -27,7 +35,7 @@ import {
|
|||||||
setupBoard,
|
setupBoard,
|
||||||
updateBoardPost,
|
updateBoardPost,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type { BoardDraft, BoardPost } from './types';
|
import type { BoardAttachment, BoardDraft, BoardPost } from './types';
|
||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -36,15 +44,68 @@ const EMPTY_DRAFT: BoardDraft = {
|
|||||||
id: null,
|
id: null,
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
attachments: [],
|
||||||
automationType: 'none',
|
automationType: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createBoardAttachmentSessionId() {
|
||||||
|
const randomValue =
|
||||||
|
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
return `board-draft-${randomValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
return new Date(value).toLocaleString('ko-KR', {
|
return new Date(value).toLocaleString('ko-KR', {
|
||||||
timeZone: 'Asia/Seoul',
|
timeZone: 'Asia/Seoul',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 1024 * 1024) {
|
||||||
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 1024) {
|
||||||
|
return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(value)} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeBoardAttachments(current: BoardAttachment[], next: BoardAttachment[]) {
|
||||||
|
const merged = [...current];
|
||||||
|
const existingPaths = new Set(current.map((item) => item.path));
|
||||||
|
|
||||||
|
next.forEach((item) => {
|
||||||
|
if (existingPaths.has(item.path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPaths.add(item.path);
|
||||||
|
merged.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBoardAttachmentSessionId(
|
||||||
|
draftId: number | null,
|
||||||
|
draftAttachmentSessionIdRef: RefObject<string>,
|
||||||
|
) {
|
||||||
|
if (draftId) {
|
||||||
|
return `board-post-${draftId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftAttachmentSessionIdRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
async function copyText(value: string) {
|
async function copyText(value: string) {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
@@ -122,6 +183,8 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
|
|||||||
export function BoardPage() {
|
export function BoardPage() {
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const { automationTypes } = useAutomationTypeRegistry();
|
const { automationTypes } = useAutomationTypeRegistry();
|
||||||
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const draftAttachmentSessionIdRef = useRef<string>(createBoardAttachmentSessionId());
|
||||||
const [items, setItems] = useState<BoardPost[]>([]);
|
const [items, setItems] = useState<BoardPost[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||||
@@ -129,34 +192,15 @@ export function BoardPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [attachmentUploading, setAttachmentUploading] = useState(false);
|
||||||
const [automationReceiving, setAutomationReceiving] = useState(false);
|
const [automationReceiving, setAutomationReceiving] = useState(false);
|
||||||
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
|
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
|
||||||
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
||||||
const [contentExpanded, setContentExpanded] = useState(false);
|
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||||
|
const [attachmentsExpanded, setAttachmentsExpanded] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
|
||||||
const update = () => {
|
|
||||||
setIsMobileViewport(mediaQuery.matches);
|
|
||||||
if (!mediaQuery.matches) {
|
|
||||||
setMobileDetailOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
update();
|
|
||||||
mediaQuery.addEventListener('change', update);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mediaQuery.removeEventListener('change', update);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -193,11 +237,32 @@ export function BoardPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||||
|
const update = () => {
|
||||||
|
setIsMobileViewport(mediaQuery.matches);
|
||||||
|
if (!mediaQuery.matches) {
|
||||||
|
setMobileView('edit');
|
||||||
|
}
|
||||||
|
setAttachmentsExpanded(!mediaQuery.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
mediaQuery.addEventListener('change', update);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectedItem = useMemo(
|
const selectedItem = useMemo(
|
||||||
() => items.find((item) => item.id === selectedId) ?? null,
|
() => items.find((item) => item.id === selectedId) ?? null,
|
||||||
[items, selectedId],
|
[items, selectedId],
|
||||||
);
|
);
|
||||||
const showMobileDetailOnly = isMobileViewport && mobileDetailOpen;
|
|
||||||
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
|
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
|
||||||
const isDraftLocked = automationReceived;
|
const isDraftLocked = automationReceived;
|
||||||
const draftDirty = Boolean(
|
const draftDirty = Boolean(
|
||||||
@@ -218,6 +283,7 @@ export function BoardPage() {
|
|||||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||||
[automationTypes, draft.automationType],
|
[automationTypes, draft.automationType],
|
||||||
);
|
);
|
||||||
|
const isPaneMaximized = maximizedPane !== 'none';
|
||||||
const receivableIds = useMemo(
|
const receivableIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items
|
items
|
||||||
@@ -236,6 +302,7 @@ export function BoardPage() {
|
|||||||
id: selectedItem.id,
|
id: selectedItem.id,
|
||||||
title: selectedItem.title,
|
title: selectedItem.title,
|
||||||
content: selectedItem.content,
|
content: selectedItem.content,
|
||||||
|
attachments: selectedItem.attachments,
|
||||||
automationType: selectedItem.automationType,
|
automationType: selectedItem.automationType,
|
||||||
});
|
});
|
||||||
setAutomationReceiveError(null);
|
setAutomationReceiveError(null);
|
||||||
@@ -252,10 +319,82 @@ export function BoardPage() {
|
|||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const handleCreateDraft = () => {
|
const handleCreateDraft = () => {
|
||||||
|
draftAttachmentSessionIdRef.current = createBoardAttachmentSessionId();
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setDraft(EMPTY_DRAFT);
|
setDraft(EMPTY_DRAFT);
|
||||||
setAutomationReceiveError(null);
|
setAutomationReceiveError(null);
|
||||||
setMobileDetailOpen(isMobileViewport);
|
setMaximizedPane('none');
|
||||||
|
setMobileView('edit');
|
||||||
|
setDetailMode('detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDetail = (itemId: number) => {
|
||||||
|
setSelectedId(itemId);
|
||||||
|
setAutomationReceiveError(null);
|
||||||
|
setMaximizedPane('none');
|
||||||
|
setMobileView('edit');
|
||||||
|
setDetailMode('detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetail = () => {
|
||||||
|
setAutomationReceiveError(null);
|
||||||
|
setMaximizedPane('none');
|
||||||
|
setDetailMode('list');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachmentFilesPicked = async (files: File[]) => {
|
||||||
|
if (files.length === 0 || attachmentUploading || isDraftLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachmentUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = resolveBoardAttachmentSessionId(draft.id, draftAttachmentSessionIdRef);
|
||||||
|
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
|
||||||
|
const uploadedItems: BoardAttachment[] = [];
|
||||||
|
const failedFileNames: string[] = [];
|
||||||
|
|
||||||
|
uploadResults.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
uploadedItems.push(result.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadedItems.length > 0) {
|
||||||
|
setDraft((previous) => ({
|
||||||
|
...previous,
|
||||||
|
attachments: mergeBoardAttachments(previous.attachments, uploadedItems),
|
||||||
|
}));
|
||||||
|
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 추가했습니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedFileNames.length > 0) {
|
||||||
|
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAttachmentUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachmentInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
event.target.value = '';
|
||||||
|
void handleAttachmentFilesPicked(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAttachment = (attachmentId: string) => {
|
||||||
|
if (isDraftLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft((previous) => ({
|
||||||
|
...previous,
|
||||||
|
attachments: previous.attachments.filter((attachment) => attachment.id !== attachmentId),
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -293,7 +432,7 @@ export function BoardPage() {
|
|||||||
return [savedItem, ...filtered];
|
return [savedItem, ...filtered];
|
||||||
});
|
});
|
||||||
setSelectedId(savedItem.id);
|
setSelectedId(savedItem.id);
|
||||||
setMobileDetailOpen(isMobileViewport);
|
setDetailMode('detail');
|
||||||
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
|
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
|
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
|
||||||
@@ -315,7 +454,7 @@ export function BoardPage() {
|
|||||||
setItems((previous) => previous.filter((item) => item.id !== draft.id));
|
setItems((previous) => previous.filter((item) => item.id !== draft.id));
|
||||||
setSelectedId((previous) => (previous === draft.id ? null : previous));
|
setSelectedId((previous) => (previous === draft.id ? null : previous));
|
||||||
setDraft(EMPTY_DRAFT);
|
setDraft(EMPTY_DRAFT);
|
||||||
setMobileDetailOpen(false);
|
setDetailMode('list');
|
||||||
messageApi.success('게시글을 삭제했습니다.');
|
messageApi.success('게시글을 삭제했습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
|
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
|
||||||
@@ -441,52 +580,56 @@ export function BoardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={16} className="board-page">
|
<div
|
||||||
|
className={`board-page${detailMode === 'detail' ? ' board-page--detail' : ''}${
|
||||||
|
isPaneMaximized ? ' board-page--pane-maximized' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Card className="board-page__card" bordered={false}>
|
<input
|
||||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
ref={attachmentInputRef}
|
||||||
<div>
|
type="file"
|
||||||
<Title level={4} className="board-page__title">
|
multiple
|
||||||
Plan
|
className="board-page__hidden-file-input"
|
||||||
</Title>
|
onChange={handleAttachmentInputChange}
|
||||||
<Paragraph className="board-page__copy">
|
/>
|
||||||
마크다운 본문을 입력하고 즉시 프리뷰를 확인한 뒤 DB에 저장합니다.
|
<Space direction="vertical" size={16} className="board-page__stack">
|
||||||
</Paragraph>
|
{detailMode === 'list' ? (
|
||||||
</div>
|
<Card className="board-page__card board-page__overview-card" bordered={false}>
|
||||||
<Space wrap>
|
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||||
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
<div>
|
||||||
새 글
|
<Title level={4} className="board-page__title">
|
||||||
</Button>
|
작업 요청
|
||||||
<Button
|
</Title>
|
||||||
type="primary"
|
<Paragraph className="board-page__copy">
|
||||||
icon={<SaveOutlined />}
|
제목, 자동화 유형, 첨부 파일을 한 번에 정리하고 저장 후 바로 자동화 접수합니다.
|
||||||
loading={saving}
|
</Paragraph>
|
||||||
disabled={isDraftLocked}
|
</div>
|
||||||
onClick={() => {
|
<Space wrap>
|
||||||
void handleSave();
|
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
||||||
}}
|
새 글
|
||||||
>
|
</Button>
|
||||||
저장
|
</Space>
|
||||||
</Button>
|
</Flex>
|
||||||
</Space>
|
</Card>
|
||||||
</Flex>
|
) : null}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage && detailMode === 'list' ? (
|
||||||
<Card className="board-page__card" bordered={false}>
|
<Card className="board-page__card" bordered={false}>
|
||||||
<Text type="danger">{errorMessage}</Text>
|
<Text type="danger">{errorMessage}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="board-page__grid">
|
{detailMode === 'list' ? (
|
||||||
<Card
|
<Card
|
||||||
title={`게시글 목록 (${items.length})`}
|
title={`게시글 목록 (${items.length})`}
|
||||||
className={`board-page__card board-page__list-card${
|
className="board-page__card board-page__list-card"
|
||||||
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
|
|
||||||
}`}
|
|
||||||
bordered={false}
|
bordered={false}
|
||||||
extra={
|
extra={
|
||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
||||||
|
새 글
|
||||||
|
</Button>
|
||||||
{loading ? <Spin size="small" /> : null}
|
{loading ? <Spin size="small" /> : null}
|
||||||
<Text type="secondary" className="board-page__bulk-count">
|
<Text type="secondary" className="board-page__bulk-count">
|
||||||
선택 {checkedReceivableCount}건
|
선택 {checkedReceivableCount}건
|
||||||
@@ -528,15 +671,12 @@ export function BoardPage() {
|
|||||||
<List
|
<List
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
|
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(item.id);
|
handleOpenDetail(item.id);
|
||||||
if (isMobileViewport) {
|
}}
|
||||||
setMobileDetailOpen(true);
|
>
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -562,6 +702,7 @@ export function BoardPage() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
<Space size={6} wrap>
|
<Space size={6} wrap>
|
||||||
{item.id === dirtyDraftId ? <Tag color="warning">저장 필요</Tag> : null}
|
{item.id === dirtyDraftId ? <Tag color="warning">저장 필요</Tag> : null}
|
||||||
|
{item.attachments.length ? <Tag color="blue">첨부 {item.attachments.length}</Tag> : null}
|
||||||
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
|
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
|
||||||
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
|
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -584,151 +725,335 @@ export function BoardPage() {
|
|||||||
<Empty description="등록된 게시글이 없습니다." />
|
<Empty description="등록된 게시글이 없습니다." />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
<div
|
<div className="board-page__editor-column">
|
||||||
className={`board-page__editor-column${
|
|
||||||
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Card
|
<Card
|
||||||
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
|
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
|
||||||
className="board-page__card board-page__editor-card"
|
className={`board-page__card board-page__editor-card${isPaneMaximized ? ' board-page__editor-card--pane-maximized' : ''}`}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap className="board-page__header-actions">
|
||||||
{isMobileViewport && mobileDetailOpen ? (
|
<Button icon={<ArrowLeftOutlined />} aria-label="목록으로" title="목록으로" onClick={handleCloseDetail} />
|
||||||
<Button
|
<Button icon={<PlusOutlined />} onClick={handleCreateDraft} aria-label="새 글" title="새 글" />
|
||||||
icon={<ArrowLeftOutlined />}
|
<Button
|
||||||
onClick={() => {
|
type="primary"
|
||||||
setMobileDetailOpen(false);
|
icon={<SaveOutlined />}
|
||||||
}}
|
aria-label="저장"
|
||||||
>
|
title="저장"
|
||||||
목록
|
loading={saving}
|
||||||
</Button>
|
disabled={isDraftLocked}
|
||||||
) : null}
|
onClick={() => {
|
||||||
|
void handleSave();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
|
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
|
||||||
{draft.id && selectedItem?.automationPlanItemId ? (
|
{draft.id && selectedItem?.automationPlanItemId ? (
|
||||||
<Button
|
<Button
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
aria-label="연결 자동화 열기"
|
||||||
|
title="연결 자동화 열기"
|
||||||
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
|
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
/>
|
||||||
연결자동화
|
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
{draft.id ? (
|
{draft.id ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
|
aria-label={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
||||||
|
title={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
||||||
loading={automationReceiving}
|
loading={automationReceiving}
|
||||||
disabled={automationReceived && !automationReceiveError}
|
disabled={automationReceived && !automationReceiveError}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleAutomationReceive();
|
void handleAutomationReceive();
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{draft.id ? (
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
loading={deleting}
|
|
||||||
disabled={isDraftLocked}
|
|
||||||
onClick={() => {
|
|
||||||
void handleDelete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label="삭제"
|
||||||
|
title="삭제"
|
||||||
|
loading={deleting}
|
||||||
|
disabled={!draft.id || isDraftLocked}
|
||||||
|
onClick={() => {
|
||||||
|
void handleDelete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={16} className="board-page__editor">
|
<div className="board-page__editor">
|
||||||
<Input
|
{errorMessage ? <Text type="danger">{errorMessage}</Text> : null}
|
||||||
size="large"
|
<div className="board-page__editor-scroll">
|
||||||
placeholder="제목을 입력하세요"
|
<div className={`board-page__meta-stack${isPaneMaximized ? ' board-page__meta-stack--hidden' : ''}`}>
|
||||||
value={draft.title}
|
<div className="board-page__hero">
|
||||||
readOnly={isDraftLocked}
|
<div className="board-page__hero-main">
|
||||||
onChange={(event) => {
|
<div className="board-page__field-label-row">
|
||||||
setDraft((previous) => ({
|
<Text strong>요청 제목</Text>
|
||||||
...previous,
|
<Flex gap={8} wrap>
|
||||||
title: event.target.value,
|
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
|
||||||
}));
|
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
|
||||||
}}
|
</Flex>
|
||||||
/>
|
</div>
|
||||||
<Flex gap={8} wrap>
|
<Input
|
||||||
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
|
size="large"
|
||||||
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
|
placeholder="예: 작업요청 입력 폼을 전면 개편하고 첨부 자동 전달 연결"
|
||||||
</Flex>
|
value={draft.title}
|
||||||
<div className="board-page__automation-field">
|
readOnly={isDraftLocked}
|
||||||
<Text strong>자동화 처리</Text>
|
onChange={(event) => {
|
||||||
{automationReceived ? (
|
setDraft((previous) => ({
|
||||||
<div className="board-page__automation-readonly" aria-readonly="true">
|
...previous,
|
||||||
<Text>{automationTypeLabel}</Text>
|
title: event.target.value,
|
||||||
<Tag color="processing">접수 후 읽기전용</Tag>
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="board-page__hero-side">
|
||||||
|
<div className="board-page__automation-field">
|
||||||
|
<div className="board-page__field-label-row">
|
||||||
|
<Text strong>자동화 처리</Text>
|
||||||
|
{automationReceived ? <Tag color="processing">접수 후 읽기전용</Tag> : null}
|
||||||
|
</div>
|
||||||
|
{automationReceived ? (
|
||||||
|
<div className="board-page__automation-readonly" aria-readonly="true">
|
||||||
|
<Text>{automationTypeLabel}</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
className="board-page__automation-select"
|
||||||
|
value={draft.automationType}
|
||||||
|
options={automationTypeOptions}
|
||||||
|
popupClassName="board-page__automation-select-popup"
|
||||||
|
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||||
|
disabled={isDraftLocked}
|
||||||
|
onChange={(automationType) => {
|
||||||
|
setDraft((previous) => ({
|
||||||
|
...previous,
|
||||||
|
automationType,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="board-page__attachment-panel">
|
||||||
<Select
|
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||||
className="board-page__automation-select"
|
<div>
|
||||||
value={draft.automationType}
|
<div className="board-page__field-label-row">
|
||||||
options={automationTypeOptions}
|
<Text strong>첨부 파일</Text>
|
||||||
popupClassName="board-page__automation-select-popup"
|
<Tag color={draft.attachments.length ? 'blue' : 'default'}>
|
||||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
{draft.attachments.length}건
|
||||||
disabled={isDraftLocked}
|
</Tag>
|
||||||
onChange={(automationType) => {
|
</div>
|
||||||
setDraft((previous) => ({
|
<Text type="secondary">자동화 접수 시 아래 첨부 파일 경로가 작업 메모에 자동으로 포함됩니다.</Text>
|
||||||
...previous,
|
</div>
|
||||||
automationType,
|
<Space size={8}>
|
||||||
}));
|
<Button
|
||||||
}}
|
type="text"
|
||||||
/>
|
icon={attachmentsExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||||
)}
|
aria-label={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
|
||||||
|
title={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
|
||||||
|
onClick={() => {
|
||||||
|
setAttachmentsExpanded((current) => !current);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
aria-label="파일 추가"
|
||||||
|
title="파일 추가"
|
||||||
|
loading={attachmentUploading}
|
||||||
|
disabled={isDraftLocked}
|
||||||
|
onClick={() => {
|
||||||
|
attachmentInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
{attachmentsExpanded ? draft.attachments.length ? (
|
||||||
|
<div className="board-page__attachment-grid">
|
||||||
|
{draft.attachments.map((attachment) => (
|
||||||
|
<div key={attachment.id} className="board-page__attachment-card">
|
||||||
|
<Flex justify="space-between" align="start" gap={12}>
|
||||||
|
<Flex vertical gap={6} className="board-page__attachment-copy">
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<PaperClipOutlined className="board-page__attachment-icon" />
|
||||||
|
<Text strong ellipsis={{ tooltip: attachment.name }}>
|
||||||
|
{attachment.name}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary">{formatBytes(attachment.size)}</Text>
|
||||||
|
<Text type="secondary" className="board-page__attachment-path">
|
||||||
|
{attachment.path}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Space size={6}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
href={attachment.publicUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
disabled={isDraftLocked}
|
||||||
|
onClick={() => {
|
||||||
|
handleRemoveAttachment(attachment.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="첨부 파일이 없습니다." />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="board-page__markdown-field">
|
||||||
|
<Text strong className={`board-page__field-label${isPaneMaximized ? ' board-page__field-label--hidden' : ''}`}>
|
||||||
|
본문
|
||||||
|
</Text>
|
||||||
|
<div className="board-page__markdown-editor">
|
||||||
|
<div className="board-page__editor-toolbar">
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<Segmented
|
||||||
|
className="board-page__mobile-toggle"
|
||||||
|
options={[
|
||||||
|
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
|
||||||
|
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
|
||||||
|
]}
|
||||||
|
value={mobileView}
|
||||||
|
onChange={(value) => {
|
||||||
|
setMobileView(value as 'edit' | 'preview');
|
||||||
|
setMaximizedPane('none');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Space size={8} wrap className="board-page__desktop-toolbar">
|
||||||
|
<Button
|
||||||
|
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||||
|
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
|
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||||
|
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||||
|
onClick={() => {
|
||||||
|
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||||
|
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
|
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||||
|
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||||
|
onClick={() => {
|
||||||
|
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`board-page__preview-grid${
|
||||||
|
isPaneMaximized ? ' board-page__preview-grid--maximized' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`board-page__pane${
|
||||||
|
mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''
|
||||||
|
}${maximizedPane === 'preview' ? ' board-page__pane--desktop-hidden' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="board-page__pane-header">
|
||||||
|
<Text type="secondary">편집</Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
aria-label="본문 복사"
|
||||||
|
title="본문 복사"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyContent();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||||
|
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
|
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||||
|
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
|
||||||
|
onClick={() => {
|
||||||
|
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
value={draft.content}
|
||||||
|
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
|
||||||
|
readOnly={isDraftLocked}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft((previous) => ({
|
||||||
|
...previous,
|
||||||
|
content: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="board-page__textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`board-page__pane${
|
||||||
|
mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''
|
||||||
|
}${maximizedPane === 'edit' ? ' board-page__pane--desktop-hidden' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="board-page__preview">
|
||||||
|
<div className="board-page__pane-header">
|
||||||
|
<Text type="secondary">미리보기</Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
aria-label="본문 복사"
|
||||||
|
title="본문 복사"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyContent();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||||
|
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||||
|
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||||
|
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||||
|
onClick={() => {
|
||||||
|
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="board-page__preview-content">
|
||||||
|
{draft.content.trim() ? (
|
||||||
|
<MarkdownPreviewContent content={draft.content} />
|
||||||
|
) : (
|
||||||
|
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDraftLocked ? (
|
||||||
|
<Text type="secondary">자동화 접수된 작업메모는 게시판에서 수정하거나 삭제할 수 없습니다.</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>
|
{isPaneMaximized ? (
|
||||||
{contentExpanded ? (
|
<div className="board-page__floating-toolbar">
|
||||||
<Flex justify="space-between" align="center" gap={12} className="board-page__editor-toolbar">
|
|
||||||
<Text strong>본문 전체화면</Text>
|
|
||||||
<Space size={8}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label="본문 복사"
|
|
||||||
title="본문 복사"
|
|
||||||
onClick={() => {
|
|
||||||
void handleCopyContent();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
복사
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CompressOutlined />}
|
|
||||||
aria-label="본문 최대화 해제"
|
|
||||||
onClick={() => {
|
|
||||||
setContentExpanded(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
닫기
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Flex>
|
|
||||||
) : null}
|
|
||||||
<Segmented
|
|
||||||
className="board-page__mobile-toggle"
|
|
||||||
options={[
|
|
||||||
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
|
|
||||||
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
|
|
||||||
]}
|
|
||||||
value={mobileView}
|
|
||||||
onChange={(value) => {
|
|
||||||
setMobileView(value as 'edit' | 'preview');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Flex justify="space-between" align="center" gap={8}>
|
|
||||||
<Text type="secondary">본문</Text>
|
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<Button
|
<Button
|
||||||
|
size="small"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
aria-label="본문 복사"
|
aria-label="본문 복사"
|
||||||
title="본문 복사"
|
title="본문 복사"
|
||||||
@@ -737,60 +1062,22 @@ export function BoardPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />}
|
size="small"
|
||||||
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
icon={<ShrinkOutlined />}
|
||||||
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
aria-label="편집 보기로 복귀"
|
||||||
|
title="편집 보기로 복귀"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContentExpanded((previous) => !previous);
|
setMaximizedPane('none');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
|
||||||
<div
|
|
||||||
className={`board-page__preview-grid${contentExpanded ? ' board-page__preview-grid--expanded' : ''}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`board-page__pane${mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''}${
|
|
||||||
contentExpanded ? ' board-page__pane--expanded' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={draft.content}
|
|
||||||
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
|
|
||||||
readOnly={isDraftLocked}
|
|
||||||
onChange={(event) => {
|
|
||||||
setDraft((previous) => ({
|
|
||||||
...previous,
|
|
||||||
content: event.target.value,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className={`board-page__textarea${contentExpanded ? ' board-page__textarea--expanded' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`board-page__pane${mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''}${
|
|
||||||
contentExpanded ? ' board-page__pane--expanded' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`board-page__preview${contentExpanded ? ' board-page__preview--expanded' : ''}`}>
|
|
||||||
<div className="board-page__preview-content">
|
|
||||||
{draft.content.trim() ? (
|
|
||||||
<MarkdownPreviewContent content={draft.content} />
|
|
||||||
) : (
|
|
||||||
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isDraftLocked ? (
|
) : null}
|
||||||
<Text type="secondary">자동화 접수된 작업메모는 게시판에서 수정하거나 삭제할 수 없습니다.</Text>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||||
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
|
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
|
||||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||||
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
|
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost } from './types';
|
||||||
|
|
||||||
class BoardApiError extends Error {
|
class BoardApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -17,6 +17,37 @@ function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
|
|||||||
return normalizeAutomationTypeId(value);
|
return normalizeAutomationTypeId(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBoardAttachment(item: unknown): BoardAttachment | null {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = item as Partial<BoardAttachment>;
|
||||||
|
const id = String(candidate.id ?? '').trim();
|
||||||
|
const path = String(candidate.path ?? '').trim();
|
||||||
|
|
||||||
|
if (!id || !path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: String(candidate.name ?? '').trim() || path.split('/').pop() || '첨부 파일',
|
||||||
|
path,
|
||||||
|
publicUrl: String(candidate.publicUrl ?? '').trim() || path,
|
||||||
|
size: Math.max(0, Number(candidate.size ?? 0) || 0),
|
||||||
|
mimeType: String(candidate.mimeType ?? '').trim() || 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoardPost(item: BoardPost): BoardPost {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
automationType: normalizeBoardAutomationType(item.automationType),
|
||||||
|
attachments: Array.isArray(item.attachments) ? item.attachments.map(normalizeBoardAttachment).filter(Boolean) as BoardAttachment[] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBoardApiBaseUrl() {
|
function resolveBoardApiBaseUrl() {
|
||||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||||
@@ -134,10 +165,7 @@ export async function setupBoard() {
|
|||||||
|
|
||||||
export async function fetchBoardPosts() {
|
export async function fetchBoardPosts() {
|
||||||
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
|
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
|
||||||
return response.items.map((item) => ({
|
return response.items.map((item) => normalizeBoardPost(item));
|
||||||
...item,
|
|
||||||
automationType: normalizeBoardAutomationType(item.automationType),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBoardPost(draft: BoardDraft) {
|
export async function createBoardPost(draft: BoardDraft) {
|
||||||
@@ -146,14 +174,12 @@ export async function createBoardPost(draft: BoardDraft) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
content: draft.content,
|
content: draft.content,
|
||||||
|
attachments: draft.attachments,
|
||||||
automationType: draft.automationType,
|
automationType: draft.automationType,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return normalizeBoardPost(response.item);
|
||||||
...response.item,
|
|
||||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBoardPost(draft: BoardDraft) {
|
export async function updateBoardPost(draft: BoardDraft) {
|
||||||
@@ -166,14 +192,12 @@ export async function updateBoardPost(draft: BoardDraft) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
content: draft.content,
|
content: draft.content,
|
||||||
|
attachments: draft.attachments,
|
||||||
automationType: draft.automationType,
|
automationType: draft.automationType,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return normalizeBoardPost(response.item);
|
||||||
...response.item,
|
|
||||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function receiveBoardPostAutomation(id: number) {
|
export async function receiveBoardPostAutomation(id: number) {
|
||||||
@@ -188,10 +212,7 @@ export async function receiveBoardPostAutomation(id: number) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item: {
|
item: normalizeBoardPost(response.item),
|
||||||
...response.item,
|
|
||||||
automationType: normalizeBoardAutomationType(response.item.automationType),
|
|
||||||
},
|
|
||||||
planItemId: response.planItemId,
|
planItemId: response.planItemId,
|
||||||
alreadyReceived: response.alreadyReceived,
|
alreadyReceived: response.alreadyReceived,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
export type BoardAutomationType = string;
|
export type BoardAutomationType = string;
|
||||||
|
|
||||||
|
export type BoardAttachment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
publicUrl: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BoardPost = {
|
export type BoardPost = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
attachments: BoardAttachment[];
|
||||||
automationType: BoardAutomationType;
|
automationType: BoardAutomationType;
|
||||||
automationPlanItemId: number | null;
|
automationPlanItemId: number | null;
|
||||||
automationReceivedAt: string | null;
|
automationReceivedAt: string | null;
|
||||||
@@ -16,5 +26,6 @@ export type BoardDraft = {
|
|||||||
id: number | null;
|
id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments: BoardAttachment[];
|
||||||
automationType: BoardAutomationType;
|
automationType: BoardAutomationType;
|
||||||
};
|
};
|
||||||
|
|||||||
406
src/styles.css
406
src/styles.css
@@ -1022,7 +1022,44 @@ button,
|
|||||||
|
|
||||||
.board-page {
|
.board-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page--detail {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__stack.ant-space,
|
||||||
|
.board-page__stack.ant-space > .ant-space-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__stack.ant-space {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page--detail .board-page__stack.ant-space > .ant-space-item,
|
||||||
|
.board-page--detail .board-page__editor-column,
|
||||||
|
.board-page--detail .board-page__editor-card,
|
||||||
|
.board-page--detail .board-page__editor-card .ant-card-body,
|
||||||
|
.board-page--detail .board-page__editor,
|
||||||
|
.board-page--detail .board-page__editor-scroll,
|
||||||
|
.board-page--detail .board-page__markdown-field,
|
||||||
|
.board-page--detail .board-page__markdown-editor,
|
||||||
|
.board-page--detail .board-page__preview-grid,
|
||||||
|
.board-page--detail .board-page__pane,
|
||||||
|
.board-page--detail .board-page__preview {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__card {
|
.board-page__card {
|
||||||
@@ -1031,6 +1068,18 @@ button,
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-page .ant-card,
|
||||||
|
.board-page .ant-card-body,
|
||||||
|
.board-page__card .ant-card-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page .ant-card,
|
||||||
|
.board-page__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.board-page__title.ant-typography {
|
.board-page__title.ant-typography {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
@@ -1039,17 +1088,12 @@ button,
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-page__editor-column {
|
.board-page__editor-column {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__list-card .ant-card-body,
|
.board-page__list-card .ant-card-body,
|
||||||
@@ -1057,6 +1101,16 @@ button,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-page__list-card .ant-card-head,
|
||||||
|
.board-page__editor-card .ant-card-head {
|
||||||
|
padding-inline: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__list-card .ant-card-body,
|
||||||
|
.board-page__editor-card .ant-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.board-page__list-card--mobile-hidden,
|
.board-page__list-card--mobile-hidden,
|
||||||
.board-page__editor-column--mobile-hidden {
|
.board-page__editor-column--mobile-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -1065,7 +1119,18 @@ button,
|
|||||||
.board-page__editor-card,
|
.board-page__editor-card,
|
||||||
.board-page__editor-card .ant-card-body {
|
.board-page__editor-card .ant-card-body {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible;
|
}
|
||||||
|
|
||||||
|
.board-page__editor-card .ant-card-body {
|
||||||
|
height: 100%;
|
||||||
|
min-height: calc(100vh - 240px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__editor-card--pane-maximized .ant-card-body {
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__list-item {
|
.board-page__list-item {
|
||||||
@@ -1103,18 +1168,42 @@ button,
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor.ant-space {
|
.board-page__editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor.ant-space > .ant-space-item {
|
.board-page__editor-scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor.ant-space > .ant-space-item:last-child {
|
.board-page__header-actions {
|
||||||
min-height: 0;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__meta-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__meta-stack--hidden {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__automation-field {
|
.board-page__automation-field {
|
||||||
@@ -1124,10 +1213,72 @@ button,
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__automation-readonly {
|
.board-page__hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__hero-main,
|
||||||
|
.board-page__hero-side,
|
||||||
|
.board-page__attachment-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__field-label-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-panel {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid rgba(22, 93, 255, 0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 248, 255, 0.98) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.88),
|
||||||
|
0 14px 32px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(22, 93, 255, 0.14);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-icon {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-path {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__automation-readonly {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
@@ -1170,29 +1321,47 @@ button,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor-frame {
|
.board-page__hidden-file-input {
|
||||||
position: relative;
|
display: none;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor-frame--expanded {
|
.board-page__markdown-field {
|
||||||
position: fixed;
|
width: 100%;
|
||||||
inset: 0;
|
flex: 1 1 auto;
|
||||||
z-index: 1300;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 6px;
|
||||||
padding: 24px;
|
overflow: hidden;
|
||||||
background: #fff;
|
}
|
||||||
|
|
||||||
|
.board-page__field-label {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__field-label--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__markdown-editor {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor-toolbar {
|
.board-page__editor-toolbar {
|
||||||
flex: 0 0 auto;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__desktop-toolbar {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__preview-grid {
|
.board-page__preview-grid {
|
||||||
@@ -1201,63 +1370,65 @@ button,
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-page__preview-grid--expanded {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__preview-grid--maximized {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__pane {
|
.board-page__pane {
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__pane--expanded {
|
.board-page__pane--desktop-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__pane-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 0;
|
align-items: center;
|
||||||
width: 100%;
|
justify-content: space-between;
|
||||||
min-height: 0;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__textarea.ant-input {
|
.board-page__textarea.ant-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
font-family:
|
font-family:
|
||||||
'JetBrains Mono', 'D2Coding', 'Fira Code', Consolas, monospace;
|
'JetBrains Mono', 'D2Coding', 'Fira Code', Consolas, monospace;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
resize: vertical;
|
resize: none;
|
||||||
}
|
|
||||||
|
|
||||||
.board-page__textarea--expanded.ant-input {
|
|
||||||
min-height: calc(100vh - 140px);
|
|
||||||
height: calc(100vh - 140px);
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__preview {
|
.board-page__preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
border: 1px solid rgba(22, 93, 255, 0.12);
|
border: 1px solid rgba(22, 93, 255, 0.12);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__preview-content {
|
.board-page__preview-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
overflow: auto;
|
||||||
|
|
||||||
.board-page__preview--expanded {
|
|
||||||
min-height: calc(100vh - 140px);
|
|
||||||
height: calc(100vh - 140px);
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__loading {
|
.board-page__loading {
|
||||||
@@ -1266,6 +1437,16 @@ button,
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-page__floating-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.96) 28%);
|
||||||
|
}
|
||||||
|
|
||||||
.release-pending-main-modal .ant-modal-content {
|
.release-pending-main-modal .ant-modal-content {
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
@@ -1493,6 +1674,48 @@ button,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
.board-page {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page .ant-card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page--detail .board-page__editor-card .ant-card-body {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__overview-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__list-card .ant-card-head,
|
||||||
|
.board-page__editor-card .ant-card-head {
|
||||||
|
min-height: 48px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__list-card .ant-card-head-title,
|
||||||
|
.board-page__list-card .ant-card-extra,
|
||||||
|
.board-page__editor-card .ant-card-head-title,
|
||||||
|
.board-page__editor-card .ant-card-extra,
|
||||||
|
.board-page__list-card .ant-card-body,
|
||||||
|
.board-page__editor-card .ant-card-body {
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__list-card .ant-card-head-title,
|
||||||
|
.board-page__list-card .ant-card-extra,
|
||||||
|
.board-page__editor-card .ant-card-head-title,
|
||||||
|
.board-page__editor-card .ant-card-extra {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.history-page__filter-grid {
|
.history-page__filter-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1505,10 +1728,25 @@ button,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__grid {
|
.board-page__hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-page__attachment-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__editor-column,
|
||||||
|
.board-page__editor,
|
||||||
|
.board-page__editor-scroll,
|
||||||
|
.board-page__markdown-field,
|
||||||
|
.board-page__markdown-editor,
|
||||||
|
.board-page__preview-grid,
|
||||||
|
.board-page__pane,
|
||||||
|
.board-page__preview {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.history-page__list-card .ant-card-body,
|
.history-page__list-card .ant-card-body,
|
||||||
.history-page__detail-card .ant-card-body,
|
.history-page__detail-card .ant-card-body,
|
||||||
.chat-source-changes-page__list-card .ant-card-body,
|
.chat-source-changes-page__list-card .ant-card-body,
|
||||||
@@ -1518,13 +1756,7 @@ button,
|
|||||||
|
|
||||||
.board-page__textarea.ant-input,
|
.board-page__textarea.ant-input,
|
||||||
.board-page__preview {
|
.board-page__preview {
|
||||||
min-height: 360px;
|
min-height: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.board-page__textarea--expanded.ant-input,
|
|
||||||
.board-page__preview--expanded {
|
|
||||||
min-height: calc(100vh - 148px);
|
|
||||||
height: calc(100vh - 148px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-pending-main-modal .ant-modal {
|
.release-pending-main-modal .ant-modal {
|
||||||
@@ -1554,20 +1786,28 @@ button,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-page__mobile-toggle {
|
.board-page__mobile-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__preview-grid {
|
.board-page__editor-toolbar {
|
||||||
grid-template-columns: 1fr;
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__editor-frame--expanded {
|
.board-page__desktop-toolbar {
|
||||||
padding: 16px;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__field-label-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__preview-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-page__automation-readonly {
|
.board-page__automation-readonly {
|
||||||
@@ -1579,6 +1819,36 @@ button,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-page__textarea.ant-input {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__textarea.ant-input textarea {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: auto !important;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__preview {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__preview-content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__header-actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-page__pane-header .ant-space {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.release-review-page__toolbar {
|
.release-review-page__toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user