feat: refresh shared chat and server workflows
This commit is contained in:
172
etc/commands/server-command/restart-work-server.sh
Normal file → Executable file
172
etc/commands/server-command/restart-work-server.sh
Normal file → Executable file
@@ -5,7 +5,177 @@ 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"
|
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
||||||
|
PROXY_SERVICE="${WORK_SERVER_PROXY_SERVICE:-work-server}"
|
||||||
|
PROXY_CONTAINER="${WORK_SERVER_PROXY_CONTAINER:-work-server}"
|
||||||
|
BLUE_SERVICE="${WORK_SERVER_BLUE_SERVICE:-work-server-blue}"
|
||||||
|
GREEN_SERVICE="${WORK_SERVER_GREEN_SERVICE:-work-server-green}"
|
||||||
|
BLUE_CONTAINER="${WORK_SERVER_BLUE_CONTAINER:-work-server-blue}"
|
||||||
|
GREEN_CONTAINER="${WORK_SERVER_GREEN_CONTAINER:-work-server-green}"
|
||||||
|
ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/active-slot}"
|
||||||
|
PROXY_CONFIG_FILE="${WORK_SERVER_PROXY_CONFIG_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/proxy/default.conf}"
|
||||||
|
HEALTH_ENDPOINT="${WORK_SERVER_HEALTH_ENDPOINT:-http://127.0.0.1:3100/health}"
|
||||||
|
RUNTIME_ENDPOINT="${WORK_SERVER_RUNTIME_ENDPOINT:-http://127.0.0.1:3100/api/runtime}"
|
||||||
|
PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}"
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
|
mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")"
|
||||||
|
|
||||||
|
read_active_slot() {
|
||||||
|
if [ -f "$ACTIVE_SLOT_FILE" ]; then
|
||||||
|
SLOT=$(tr -d '[:space:]' <"$ACTIVE_SLOT_FILE")
|
||||||
|
if [ "$SLOT" = "blue" ] || [ "$SLOT" = "green" ]; then
|
||||||
|
printf '%s' "$SLOT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
container_is_running() {
|
||||||
|
CONTAINER_NAME="$1"
|
||||||
|
STATUS=$(docker inspect -f '{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)
|
||||||
|
[ "$STATUS" = "running" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_active_slot() {
|
||||||
|
SLOT=$(read_active_slot)
|
||||||
|
|
||||||
|
if [ "$SLOT" = "blue" ] && ! container_is_running "$BLUE_CONTAINER" && container_is_running "$GREEN_CONTAINER"; then
|
||||||
|
printf 'green'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$SLOT" = "green" ] && ! container_is_running "$GREEN_CONTAINER" && container_is_running "$BLUE_CONTAINER"; then
|
||||||
|
printf 'blue'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$SLOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_proxy_config() {
|
||||||
|
SLOT="$1"
|
||||||
|
TARGET_CONTAINER="$BLUE_CONTAINER"
|
||||||
|
|
||||||
|
if [ "$SLOT" = "green" ]; then
|
||||||
|
TARGET_CONTAINER="$GREEN_CONTAINER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >"$PROXY_CONFIG_FILE" <<EOF2
|
||||||
|
server {
|
||||||
|
listen 3100;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /ws/chat {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host \$host;
|
||||||
|
proxy_set_header X-Forwarded-Port \$server_port;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_pass http://$TARGET_CONTAINER:3100;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host \$host;
|
||||||
|
proxy_set_header X-Forwarded-Port \$server_port;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_pass http://$TARGET_CONTAINER:3100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF2
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_container_health() {
|
||||||
|
TARGET_CONTAINER="$1"
|
||||||
|
ATTEMPT=0
|
||||||
|
|
||||||
|
while [ "$ATTEMPT" -lt 60 ]; do
|
||||||
|
if docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then(async (response) => { if (!response.ok) process.exit(1); process.stdout.write(await response.text()); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "health check failed for $TARGET_CONTAINER" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read_runtime_value() {
|
||||||
|
TARGET_CONTAINER="$1"
|
||||||
|
FIELD_NAME="$2"
|
||||||
|
|
||||||
|
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then((response) => response.json()).then((payload) => { const value = payload?.[process.argv[2]]; if (typeof value === 'boolean') { process.stdout.write(value ? 'true' : 'false'); return; } if (value == null) { process.stdout.write(''); return; } process.stdout.write(String(value)); }).catch(() => process.exit(1));" "$RUNTIME_ENDPOINT" "$FIELD_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_container_draining() {
|
||||||
|
TARGET_CONTAINER="$1"
|
||||||
|
DRAINING_VALUE="$2"
|
||||||
|
|
||||||
|
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ draining: process.argv[2] === 'true' }) }).then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));" "${RUNTIME_ENDPOINT}/drain" "$DRAINING_VALUE"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_previous_slot_drain() {
|
||||||
|
TARGET_CONTAINER="$1"
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while [ "$ELAPSED" -lt "$PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS" ]; do
|
||||||
|
ACTIVE_COUNT=$(read_runtime_value "$TARGET_CONTAINER" activeChatRequestCount 2>/dev/null || printf '0')
|
||||||
|
QUEUED_COUNT=$(read_runtime_value "$TARGET_CONTAINER" queuedChatRequestCount 2>/dev/null || printf '0')
|
||||||
|
|
||||||
|
if [ "${ACTIVE_COUNT:-0}" = "0" ] && [ "${QUEUED_COUNT:-0}" = "0" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
ELAPSED=$((ELAPSED + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "drain timeout reached for $TARGET_CONTAINER" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_proxy_running() {
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --no-deps "$PROXY_SERVICE" >/dev/null
|
||||||
|
docker exec "$PROXY_CONTAINER" nginx -s reload >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTIVE_SLOT=$(resolve_active_slot)
|
||||||
|
TARGET_SLOT="green"
|
||||||
|
TARGET_SERVICE="$GREEN_SERVICE"
|
||||||
|
TARGET_CONTAINER="$GREEN_CONTAINER"
|
||||||
|
PREVIOUS_SERVICE="$BLUE_SERVICE"
|
||||||
|
PREVIOUS_CONTAINER="$BLUE_CONTAINER"
|
||||||
|
|
||||||
|
if [ "$ACTIVE_SLOT" = "green" ]; then
|
||||||
|
TARGET_SLOT="blue"
|
||||||
|
TARGET_SERVICE="$BLUE_SERVICE"
|
||||||
|
TARGET_CONTAINER="$BLUE_CONTAINER"
|
||||||
|
PREVIOUS_SERVICE="$GREEN_SERVICE"
|
||||||
|
PREVIOUS_CONTAINER="$GREEN_CONTAINER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE"
|
||||||
|
wait_for_container_health "$TARGET_CONTAINER"
|
||||||
|
write_proxy_config "$TARGET_SLOT"
|
||||||
|
ensure_proxy_running
|
||||||
|
printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE"
|
||||||
|
|
||||||
|
if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then
|
||||||
|
set_container_draining "$PREVIOUS_CONTAINER" true
|
||||||
|
wait_for_previous_slot_drain "$PREVIOUS_CONTAINER"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE"
|
||||||
|
wait_for_container_health "$PREVIOUS_CONTAINER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'work-server zero-downtime switch completed: %s -> %s\n' "$ACTIVE_SLOT" "$TARGET_SLOT"
|
||||||
|
|||||||
1
etc/servers/work-server/.gitignore
vendored
1
etc/servers/work-server/.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
.dist-verify-actual
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ docker compose up -d
|
|||||||
docker compose logs -f work-server
|
docker compose logs -f work-server
|
||||||
```
|
```
|
||||||
|
|
||||||
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
`work-server`는 `3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
|
||||||
|
|
||||||
|
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f work-server work-server-blue work-server-green
|
||||||
|
```
|
||||||
|
|
||||||
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
|
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,9 +1,25 @@
|
|||||||
services:
|
services:
|
||||||
work-server:
|
work-server:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: work-server
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "200m"
|
||||||
|
max-file: "2"
|
||||||
|
mem_limit: 256m
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:3100:3100'
|
||||||
|
volumes:
|
||||||
|
- ./.docker/proxy/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
networks:
|
||||||
|
- work-backend
|
||||||
|
|
||||||
|
work-server-blue:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: work-server
|
container_name: work-server-blue
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -19,8 +35,6 @@ services:
|
|||||||
required: false
|
required: false
|
||||||
- path: ./.env
|
- path: ./.env
|
||||||
required: false
|
required: false
|
||||||
ports:
|
|
||||||
- '127.0.0.1:3100:3100'
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- work-server-node-modules:/app/node_modules
|
- work-server-node-modules:/app/node_modules
|
||||||
@@ -43,6 +57,57 @@ services:
|
|||||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||||
WORK_SERVER_DIST_DIR: /app/dist
|
WORK_SERVER_DIST_DIR: /app/dist
|
||||||
|
WORK_SERVER_SLOT: blue
|
||||||
|
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||||
|
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||||
|
networks:
|
||||||
|
- work-backend
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
|
||||||
|
work-server-green:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: work-server-green
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "200m"
|
||||||
|
max-file: "2"
|
||||||
|
user: "0:0"
|
||||||
|
group_add:
|
||||||
|
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||||
|
mem_limit: 2048m
|
||||||
|
working_dir: /app
|
||||||
|
env_file:
|
||||||
|
- path: ./.env.example
|
||||||
|
required: false
|
||||||
|
- path: ./.env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- work-server-node-modules:/app/node_modules
|
||||||
|
- ../../../:/workspace/main-project
|
||||||
|
- ../../../.auto_codex:/workspace/auto_codex
|
||||||
|
- ../../../scripts:/workspace/repo-scripts:ro
|
||||||
|
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||||
|
- ./.docker/home:/home/how2ice
|
||||||
|
- ./.docker/codex-home:/codex-home
|
||||||
|
- ./.docker/codex-home-template:/codex-home-template
|
||||||
|
environment:
|
||||||
|
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||||
|
HOME: /home/how2ice
|
||||||
|
CODEX_HOME: /codex-home
|
||||||
|
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
|
||||||
|
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
|
||||||
|
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
|
||||||
|
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
|
||||||
|
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||||
|
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||||
|
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||||
|
WORK_SERVER_DIST_DIR: /app/dist
|
||||||
|
WORK_SERVER_SLOT: green
|
||||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { registerDdlRoutes } from './routes/ddl.js';
|
|||||||
import { registerErrorLogRoutes } from './routes/error-log.js';
|
import { registerErrorLogRoutes } from './routes/error-log.js';
|
||||||
import { registerHealthRoutes } from './routes/health.js';
|
import { registerHealthRoutes } from './routes/health.js';
|
||||||
import { registerAppConfigRoutes } from './routes/app-config.js';
|
import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||||
|
import { registerBaseballTicketBayRoutes } from './routes/baseball-ticket-bay.js';
|
||||||
import { registerChatRoutes } from './routes/chat.js';
|
import { registerChatRoutes } from './routes/chat.js';
|
||||||
import { registerNotificationRoutes } from './routes/notification.js';
|
import { registerNotificationRoutes } from './routes/notification.js';
|
||||||
import { registerPlanRoutes } from './routes/plan.js';
|
import { registerPlanRoutes } from './routes/plan.js';
|
||||||
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
||||||
import { registerReaderRoutes } from './routes/reader.js';
|
import { registerReaderRoutes } from './routes/reader.js';
|
||||||
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
||||||
|
import { registerRuntimeRoutes } from './routes/runtime.js';
|
||||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||||
import { registerSchemaRoutes } from './routes/schema.js';
|
import { registerSchemaRoutes } from './routes/schema.js';
|
||||||
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
|
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
|
||||||
@@ -22,6 +24,20 @@ import { registerTextMemoRoutes } from './routes/text-memo.js';
|
|||||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||||
import { createErrorLog } from './services/error-log-service.js';
|
import { createErrorLog } from './services/error-log-service.js';
|
||||||
|
import {
|
||||||
|
isRuntimeDraining,
|
||||||
|
trackHttpRequestFinished,
|
||||||
|
trackHttpRequestStarted,
|
||||||
|
} from './services/runtime-drain-service.js';
|
||||||
|
|
||||||
|
function isDrainAllowedPath(method: string, url: string) {
|
||||||
|
return method === 'OPTIONS'
|
||||||
|
|| url === '/'
|
||||||
|
|| url === '/api'
|
||||||
|
|| url === '/health'
|
||||||
|
|| url.startsWith('/api/runtime')
|
||||||
|
|| url.startsWith('/api/server-commands');
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -35,10 +51,37 @@ export function createApp() {
|
|||||||
origin: true,
|
origin: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
|
trackHttpRequestStarted();
|
||||||
|
let finished = false;
|
||||||
|
const finalize = () => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = true;
|
||||||
|
trackHttpRequestFinished();
|
||||||
|
reply.raw.off('finish', finalize);
|
||||||
|
reply.raw.off('close', finalize);
|
||||||
|
};
|
||||||
|
|
||||||
|
reply.raw.on('finish', finalize);
|
||||||
|
reply.raw.on('close', finalize);
|
||||||
|
|
||||||
|
if (isRuntimeDraining() && !isDrainAllowedPath(request.method, request.url)) {
|
||||||
|
reply.code(503).send({
|
||||||
|
ok: false,
|
||||||
|
message: '이 서버는 배포 전환 중이라 새 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
});
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
registerJsonBodyParser(app);
|
registerJsonBodyParser(app);
|
||||||
app.register(registerBoardRoutes);
|
app.register(registerBoardRoutes);
|
||||||
app.register(registerHealthRoutes);
|
app.register(registerHealthRoutes);
|
||||||
app.register(registerAppConfigRoutes);
|
app.register(registerAppConfigRoutes);
|
||||||
|
app.register(registerBaseballTicketBayRoutes);
|
||||||
app.register(registerChatRoutes);
|
app.register(registerChatRoutes);
|
||||||
app.register(registerSchemaRoutes);
|
app.register(registerSchemaRoutes);
|
||||||
app.register(registerDdlRoutes);
|
app.register(registerDdlRoutes);
|
||||||
@@ -51,6 +94,7 @@ export function createApp() {
|
|||||||
app.register(registerPhotoPrismRoutes);
|
app.register(registerPhotoPrismRoutes);
|
||||||
app.register(registerReaderRoutes);
|
app.register(registerReaderRoutes);
|
||||||
app.register(registerResourceManagerRoutes);
|
app.register(registerResourceManagerRoutes);
|
||||||
|
app.register(registerRuntimeRoutes);
|
||||||
app.register(registerSharedResourceTokenRoutes);
|
app.register(registerSharedResourceTokenRoutes);
|
||||||
app.register(registerServerCommandRoutes);
|
app.register(registerServerCommandRoutes);
|
||||||
app.register(registerTextMemoRoutes);
|
app.register(registerTextMemoRoutes);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const envSchema = z.object({
|
|||||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
||||||
|
SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE: z.string().optional(),
|
||||||
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
||||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||||
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
|
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
|
||||||
|
|||||||
205
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
205
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
createBaseballTicketBayAlert,
|
||||||
|
createBaseballTicketBayLog,
|
||||||
|
deleteBaseballTicketBayLog,
|
||||||
|
deleteBaseballTicketBayAlert,
|
||||||
|
listBaseballTicketBayAlerts,
|
||||||
|
listBaseballTicketBayLogs,
|
||||||
|
runBaseballTicketBayAlert,
|
||||||
|
searchBaseballTicketBayListings,
|
||||||
|
updateBaseballTicketBayAlert,
|
||||||
|
} from '../services/baseball-ticket-bay-service.js';
|
||||||
|
|
||||||
|
const timeWindowSchema = z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
start: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||||
|
end: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const alertPayloadSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(255),
|
||||||
|
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
team: z.string().trim().min(1).max(50),
|
||||||
|
zone: z.string().trim().min(1).max(100),
|
||||||
|
aisleSide: z.string().trim().min(1).max(100),
|
||||||
|
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10),
|
||||||
|
maxPrice: z.number().finite().positive().nullable(),
|
||||||
|
seatCount: z.number().int().positive().max(10),
|
||||||
|
batchIntervalMinutes: z.number().int().min(1).max(120),
|
||||||
|
sameProductAlertEnabled: z.boolean(),
|
||||||
|
sameProductNotifyOnce: z.boolean(),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
timeWindows: z.array(timeWindowSchema).min(1).max(24),
|
||||||
|
});
|
||||||
|
|
||||||
|
function readHeader(request: { headers: Record<string, string | string[] | undefined> }, key: string) {
|
||||||
|
const raw = request.headers[key];
|
||||||
|
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||||
|
app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {}));
|
||||||
|
|
||||||
|
app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림 목록을 불러올 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items: await listBaseballTicketBayAlerts(clientId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 불러올 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items: await listBaseballTicketBayLogs(clientId, query.alertId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 삭제할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||||
|
const item = await deleteBaseballTicketBayLog(params.id, clientId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 저장할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = alertPayloadSchema.parse(request.body ?? {});
|
||||||
|
const item = await createBaseballTicketBayAlert(payload, {
|
||||||
|
clientId,
|
||||||
|
appOrigin: readHeader(request, 'x-app-origin'),
|
||||||
|
appDomain: readHeader(request, 'x-app-domain'),
|
||||||
|
});
|
||||||
|
await createBaseballTicketBayLog({
|
||||||
|
clientId,
|
||||||
|
alertId: item.id,
|
||||||
|
alertTitle: item.title,
|
||||||
|
action: 'create',
|
||||||
|
status: 'info',
|
||||||
|
message: '알림 조건을 저장했습니다.',
|
||||||
|
detail: `${item.team} · ${item.eventDate}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 수정할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||||
|
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
|
||||||
|
const item = await updateBaseballTicketBayAlert(params.id, payload, {
|
||||||
|
clientId,
|
||||||
|
appOrigin: readHeader(request, 'x-app-origin'),
|
||||||
|
appDomain: readHeader(request, 'x-app-domain'),
|
||||||
|
});
|
||||||
|
await createBaseballTicketBayLog({
|
||||||
|
clientId,
|
||||||
|
alertId: item.id,
|
||||||
|
alertTitle: item.title,
|
||||||
|
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
|
||||||
|
status: 'info',
|
||||||
|
message:
|
||||||
|
payload.active === false
|
||||||
|
? '알림을 중지했습니다.'
|
||||||
|
: payload.active === true
|
||||||
|
? '알림을 다시 실행 상태로 전환했습니다.'
|
||||||
|
: '알림 조건을 수정 저장했습니다.',
|
||||||
|
detail: `${item.team} · ${item.eventDate}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 삭제할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||||
|
const item = await deleteBaseballTicketBayAlert(params.id, clientId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await createBaseballTicketBayLog({
|
||||||
|
clientId,
|
||||||
|
alertId: item.id,
|
||||||
|
alertTitle: item.title,
|
||||||
|
action: 'delete',
|
||||||
|
status: 'info',
|
||||||
|
message: '알림 항목을 삭제했습니다.',
|
||||||
|
detail: `${item.team} · ${item.eventDate}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
|
||||||
|
const clientId = readHeader(request, 'x-client-id');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return reply.code(400).send({ message: '클라이언트 ID가 없어 즉시 실행할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||||
|
const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
alert: result.alert,
|
||||||
|
matches: result.matches,
|
||||||
|
notifiedMatches: result.notifiedMatches,
|
||||||
|
log: result.log,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,9 +23,11 @@ import {
|
|||||||
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||||
import {
|
import {
|
||||||
assignSharedResourceTokenToRequests,
|
assignSharedResourceTokenToRequests,
|
||||||
|
appendChatConversationActivityLine,
|
||||||
appendChatConversationMessage,
|
appendChatConversationMessage,
|
||||||
buildChatPromptTargetSignature,
|
buildChatPromptTargetSignature,
|
||||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||||
|
cancelUnansweredChatConversationRequest,
|
||||||
clearChatConversationData,
|
clearChatConversationData,
|
||||||
createChatConversation,
|
createChatConversation,
|
||||||
deleteUnansweredChatConversationRequest,
|
deleteUnansweredChatConversationRequest,
|
||||||
@@ -90,6 +92,15 @@ const chatPromptContextRefSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable();
|
.nullable();
|
||||||
|
|
||||||
|
const chatComposerAttachmentSchema = z.object({
|
||||||
|
id: z.string().trim().min(1).max(240),
|
||||||
|
name: z.string().trim().min(1).max(500),
|
||||||
|
path: z.string().trim().min(1).max(4000),
|
||||||
|
publicUrl: z.string().trim().min(1).max(4000),
|
||||||
|
size: z.number().finite().min(0).max(CHAT_ATTACHMENT_FILE_SIZE_LIMIT),
|
||||||
|
mimeType: z.string().trim().min(1).max(255),
|
||||||
|
});
|
||||||
|
|
||||||
async function findExistingActivePromptFollowupRequest(
|
async function findExistingActivePromptFollowupRequest(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
parentRequestId: string,
|
parentRequestId: string,
|
||||||
@@ -244,6 +255,127 @@ function createManagedChatShareMessageIds() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortShareMessages(messages: ListedChatConversationMessage[]) {
|
||||||
|
return [...messages].sort((left, right) => {
|
||||||
|
if (left.id !== right.id) {
|
||||||
|
return left.id - right.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.timestamp.localeCompare(right.timestamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortShareRequests(requests: ListedChatConversationRequest[]) {
|
||||||
|
return [...requests].sort((left, right) => {
|
||||||
|
const createdAtDiff = left.createdAt.localeCompare(right.createdAt);
|
||||||
|
|
||||||
|
if (createdAtDiff !== 0) {
|
||||||
|
return createdAtDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.requestId.localeCompare(right.requestId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function materializeManagedShareConversation(args: {
|
||||||
|
shareSnapshot: NonNullable<Awaited<ReturnType<typeof buildChatShareSnapshot>>>;
|
||||||
|
managedResourceTokenId: string;
|
||||||
|
ownerClientId: string | null;
|
||||||
|
shareTitle: string;
|
||||||
|
}) {
|
||||||
|
const { shareSnapshot, managedResourceTokenId, ownerClientId, shareTitle } = args;
|
||||||
|
const sessionId = createManagedChatShareSessionId();
|
||||||
|
const requestIdSet = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean));
|
||||||
|
const sortedRequests = sortShareRequests(shareSnapshot.requests);
|
||||||
|
const sortedMessages = sortShareMessages(shareSnapshot.messages);
|
||||||
|
const sourceConversation = shareSnapshot.conversation;
|
||||||
|
|
||||||
|
await createChatConversation({
|
||||||
|
sessionId,
|
||||||
|
clientId: ownerClientId,
|
||||||
|
title: shareTitle,
|
||||||
|
draftText: '',
|
||||||
|
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
|
||||||
|
codexModel: sourceConversation?.codexModel ?? null,
|
||||||
|
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
|
||||||
|
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
|
||||||
|
generalSectionName: sourceConversation?.generalSectionName ?? null,
|
||||||
|
contextLabel: sourceConversation?.contextLabel ?? null,
|
||||||
|
contextDescription: sourceConversation?.contextDescription ?? null,
|
||||||
|
notifyOffline: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const message of sortedMessages) {
|
||||||
|
await appendChatConversationMessage(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
clientId: ownerClientId,
|
||||||
|
title: shareTitle,
|
||||||
|
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
|
||||||
|
codexModel: sourceConversation?.codexModel ?? null,
|
||||||
|
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
|
||||||
|
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
|
||||||
|
generalSectionName: sourceConversation?.generalSectionName ?? null,
|
||||||
|
contextLabel: sourceConversation?.contextLabel ?? null,
|
||||||
|
contextDescription: sourceConversation?.contextDescription ?? null,
|
||||||
|
notifyOffline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
messageId: message.id,
|
||||||
|
author: message.author,
|
||||||
|
text: message.text,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
clientRequestId: message.clientRequestId?.trim() || null,
|
||||||
|
parts: message.parts ?? [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const request of sortedRequests) {
|
||||||
|
const normalizedParentRequestId = request.parentRequestId?.trim() || '';
|
||||||
|
|
||||||
|
await upsertChatConversationRequest(sessionId, {
|
||||||
|
requestId: request.requestId,
|
||||||
|
requesterClientId: request.requesterClientId ?? ownerClientId,
|
||||||
|
chatTypeId: request.chatTypeId ?? sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
|
||||||
|
chatTypeLabel: request.chatTypeLabel ?? sourceConversation?.contextLabel ?? null,
|
||||||
|
requestOrigin: request.requestOrigin,
|
||||||
|
sharedResourceTokenId: managedResourceTokenId,
|
||||||
|
parentRequestId: normalizedParentRequestId && requestIdSet.has(normalizedParentRequestId) ? normalizedParentRequestId : null,
|
||||||
|
status: request.status,
|
||||||
|
statusMessage: request.statusMessage,
|
||||||
|
userMessageId: request.userMessageId,
|
||||||
|
userText: request.userText,
|
||||||
|
responseMessageId: request.responseMessageId,
|
||||||
|
responseText: request.responseText,
|
||||||
|
usageSnapshot: request.usageSnapshot,
|
||||||
|
totalTokens: request.totalTokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const activityLog of shareSnapshot.activityLogs) {
|
||||||
|
let lineNo = 1;
|
||||||
|
for (const line of activityLog.lines) {
|
||||||
|
await appendChatConversationActivityLine(sessionId, activityLog.requestId, line, lineNo);
|
||||||
|
lineNo += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await assignSharedResourceTokenToRequests(
|
||||||
|
sessionId,
|
||||||
|
sortedRequests.map((item) => item.requestId),
|
||||||
|
managedResourceTokenId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
requestId:
|
||||||
|
sortedRequests.find((item) => item.requestId.trim() === shareSnapshot.targetRequest.requestId.trim())?.requestId
|
||||||
|
?? shareSnapshot.targetRequest.requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStaticContentType(filePath: string) {
|
export function resolveStaticContentType(filePath: string) {
|
||||||
const extension = path.extname(filePath).toLowerCase();
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
@@ -1546,13 +1678,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.kind === 'prompt') {
|
|
||||||
if (payload.promptIndex == null || !payload.promptSignature) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
message: 'prompt 공유에는 prompt 대상 정보가 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareSnapshot = await buildChatShareSnapshot({
|
const shareSnapshot = await buildChatShareSnapshot({
|
||||||
version: CHAT_SHARE_TOKEN_VERSION,
|
version: CHAT_SHARE_TOKEN_VERSION,
|
||||||
kind: payload.kind,
|
kind: payload.kind,
|
||||||
@@ -1571,7 +1696,20 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
promptSignature: payload.promptSignature,
|
promptSignature: payload.promptSignature,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!shareSnapshot?.promptTarget) {
|
if (!shareSnapshot) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '공유할 채팅 범위를 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.kind === 'prompt') {
|
||||||
|
if (payload.promptIndex == null || !payload.promptSignature) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'prompt 공유에는 prompt 대상 정보가 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shareSnapshot.promptTarget) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
message: '공유할 prompt 대상을 찾을 수 없습니다.',
|
message: '공유할 prompt 대상을 찾을 수 없습니다.',
|
||||||
});
|
});
|
||||||
@@ -1581,6 +1719,12 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
const managedResourceTokenId = createManagedChatShareTokenId();
|
const managedResourceTokenId = createManagedChatShareTokenId();
|
||||||
const token = randomUUID().replace(/-/g, '').slice(0, 24);
|
const token = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||||
const sharePath = resolveChatSharePath(token);
|
const sharePath = resolveChatSharePath(token);
|
||||||
|
const managedShareConversation = await materializeManagedShareConversation({
|
||||||
|
shareSnapshot,
|
||||||
|
managedResourceTokenId,
|
||||||
|
ownerClientId: clientId || null,
|
||||||
|
shareTitle: payload.name,
|
||||||
|
});
|
||||||
|
|
||||||
await upsertSharedResourceToken({
|
await upsertSharedResourceToken({
|
||||||
id: managedResourceTokenId,
|
id: managedResourceTokenId,
|
||||||
@@ -1599,8 +1743,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
resourceContext: {
|
resourceContext: {
|
||||||
kind: payload.kind,
|
kind: payload.kind,
|
||||||
sessionId: payload.sessionId,
|
sessionId: managedShareConversation.sessionId,
|
||||||
requestId: payload.requestId,
|
requestId: managedShareConversation.requestId,
|
||||||
sourceMessageId: payload.sourceMessageId ?? null,
|
sourceMessageId: payload.sourceMessageId ?? null,
|
||||||
promptIndex: payload.promptIndex ?? null,
|
promptIndex: payload.promptIndex ?? null,
|
||||||
promptSignature: payload.promptSignature ?? null,
|
promptSignature: payload.promptSignature ?? null,
|
||||||
@@ -1620,33 +1764,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
usageLimit: 0,
|
usageLimit: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdShareSnapshot = await buildChatShareSnapshot({
|
|
||||||
version: CHAT_SHARE_TOKEN_VERSION,
|
|
||||||
kind: payload.kind,
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
requestId: payload.requestId,
|
|
||||||
tokenSettingId: tokenSetting.id,
|
|
||||||
tokenSettingName: tokenSetting.name,
|
|
||||||
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
|
|
||||||
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
|
|
||||||
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
|
|
||||||
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
|
|
||||||
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
|
|
||||||
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
|
|
||||||
managedResourceTokenId,
|
|
||||||
sourceMessageId: payload.sourceMessageId,
|
|
||||||
promptIndex: payload.promptIndex,
|
|
||||||
promptSignature: payload.promptSignature,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (createdShareSnapshot?.requests.length) {
|
|
||||||
await assignSharedResourceTokenToRequests(
|
|
||||||
payload.sessionId,
|
|
||||||
createdShareSnapshot.requests.map((item) => item.requestId),
|
|
||||||
managedResourceTokenId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
token,
|
token,
|
||||||
@@ -2060,6 +2177,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
}),
|
}),
|
||||||
).max(20).optional(),
|
).max(20).optional(),
|
||||||
summaryText: z.string().max(10000).optional().nullable(),
|
summaryText: z.string().max(10000).optional().nullable(),
|
||||||
|
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||||
followupText: z.string().trim().min(1).max(20000),
|
followupText: z.string().trim().min(1).max(20000),
|
||||||
contextRef: chatPromptContextRefSchema,
|
contextRef: chatPromptContextRefSchema,
|
||||||
}).parse(request.body ?? {});
|
}).parse(request.body ?? {});
|
||||||
@@ -2265,6 +2383,237 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-cancel`, async (request, reply) => {
|
||||||
|
const params = z.object({
|
||||||
|
token: z.string().trim().min(1).max(16000),
|
||||||
|
}).parse(request.params ?? {});
|
||||||
|
const payload = z.object({
|
||||||
|
parentRequestId: z.string().trim().min(1).max(120),
|
||||||
|
}).parse(request.body ?? {});
|
||||||
|
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||||
|
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||||
|
|
||||||
|
if (!tokenPayload) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '공유 링크가 유효하지 않습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
|
||||||
|
|
||||||
|
if (!shareSnapshot) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '공유 대상을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
|
||||||
|
|
||||||
|
if (unavailableMessage) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: unavailableMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: '이 공유 링크에는 요청 취소 처리 권한이 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
|
||||||
|
shareSnapshot,
|
||||||
|
payload.parentRequestId,
|
||||||
|
[shareSnapshot.targetRequest.requestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: '이 공유 링크 범위를 벗어난 요청입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!targetRequest
|
||||||
|
|| targetRequest.hasResponse
|
||||||
|
|| targetRequest.status !== 'failed'
|
||||||
|
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
|
||||||
|
) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: '지금은 이 요청을 취소 처리할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cancelUnansweredChatConversationRequest(
|
||||||
|
tokenPayload.sessionId,
|
||||||
|
normalizedParentRequestId,
|
||||||
|
'사용자 요청으로 중단된 요청을 취소 처리했습니다.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.cancelled || !result.item) {
|
||||||
|
if (result.reason === 'answered') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: '이미 답변이 연결된 요청은 취소 처리할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.reason === 'active') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: '현재 처리 중인 요청은 여기서 취소 처리할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.reason === 'already_terminal') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: '이미 취소 처리된 요청입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '취소 처리할 요청을 찾지 못했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, result.item);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item: result.item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-retry`, async (request, reply) => {
|
||||||
|
const params = z.object({
|
||||||
|
token: z.string().trim().min(1).max(16000),
|
||||||
|
}).parse(request.params ?? {});
|
||||||
|
const payload = z.object({
|
||||||
|
parentRequestId: z.string().trim().min(1).max(120),
|
||||||
|
}).parse(request.body ?? {});
|
||||||
|
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||||
|
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||||
|
|
||||||
|
if (!tokenPayload) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '공유 링크가 유효하지 않습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
|
||||||
|
|
||||||
|
if (!shareSnapshot) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: '공유 대상을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
|
||||||
|
|
||||||
|
if (unavailableMessage) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: unavailableMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: '이 공유 링크에는 요청 재처리 권한이 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedReason = resolveShareBlockedReason(
|
||||||
|
shareSnapshot.requests,
|
||||||
|
resolveChatShareTokenSettingSnapshot(tokenPayload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blockedReason) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: blockedReason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
|
||||||
|
shareSnapshot,
|
||||||
|
payload.parentRequestId,
|
||||||
|
[shareSnapshot.targetRequest.requestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: '이 공유 링크 범위를 벗어난 요청입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!targetRequest
|
||||||
|
|| targetRequest.hasResponse
|
||||||
|
|| targetRequest.status !== 'failed'
|
||||||
|
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
|
||||||
|
) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: '지금은 이 요청을 재처리할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUserText = targetRequest.userText.trim();
|
||||||
|
|
||||||
|
if (!normalizedUserText) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
message: '재처리할 요청 본문이 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, {
|
||||||
|
mode: 'direct',
|
||||||
|
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
|
||||||
|
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
|
||||||
|
parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null,
|
||||||
|
clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!queuedRequestId) {
|
||||||
|
return reply.code(503).send({
|
||||||
|
message: '중단된 요청 재처리를 시작하지 못했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managedContext.managedResource) {
|
||||||
|
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
|
||||||
|
actorLabel: 'share-viewer',
|
||||||
|
summary: '공유 채팅에서 중단 요청 재처리를 시작했습니다.',
|
||||||
|
detail: normalizedParentRequestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
queuedRequestId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/chat/conversations', async (request) => {
|
app.get('/api/chat/conversations', async (request) => {
|
||||||
const query = z.object({
|
const query = z.object({
|
||||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||||
@@ -2622,6 +2971,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
}),
|
}),
|
||||||
).max(20).optional(),
|
).max(20).optional(),
|
||||||
summaryText: z.string().max(10000).optional().nullable(),
|
summaryText: z.string().max(10000).optional().nullable(),
|
||||||
|
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||||
}).parse(request.body ?? {});
|
}).parse(request.body ?? {});
|
||||||
|
|
||||||
if (params.requestId !== payload.parentRequestId) {
|
if (params.requestId !== payload.parentRequestId) {
|
||||||
@@ -2675,6 +3025,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
}),
|
}),
|
||||||
).max(20).optional(),
|
).max(20).optional(),
|
||||||
summaryText: z.string().max(10000).optional().nullable(),
|
summaryText: z.string().max(10000).optional().nullable(),
|
||||||
|
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||||
followupText: z.string().trim().min(1).max(20000),
|
followupText: z.string().trim().min(1).max(20000),
|
||||||
mode: z.enum(['queue', 'direct']).optional(),
|
mode: z.enum(['queue', 'direct']).optional(),
|
||||||
contextRef: chatPromptContextRefSchema,
|
contextRef: chatPromptContextRefSchema,
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { getActiveChatService } from '../services/chat-service.js';
|
||||||
|
import { getRuntimeDrainSnapshot } from '../services/runtime-drain-service.js';
|
||||||
|
import { getRuntimeWorkServerBuildInfo } from '../services/work-server-build-service.js';
|
||||||
|
|
||||||
export async function registerHealthRoutes(app: FastifyInstance) {
|
export async function registerHealthRoutes(app: FastifyInstance) {
|
||||||
const respondHealth = async () => ({
|
const respondHealth = async () => {
|
||||||
|
const buildInfo = getRuntimeWorkServerBuildInfo();
|
||||||
|
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: 'work-server',
|
service: 'work-server',
|
||||||
|
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
buildId: buildInfo?.buildId ?? null,
|
||||||
|
builtAt: buildInfo?.builtAt ?? null,
|
||||||
|
...getRuntimeDrainSnapshot(),
|
||||||
|
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||||
|
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||||
|
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||||
|
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||||
|
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/', respondHealth);
|
app.get('/', respondHealth);
|
||||||
app.get('/api', respondHealth);
|
app.get('/api', respondHealth);
|
||||||
|
|||||||
42
etc/servers/work-server/src/routes/runtime.ts
Normal file
42
etc/servers/work-server/src/routes/runtime.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getActiveChatService } from '../services/chat-service.js';
|
||||||
|
import {
|
||||||
|
beginRuntimeDrain,
|
||||||
|
endRuntimeDrain,
|
||||||
|
getRuntimeDrainSnapshot,
|
||||||
|
} from '../services/runtime-drain-service.js';
|
||||||
|
|
||||||
|
const runtimeDrainBodySchema = z.object({
|
||||||
|
draining: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildRuntimeResponse() {
|
||||||
|
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...getRuntimeDrainSnapshot(),
|
||||||
|
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||||
|
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||||
|
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||||
|
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||||
|
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerRuntimeRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/runtime', async () => buildRuntimeResponse());
|
||||||
|
|
||||||
|
app.post('/api/runtime/drain', async (request) => {
|
||||||
|
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
|
||||||
|
|
||||||
|
if (draining) {
|
||||||
|
beginRuntimeDrain();
|
||||||
|
} else {
|
||||||
|
endRuntimeDrain();
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildRuntimeResponse();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||||
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
||||||
import {
|
import {
|
||||||
cancelServerRestartReservation,
|
cancelServerRestartReservation,
|
||||||
@@ -16,8 +17,10 @@ const serverCommandParamSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const restartReservationBodySchema = z.object({
|
const restartReservationBodySchema = z.object({
|
||||||
|
target: z.enum(['all', 'test', 'work-server']).optional(),
|
||||||
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
|
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
|
||||||
});
|
});
|
||||||
|
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||||
|
|
||||||
function getImmediateRestartBlockInfo(
|
function getImmediateRestartBlockInfo(
|
||||||
key: z.infer<typeof serverCommandParamSchema>['key'],
|
key: z.infer<typeof serverCommandParamSchema>['key'],
|
||||||
@@ -60,6 +63,39 @@ function getRequestAccessToken(request: FastifyRequest) {
|
|||||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequestChatShareToken(request: FastifyRequest) {
|
||||||
|
const tokenHeader = request.headers['x-chat-share-token'];
|
||||||
|
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChatSharePath(token: string) {
|
||||||
|
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSharedServerCommandAccessContext(request: FastifyRequest) {
|
||||||
|
const shareToken = getRequestChatShareToken(request);
|
||||||
|
if (!shareToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||||
|
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||||
|
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||||
|
|
||||||
|
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: 'shared' as const,
|
||||||
|
allowedKeys: new Set<string>(['work-server']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getRequestClientId(request: FastifyRequest) {
|
function getRequestClientId(request: FastifyRequest) {
|
||||||
const clientIdHeader = request.headers['x-client-id'];
|
const clientIdHeader = request.headers['x-client-id'];
|
||||||
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
|
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
|
||||||
@@ -78,36 +114,48 @@ function getRequestAppOrigin(request: FastifyRequest) {
|
|||||||
return origin?.trim() ?? '';
|
return origin?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
async function resolveServerCommandAccessContext(request: FastifyRequest) {
|
||||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||||
return true;
|
return { scope: 'full' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolveSharedServerCommandAccessContext(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAccessDenied(reply: FastifyReply) {
|
||||||
reply.status(403);
|
reply.status(403);
|
||||||
void reply.send({
|
void reply.send({
|
||||||
message: '권한 토큰이 필요합니다.',
|
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
|
||||||
});
|
});
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerServerCommandRoutes(app: FastifyInstance) {
|
export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/server-commands', async (request, reply) => {
|
app.get('/api/server-commands', async (request, reply) => {
|
||||||
if (!ensureAuthorized(request, reply)) {
|
const accessContext = await resolveServerCommandAccessContext(request);
|
||||||
|
if (!accessContext) {
|
||||||
|
sendAccessDenied(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = await listServerCommands();
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
items: await listServerCommands(),
|
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
|
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
|
||||||
if (!ensureAuthorized(request, reply)) {
|
const accessContext = await resolveServerCommandAccessContext(request);
|
||||||
|
if (!accessContext) {
|
||||||
|
sendAccessDenied(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { key } = serverCommandParamSchema.parse(request.params);
|
const { key } = serverCommandParamSchema.parse(request.params);
|
||||||
|
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) {
|
||||||
|
reply.status(403);
|
||||||
|
return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'test' || key === 'work-server') {
|
if (key === 'test' || key === 'work-server') {
|
||||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||||
@@ -160,7 +208,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||||
if (!ensureAuthorized(request, reply)) {
|
const accessContext = await resolveServerCommandAccessContext(request);
|
||||||
|
if (!accessContext) {
|
||||||
|
sendAccessDenied(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +221,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
|
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||||
if (!ensureAuthorized(request, reply)) {
|
const accessContext = await resolveServerCommandAccessContext(request);
|
||||||
|
if (!accessContext) {
|
||||||
|
sendAccessDenied(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,9 +239,14 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const parsed = restartReservationBodySchema.parse(payload ?? {});
|
const parsed = restartReservationBodySchema.parse(payload ?? {});
|
||||||
|
|
||||||
|
if (accessContext.scope !== 'full' && parsed.target !== 'work-server') {
|
||||||
|
return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
item: await scheduleServerRestartReservation({
|
item: await scheduleServerRestartReservation({
|
||||||
|
target: parsed.target,
|
||||||
clientId: getRequestClientId(request),
|
clientId: getRequestClientId(request),
|
||||||
appOrigin: getRequestAppOrigin(request),
|
appOrigin: getRequestAppOrigin(request),
|
||||||
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
|
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
|
||||||
@@ -198,7 +255,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
|
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
|
||||||
if (!ensureAuthorized(request, reply)) {
|
const accessContext = await resolveServerCommandAccessContext(request);
|
||||||
|
if (!accessContext) {
|
||||||
|
sendAccessDenied(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +268,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
|
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||||
if (!ensureAuthorized(request, reply)) {
|
const accessContext = await resolveServerCommandAccessContext(request);
|
||||||
|
if (!accessContext) {
|
||||||
|
sendAccessDenied(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { ChatService } from './services/chat-service.js';
|
|||||||
import { ensureChatConversationTables } from './services/chat-room-service.js';
|
import { ensureChatConversationTables } from './services/chat-room-service.js';
|
||||||
import { shutdownNotificationProvider } from './services/notification-service.js';
|
import { shutdownNotificationProvider } from './services/notification-service.js';
|
||||||
import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js';
|
import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js';
|
||||||
|
import { BaseballTicketBayWorker } from './workers/baseball-ticket-bay-worker.js';
|
||||||
import { PlanWorker } from './workers/plan-worker.js';
|
import { PlanWorker } from './workers/plan-worker.js';
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const planWorker = new PlanWorker(app.log);
|
const planWorker = new PlanWorker(app.log);
|
||||||
|
const baseballTicketBayWorker = new BaseballTicketBayWorker(app.log);
|
||||||
const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log);
|
const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log);
|
||||||
const chatService = new ChatService(app.log);
|
const chatService = new ChatService(app.log);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -24,6 +26,7 @@ async function start() {
|
|||||||
port: env.PORT,
|
port: env.PORT,
|
||||||
});
|
});
|
||||||
planWorker.start();
|
planWorker.start();
|
||||||
|
baseballTicketBayWorker.start();
|
||||||
serverRestartReservationWorker.start();
|
serverRestartReservationWorker.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
@@ -46,6 +49,7 @@ async function shutdown(signal: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await planWorker.stop();
|
await planWorker.stop();
|
||||||
|
await baseballTicketBayWorker.stop();
|
||||||
await serverRestartReservationWorker.stop();
|
await serverRestartReservationWorker.stop();
|
||||||
chatService.close();
|
chatService.close();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
1449
etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
Normal file
1449
etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,12 @@
|
|||||||
|
export type ChatComposerAttachment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
publicUrl: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatMessagePart =
|
export type ChatMessagePart =
|
||||||
| {
|
| {
|
||||||
type: 'link_card';
|
type: 'link_card';
|
||||||
@@ -48,6 +57,7 @@ export type ChatMessagePart =
|
|||||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||||
resolvedAt?: string | null;
|
resolvedAt?: string | null;
|
||||||
resultText?: string | null;
|
resultText?: string | null;
|
||||||
|
attachments?: ChatComposerAttachment[];
|
||||||
options: Array<{
|
options: Array<{
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -231,6 +241,53 @@ function normalizePromptSelectedValues(value: unknown) {
|
|||||||
.filter((item, index, array) => array.indexOf(item) === index);
|
.filter((item, index, array) => array.indexOf(item) === index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePromptAttachment(value: unknown): ChatComposerAttachment | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const id = normalizeText(record.id);
|
||||||
|
const name = normalizeText(record.name);
|
||||||
|
const path = normalizeText(record.path);
|
||||||
|
const publicUrl = normalizeText(record.publicUrl);
|
||||||
|
const size = Number(record.size);
|
||||||
|
const mimeType = normalizeText(record.mimeType);
|
||||||
|
|
||||||
|
if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
publicUrl,
|
||||||
|
size,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptAttachments(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [] as ChatComposerAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => normalizePromptAttachment(item))
|
||||||
|
.filter((item): item is ChatComposerAttachment => Boolean(item))
|
||||||
|
.filter((item) => {
|
||||||
|
if (seen.has(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(item.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -436,6 +493,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
|||||||
resolvedBy,
|
resolvedBy,
|
||||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||||
resultText: normalizeText(record.resultText) || null,
|
resultText: normalizeText(record.resultText) || null,
|
||||||
|
attachments: normalizePromptAttachments(record.attachments),
|
||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
summaryText: '범위: UI',
|
summaryText: '범위: UI',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
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: 128,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'2026-05-18T08:20:00.000Z',
|
'2026-05-18T08:20:00.000Z',
|
||||||
);
|
);
|
||||||
@@ -213,6 +223,7 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s
|
|||||||
assert.equal(patched?.[0]?.resolvedBy, 'user');
|
assert.equal(patched?.[0]?.resolvedBy, 'user');
|
||||||
assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
|
assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
|
||||||
assert.equal(patched?.[0]?.resultText, '범위: UI');
|
assert.equal(patched?.[0]?.resultText, '범위: UI');
|
||||||
|
assert.equal(patched?.[0]?.attachments?.[0]?.name, 'spec.png');
|
||||||
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
|
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export type ChatConversationItem = {
|
|||||||
roomScope: Record<string, unknown> | null;
|
roomScope: Record<string, unknown> | null;
|
||||||
notifyOffline: boolean;
|
notifyOffline: boolean;
|
||||||
hasUnreadResponse: boolean;
|
hasUnreadResponse: boolean;
|
||||||
|
hasPendingAttention: boolean;
|
||||||
currentRequestId: string | null;
|
currentRequestId: string | null;
|
||||||
currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null;
|
currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null;
|
||||||
currentJobMessage: string | null;
|
currentJobMessage: string | null;
|
||||||
@@ -175,6 +176,14 @@ type ChatPromptSelectionPatch = {
|
|||||||
freeText?: string | null;
|
freeText?: string | null;
|
||||||
stepSelections?: ChatPromptStepSelectionPatch[];
|
stepSelections?: ChatPromptStepSelectionPatch[];
|
||||||
summaryText?: string | null;
|
summaryText?: string | null;
|
||||||
|
attachments?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
publicUrl: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatSourceChangeSnapshotItem = {
|
export type ChatSourceChangeSnapshotItem = {
|
||||||
@@ -625,6 +634,7 @@ export function applyChatPromptSelectionPatch(
|
|||||||
resolvedBy: 'user',
|
resolvedBy: 'user',
|
||||||
resolvedAt,
|
resolvedAt,
|
||||||
resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null,
|
resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null,
|
||||||
|
attachments: Array.isArray(selection.attachments) ? selection.attachments : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return nextParts;
|
return nextParts;
|
||||||
@@ -1184,6 +1194,161 @@ function resolvePendingWorkState(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPendingAttentionPromptPart(
|
||||||
|
part: NonNullable<ChatMessagePart>,
|
||||||
|
): part is Extract<ChatMessagePart, { type: 'prompt' }> {
|
||||||
|
return (
|
||||||
|
part.type === 'prompt'
|
||||||
|
&& part.readOnly !== true
|
||||||
|
&& part.resolvedBy == null
|
||||||
|
&& !(part.resolvedAt?.trim() ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingAttentionPromptMessageParts(parts: ChatMessagePart[] | undefined) {
|
||||||
|
return (parts ?? []).some((part) => isPendingAttentionPromptPart(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingAttentionVerificationTarget(text: string | null | undefined) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length > 720) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConversationAttentionPending(options: {
|
||||||
|
request: ChatConversationRequestItem;
|
||||||
|
relatedMessages: StoredChatMessage[];
|
||||||
|
childRequestCountByParentId: Map<string, number>;
|
||||||
|
}) {
|
||||||
|
const { request, relatedMessages, childRequestCountByParentId } = options;
|
||||||
|
|
||||||
|
if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.manualPromptCompletedAt) {
|
||||||
|
const hasOpenPrompt = relatedMessages.some(
|
||||||
|
(message) =>
|
||||||
|
(message.author === 'codex' || message.author === 'system')
|
||||||
|
&& hasPendingAttentionPromptMessageParts(message.parts),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOpenPrompt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasVerificationTarget = relatedMessages.some(
|
||||||
|
(message) =>
|
||||||
|
(message.author === 'codex' || message.author === 'system')
|
||||||
|
&& hasPendingAttentionVerificationTarget(message.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasVerificationTarget) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !request.manualVerificationCompletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConversationPendingAttentionMap(sessionIds: string[]) {
|
||||||
|
const normalizedSessionIds = Array.from(new Set(sessionIds.map((item) => item.trim()).filter(Boolean)));
|
||||||
|
|
||||||
|
if (normalizedSessionIds.length === 0) {
|
||||||
|
return new Map<string, boolean>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [requestRows, messageRows] = await Promise.all([
|
||||||
|
db(CHAT_CONVERSATION_REQUEST_TABLE)
|
||||||
|
.select('*')
|
||||||
|
.whereIn('session_id', normalizedSessionIds)
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.orderBy('request_id', 'asc'),
|
||||||
|
db(CHAT_CONVERSATION_MESSAGE_TABLE)
|
||||||
|
.select('session_id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'display_timestamp')
|
||||||
|
.whereIn('session_id', normalizedSessionIds)
|
||||||
|
.andWhere((builder) => {
|
||||||
|
applyVisibleConversationMessageCondition(builder);
|
||||||
|
})
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.orderBy('message_id', 'asc')
|
||||||
|
.orderBy('id', 'asc'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const requestRowsBySession = new Map<string, ChatConversationRequestItem[]>();
|
||||||
|
requestRows.forEach((row) => {
|
||||||
|
const request = mapRequestRow(row);
|
||||||
|
const current = requestRowsBySession.get(request.sessionId) ?? [];
|
||||||
|
current.push(request);
|
||||||
|
requestRowsBySession.set(request.sessionId, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageRowsBySession = new Map<string, StoredChatMessage[]>();
|
||||||
|
messageRows.forEach((row) => {
|
||||||
|
const message = mapMessageRow(row);
|
||||||
|
const sessionId = String(row.session_id ?? '').trim();
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = messageRowsBySession.get(sessionId) ?? [];
|
||||||
|
current.push(message);
|
||||||
|
messageRowsBySession.set(sessionId, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizedSessionIds.reduce<Map<string, boolean>>((result, sessionId) => {
|
||||||
|
const requests = requestRowsBySession.get(sessionId) ?? [];
|
||||||
|
const messages = messageRowsBySession.get(sessionId) ?? [];
|
||||||
|
const childRequestCountByParentId = requests.reduce<Map<string, number>>((map, request) => {
|
||||||
|
const parentRequestId = request.parentRequestId?.trim() || '';
|
||||||
|
|
||||||
|
if (parentRequestId) {
|
||||||
|
map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
const requestMessagesById = messages.reduce<Map<string, StoredChatMessage[]>>((map, message) => {
|
||||||
|
const requestId = message.clientRequestId?.trim() || '';
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = map.get(requestId) ?? [];
|
||||||
|
current.push(message);
|
||||||
|
map.set(requestId, current);
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
result.set(
|
||||||
|
sessionId,
|
||||||
|
requests.some((request) =>
|
||||||
|
isConversationAttentionPending({
|
||||||
|
request,
|
||||||
|
relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [],
|
||||||
|
childRequestCountByParentId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
|
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
|
||||||
/이전\s*(채팅|대화|문맥)/u,
|
/이전\s*(채팅|대화|문맥)/u,
|
||||||
/이전\s*요청/u,
|
/이전\s*요청/u,
|
||||||
@@ -1363,6 +1528,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
|
|||||||
roomScope: deriveIsolatedChatRoomScopeFromContextDescription(contextDescription),
|
roomScope: deriveIsolatedChatRoomScopeFromContextDescription(contextDescription),
|
||||||
notifyOffline: Boolean(row.notify_offline),
|
notifyOffline: Boolean(row.notify_offline),
|
||||||
hasUnreadResponse: Boolean(row.has_unread_response),
|
hasUnreadResponse: Boolean(row.has_unread_response),
|
||||||
|
hasPendingAttention: false,
|
||||||
currentRequestId: row.current_request_id == null ? null : String(row.current_request_id),
|
currentRequestId: row.current_request_id == null ? null : String(row.current_request_id),
|
||||||
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
|
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
|
||||||
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
|
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
|
||||||
@@ -2912,6 +3078,9 @@ export async function listChatConversations(
|
|||||||
const latestResponsePreviewMap = await getLatestResponsePreviewMap(
|
const latestResponsePreviewMap = await getLatestResponsePreviewMap(
|
||||||
rows.map((row) => String(row.session_id ?? '')),
|
rows.map((row) => String(row.session_id ?? '')),
|
||||||
);
|
);
|
||||||
|
const pendingAttentionBySessionId = await getConversationPendingAttentionMap(
|
||||||
|
rows.map((row) => String(row.session_id ?? '')),
|
||||||
|
);
|
||||||
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
|
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
|
||||||
rows.map((row) => String(row.session_id ?? '')),
|
rows.map((row) => String(row.session_id ?? '')),
|
||||||
);
|
);
|
||||||
@@ -2942,6 +3111,7 @@ export async function listChatConversations(
|
|||||||
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||||
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
|
||||||
hasUnreadResponse: false,
|
hasUnreadResponse: false,
|
||||||
|
hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((left, right) =>
|
.sort((left, right) =>
|
||||||
@@ -2993,6 +3163,7 @@ export async function listChatConversations(
|
|||||||
hasUnreadResponse:
|
hasUnreadResponse:
|
||||||
(latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) >
|
(latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) >
|
||||||
(preference?.lastReadResponseMessageId ?? 0),
|
(preference?.lastReadResponseMessageId ?? 0),
|
||||||
|
hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((left, right) =>
|
.sort((left, right) =>
|
||||||
@@ -4510,6 +4681,51 @@ export async function deleteUnansweredChatConversationRequest(sessionId: string,
|
|||||||
return { deleted: true, reason: null as null };
|
return { deleted: true, reason: null as null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelUnansweredChatConversationRequest(
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
statusMessage = '사용자 요청으로 중단된 요청을 취소 처리했습니다.',
|
||||||
|
) {
|
||||||
|
const normalizedSessionId = sessionId.trim();
|
||||||
|
const normalizedRequestId = requestId.trim();
|
||||||
|
const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
|
||||||
|
.where({
|
||||||
|
session_id: normalizedSessionId,
|
||||||
|
request_id: normalizedRequestId,
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return { cancelled: false, reason: 'not_found' as const, item: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = await db(CHAT_CONVERSATION_TABLE)
|
||||||
|
.where({ session_id: normalizedSessionId })
|
||||||
|
.first();
|
||||||
|
const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation);
|
||||||
|
|
||||||
|
if (mapped.hasResponse) {
|
||||||
|
return { cancelled: false, reason: 'answered' as const, item: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapped.status === 'queued' || mapped.status === 'started') {
|
||||||
|
return { cancelled: false, reason: 'active' as const, item: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapped.status === 'cancelled' || mapped.status === 'removed') {
|
||||||
|
return { cancelled: false, reason: 'already_terminal' as const, item: mapped };
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await upsertChatConversationRequest(normalizedSessionId, {
|
||||||
|
requestId: normalizedRequestId,
|
||||||
|
status: 'cancelled',
|
||||||
|
statusMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshConversationPreview(normalizedSessionId);
|
||||||
|
return { cancelled: Boolean(item), reason: item ? null : ('not_found' as const), item };
|
||||||
|
}
|
||||||
|
|
||||||
export async function clearAllChatConversationJobStates() {
|
export async function clearAllChatConversationJobStates() {
|
||||||
await ensureChatConversationTables();
|
await ensureChatConversationTables();
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from './notification-message-service.js';
|
} from './notification-message-service.js';
|
||||||
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
||||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||||
|
import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js';
|
||||||
import {
|
import {
|
||||||
findLatestPlanItem,
|
findLatestPlanItem,
|
||||||
findPlanItemByPreviewUrl,
|
findPlanItemByPreviewUrl,
|
||||||
@@ -322,6 +323,14 @@ export function getActiveChatService() {
|
|||||||
return activeChatService;
|
return activeChatService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatServiceRuntimeSnapshot = {
|
||||||
|
activeRequestCount: number;
|
||||||
|
queuedRequestCount: number;
|
||||||
|
connectedSessionCount: number;
|
||||||
|
activeSocketCount: number;
|
||||||
|
canAcceptNewRequests: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function getSessionSocketReadyState(session: ChatSessionState) {
|
function getSessionSocketReadyState(session: ChatSessionState) {
|
||||||
for (const socket of session.sockets) {
|
for (const socket of session.sockets) {
|
||||||
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
|
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
|
||||||
@@ -4362,6 +4371,38 @@ export class ChatService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRuntimeSnapshot(): ChatServiceRuntimeSnapshot {
|
||||||
|
let activeRequestCount = 0;
|
||||||
|
let queuedRequestCount = 0;
|
||||||
|
let activeSocketCount = 0;
|
||||||
|
let connectedSessionCount = 0;
|
||||||
|
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
activeRequestCount += session.activeRequestCount;
|
||||||
|
queuedRequestCount += session.queue.length;
|
||||||
|
|
||||||
|
let sessionHasOpenSocket = false;
|
||||||
|
for (const socket of session.sockets) {
|
||||||
|
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
|
||||||
|
activeSocketCount += 1;
|
||||||
|
sessionHasOpenSocket = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionHasOpenSocket) {
|
||||||
|
connectedSessionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeRequestCount,
|
||||||
|
queuedRequestCount,
|
||||||
|
connectedSessionCount,
|
||||||
|
activeSocketCount,
|
||||||
|
canAcceptNewRequests: !isRuntimeDraining(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
activeRuntimeController = null;
|
activeRuntimeController = null;
|
||||||
if (activeChatService === this) {
|
if (activeChatService === this) {
|
||||||
@@ -5267,6 +5308,7 @@ export class ChatService {
|
|||||||
session.sockets.add(socket);
|
session.sockets.add(socket);
|
||||||
session.lastSeenAt = Date.now();
|
session.lastSeenAt = Date.now();
|
||||||
this.clientStates.set(socket, session);
|
this.clientStates.set(socket, session);
|
||||||
|
trackWebSocketConnectionOpened();
|
||||||
|
|
||||||
socket.on('message', (raw: RawData) => {
|
socket.on('message', (raw: RawData) => {
|
||||||
this.handleMessage(socket, raw);
|
this.handleMessage(socket, raw);
|
||||||
@@ -5275,12 +5317,14 @@ export class ChatService {
|
|||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
this.clientStates.delete(socket);
|
this.clientStates.delete(socket);
|
||||||
session.sockets.delete(socket);
|
session.sockets.delete(socket);
|
||||||
|
trackWebSocketConnectionClosed();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (error: Error) => {
|
socket.on('error', (error: Error) => {
|
||||||
this.logger.error(error, 'chat websocket error');
|
this.logger.error(error, 'chat websocket error');
|
||||||
this.clientStates.delete(socket);
|
this.clientStates.delete(socket);
|
||||||
session.sockets.delete(socket);
|
session.sockets.delete(socket);
|
||||||
|
trackWebSocketConnectionClosed();
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.initializeSession(session);
|
await this.initializeSession(session);
|
||||||
@@ -5640,6 +5684,16 @@ export class ChatService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRuntimeDraining()) {
|
||||||
|
this.sendToSession(state, {
|
||||||
|
type: 'chat:error',
|
||||||
|
payload: {
|
||||||
|
message: '현재 서버가 배포 전환 중이라 새 AI 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (contextOverride) {
|
if (contextOverride) {
|
||||||
const mergedContext = {
|
const mergedContext = {
|
||||||
...(state.context ?? {
|
...(state.context ?? {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ test('syncMainProjectBranchForReservedRestart commits local changes and pushes t
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncMainProjectBranchForReservedRestart keeps reserved restart local when local main mode is enabled', async () => {
|
test('syncMainProjectBranchForReservedRestart skips git sync when local main mode is enabled', async () => {
|
||||||
const { repoPath } = await createRepo();
|
const { repoPath } = await createRepo();
|
||||||
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
|
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
|
||||||
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
|
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
|
||||||
@@ -195,25 +195,26 @@ test('syncMainProjectBranchForReservedRestart keeps reserved restart local when
|
|||||||
await runGit(repoPath, ['switch', 'main']);
|
await runGit(repoPath, ['switch', 'main']);
|
||||||
await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8');
|
await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8');
|
||||||
|
|
||||||
|
const headBefore = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||||
const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||||
|
const statusBefore = await runGit(repoPath, ['status', '--porcelain']);
|
||||||
const result = await syncMainProjectBranchForReservedRestart(
|
const result = await syncMainProjectBranchForReservedRestart(
|
||||||
repoPath,
|
repoPath,
|
||||||
'main',
|
'main',
|
||||||
'chore: sync main before reserved restart',
|
'chore: sync main before reserved restart',
|
||||||
);
|
);
|
||||||
const head = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
const headAfter = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||||
const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||||
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
|
const statusAfter = await runGit(repoPath, ['status', '--porcelain']);
|
||||||
const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']);
|
|
||||||
|
|
||||||
assert.equal(result.committed, true);
|
assert.equal(result.committed, false);
|
||||||
assert.equal(result.commitMessage, 'chore: sync main before reserved restart');
|
assert.equal(result.commitMessage, null);
|
||||||
assert.equal(result.head, head);
|
assert.equal(result.head, null);
|
||||||
assert.equal(result.syncMode, 'local');
|
assert.equal(result.syncMode, 'local');
|
||||||
assert.equal(remoteHeadAfter, remoteHeadBefore);
|
assert.equal(remoteHeadAfter, remoteHeadBefore);
|
||||||
assert.notEqual(remoteHeadAfter, head);
|
assert.equal(headAfter, headBefore);
|
||||||
assert.equal(mainMessage, 'chore: sync main before reserved restart');
|
assert.equal(statusBefore, '?? note.txt');
|
||||||
assert.equal(noteContent, 'hello local reserved restart');
|
assert.equal(statusAfter, '?? note.txt');
|
||||||
} finally {
|
} finally {
|
||||||
if (previousLocalMainMode === undefined) {
|
if (previousLocalMainMode === undefined) {
|
||||||
delete process.env.PLAN_LOCAL_MAIN_MODE;
|
delete process.env.PLAN_LOCAL_MAIN_MODE;
|
||||||
|
|||||||
@@ -174,22 +174,25 @@ export async function syncMainProjectBranchForReservedRestart(
|
|||||||
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
|
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
|
||||||
|
|
||||||
if (useLocalMainMode) {
|
if (useLocalMainMode) {
|
||||||
await assertBranchExists(repoPath, branchName);
|
return {
|
||||||
await runGit(repoPath, ['switch', branchName]);
|
branchName,
|
||||||
} else {
|
commitMessage: null,
|
||||||
|
committed: false,
|
||||||
|
head: null,
|
||||||
|
syncMode: 'local' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||||
await ensureLocalBranchFromRemote(repoPath, branchName);
|
await ensureLocalBranchFromRemote(repoPath, branchName);
|
||||||
}
|
|
||||||
|
|
||||||
const hadChanges = await hasWorkingTreeChanges(repoPath);
|
const hadChanges = await hasWorkingTreeChanges(repoPath);
|
||||||
if (hadChanges) {
|
if (hadChanges) {
|
||||||
await commitAllChanges(repoPath, commitMessage);
|
await commitAllChanges(repoPath, commitMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useLocalMainMode) {
|
|
||||||
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
|
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
|
||||||
await pushBranch(repoPath, branchName);
|
await pushBranch(repoPath, branchName);
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ export async function syncMainProjectBranchForReservedRestart(
|
|||||||
commitMessage: hadChanges ? commitMessage : null,
|
commitMessage: hadChanges ? commitMessage : null,
|
||||||
committed: hadChanges,
|
committed: hadChanges,
|
||||||
head,
|
head,
|
||||||
syncMode: useLocalMainMode ? 'local' as const : 'remote' as const,
|
syncMode: 'remote' as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ export async function listWebPushSubscriptions() {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
endpoint: String(row.endpoint ?? ''),
|
endpoint: String(row.endpoint ?? ''),
|
||||||
deviceId: row.device_id ? String(row.device_id) : '',
|
deviceId: row.device_id ? String(row.device_id) : '',
|
||||||
|
clientId: row.client_id ? String(row.client_id) : '',
|
||||||
userAgent: row.user_agent ? String(row.user_agent) : '',
|
userAgent: row.user_agent ? String(row.user_agent) : '',
|
||||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||||
@@ -1031,6 +1032,13 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
|||||||
isAllowedTargetClientId(row, targetClientIds) &&
|
isAllowedTargetClientId(row, targetClientIds) &&
|
||||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||||
);
|
);
|
||||||
|
const matchedSubscriptions = subscriptions.map((row) => ({
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
deviceId: row.deviceId,
|
||||||
|
clientId: row.clientId,
|
||||||
|
appOrigin: row.appOrigin,
|
||||||
|
appDomain: row.appDomain,
|
||||||
|
}));
|
||||||
|
|
||||||
if (!subscriptions.length) {
|
if (!subscriptions.length) {
|
||||||
return {
|
return {
|
||||||
@@ -1039,6 +1047,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
|||||||
reason: '등록된 Web Push 구독이 없습니다.',
|
reason: '등록된 Web Push 구독이 없습니다.',
|
||||||
sentCount: 0,
|
sentCount: 0,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
|
matchedCount: 0,
|
||||||
|
matchedSubscriptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,6 +1116,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
|||||||
sentCount,
|
sentCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
invalidEndpoints,
|
invalidEndpoints,
|
||||||
|
matchedCount: matchedSubscriptions.length,
|
||||||
|
matchedSubscriptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
beginRuntimeDrain,
|
||||||
|
endRuntimeDrain,
|
||||||
|
getRuntimeDrainSnapshot,
|
||||||
|
isRuntimeDraining,
|
||||||
|
trackHttpRequestFinished,
|
||||||
|
trackHttpRequestStarted,
|
||||||
|
trackWebSocketConnectionClosed,
|
||||||
|
trackWebSocketConnectionOpened,
|
||||||
|
} from './runtime-drain-service.js';
|
||||||
|
|
||||||
|
test('runtime drain service tracks drain and connection counters without going negative', () => {
|
||||||
|
endRuntimeDrain();
|
||||||
|
trackHttpRequestFinished();
|
||||||
|
trackWebSocketConnectionClosed();
|
||||||
|
|
||||||
|
assert.equal(isRuntimeDraining(), false);
|
||||||
|
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 0);
|
||||||
|
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 0);
|
||||||
|
|
||||||
|
beginRuntimeDrain();
|
||||||
|
trackHttpRequestStarted();
|
||||||
|
trackHttpRequestStarted();
|
||||||
|
trackWebSocketConnectionOpened();
|
||||||
|
|
||||||
|
assert.equal(isRuntimeDraining(), true);
|
||||||
|
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 2);
|
||||||
|
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 1);
|
||||||
|
|
||||||
|
trackHttpRequestFinished();
|
||||||
|
trackHttpRequestFinished();
|
||||||
|
trackHttpRequestFinished();
|
||||||
|
trackWebSocketConnectionClosed();
|
||||||
|
trackWebSocketConnectionClosed();
|
||||||
|
endRuntimeDrain();
|
||||||
|
|
||||||
|
const snapshot = getRuntimeDrainSnapshot();
|
||||||
|
assert.equal(snapshot.draining, false);
|
||||||
|
assert.equal(snapshot.drainStartedAt, null);
|
||||||
|
assert.equal(snapshot.activeHttpRequestCount, 0);
|
||||||
|
assert.equal(snapshot.activeWebSocketConnectionCount, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
type RuntimeDrainState = {
|
||||||
|
draining: boolean;
|
||||||
|
drainStartedAt: string | null;
|
||||||
|
activeHttpRequestCount: number;
|
||||||
|
activeWebSocketConnectionCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: RuntimeDrainState = {
|
||||||
|
draining: false,
|
||||||
|
drainStartedAt: null,
|
||||||
|
activeHttpRequestCount: 0,
|
||||||
|
activeWebSocketConnectionCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampCount(value: number) {
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginRuntimeDrain() {
|
||||||
|
state.draining = true;
|
||||||
|
state.drainStartedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endRuntimeDrain() {
|
||||||
|
state.draining = false;
|
||||||
|
state.drainStartedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRuntimeDraining() {
|
||||||
|
return state.draining;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackHttpRequestStarted() {
|
||||||
|
state.activeHttpRequestCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackHttpRequestFinished() {
|
||||||
|
state.activeHttpRequestCount = clampCount(state.activeHttpRequestCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackWebSocketConnectionOpened() {
|
||||||
|
state.activeWebSocketConnectionCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackWebSocketConnectionClosed() {
|
||||||
|
state.activeWebSocketConnectionCount = clampCount(state.activeWebSocketConnectionCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeDrainSnapshot() {
|
||||||
|
return {
|
||||||
|
draining: state.draining,
|
||||||
|
drainStartedAt: state.drainStartedAt,
|
||||||
|
activeHttpRequestCount: state.activeHttpRequestCount,
|
||||||
|
activeWebSocketConnectionCount: state.activeWebSocketConnectionCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
|||||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
|
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
|
||||||
assert.match(
|
assert.match(
|
||||||
testScript,
|
testScript,
|
||||||
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
|
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "\$SERVER_COMMAND_SERVICE"/,
|
||||||
);
|
);
|
||||||
assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
|
assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
|
||||||
assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
|
assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
|
||||||
@@ -96,9 +96,21 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
|||||||
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
|
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
|
||||||
assert.match(
|
assert.match(
|
||||||
workServerScript,
|
workServerScript,
|
||||||
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
/ACTIVE_SLOT_FILE="\$\{WORK_SERVER_ACTIVE_SLOT_FILE:-\$REPO_ROOT\/etc\/servers\/work-server\/\.docker\/runtime\/active-slot\}"/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
|
assert.match(
|
||||||
|
workServerScript,
|
||||||
|
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$TARGET_SERVICE"/,
|
||||||
|
);
|
||||||
|
assert.match(workServerScript, /RUNTIME_ENDPOINT="\$\{WORK_SERVER_RUNTIME_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\}"/);
|
||||||
|
assert.match(workServerScript, /set_container_draining "\$PREVIOUS_CONTAINER" true/);
|
||||||
|
assert.match(workServerScript, /wait_for_previous_slot_drain "\$PREVIOUS_CONTAINER"/);
|
||||||
|
assert.match(
|
||||||
|
workServerScript,
|
||||||
|
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$PREVIOUS_SERVICE"/,
|
||||||
|
);
|
||||||
|
assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/);
|
||||||
|
assert.match(workServerScript, /work-server zero-downtime switch completed/);
|
||||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ type BuildInspectionResult = {
|
|||||||
updateSummary: string | null;
|
updateSummary: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorkServerSlot = 'blue' | 'green';
|
||||||
|
|
||||||
const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
|
const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
|
||||||
const DEFERRED_RESTART_DELAY_MS = 2_000;
|
const DEFERRED_RESTART_DELAY_MS = 2_000;
|
||||||
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
|
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
|
||||||
@@ -484,6 +486,41 @@ export function resolveDockerSocketPath(source: NodeJS.ProcessEnv | Record<strin
|
|||||||
return '/var/run/docker.sock';
|
return '/var/run/docker.sock';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWorkServerActiveSlotFileCandidates() {
|
||||||
|
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||||
|
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
||||||
|
|
||||||
|
return [
|
||||||
|
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim() || null,
|
||||||
|
path.join(mainProjectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||||
|
path.join(projectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||||
|
path.join(projectRoot, '.docker', 'runtime', 'active-slot'),
|
||||||
|
].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkServerActiveSlot(): Promise<WorkServerSlot> {
|
||||||
|
for (const candidate of getWorkServerActiveSlotFileCandidates()) {
|
||||||
|
try {
|
||||||
|
const value = (await readFile(candidate, 'utf8')).trim();
|
||||||
|
if (value === 'blue' || value === 'green') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkServerContainerName(slot: WorkServerSlot) {
|
||||||
|
return slot === 'green' ? 'work-server-green' : 'work-server-blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendComposeDetails(detailParts: Array<string | null | undefined>) {
|
||||||
|
return trimPreview(detailParts.filter(Boolean).join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRetryWithDockerSocket(error: unknown) {
|
function shouldRetryWithDockerSocket(error: unknown) {
|
||||||
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
|
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
|
||||||
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
|
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
|
||||||
@@ -1137,11 +1174,16 @@ async function inspectComposeStatus(definition: ServerDefinition) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inspectContainerRuntime(definition: ServerDefinition): Promise<RuntimeInspectionResult> {
|
async function inspectContainerRuntime(
|
||||||
|
definition: ServerDefinition,
|
||||||
|
containerNameOverride?: string,
|
||||||
|
): Promise<RuntimeInspectionResult> {
|
||||||
|
const containerName = containerNameOverride ?? definition.containerName;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'docker',
|
'docker',
|
||||||
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', definition.containerName],
|
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', containerName],
|
||||||
{
|
{
|
||||||
cwd: definition.commandWorkingDirectory,
|
cwd: definition.commandWorkingDirectory,
|
||||||
timeout: 8000,
|
timeout: 8000,
|
||||||
@@ -1158,7 +1200,7 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (shouldRetryWithDockerSocket(error)) {
|
if (shouldRetryWithDockerSocket(error)) {
|
||||||
try {
|
try {
|
||||||
const inspected = await inspectContainerViaSocket(definition.containerName);
|
const inspected = await inspectContainerViaSocket(containerName);
|
||||||
return {
|
return {
|
||||||
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
|
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
|
||||||
composeStatus: inspected.State?.Status?.trim() || null,
|
composeStatus: inspected.State?.Status?.trim() || null,
|
||||||
@@ -1298,10 +1340,27 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (definition.key === 'work-server') {
|
if (definition.key === 'work-server') {
|
||||||
|
const primarySlot = await readWorkServerActiveSlot();
|
||||||
|
const candidateSlots: WorkServerSlot[] = primarySlot === 'green' ? ['green', 'blue'] : ['blue', 'green'];
|
||||||
|
|
||||||
|
for (const slot of candidateSlots) {
|
||||||
|
const runtimeInfo = await inspectContainerRuntime(definition, resolveWorkServerContainerName(slot));
|
||||||
|
|
||||||
|
if (runtimeInfo.startedAt) {
|
||||||
|
return {
|
||||||
|
...runtimeInfo,
|
||||||
|
composeDetails: appendComposeDetails([`slot:${slot}`, runtimeInfo.composeDetails]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runtimeInfo = await inspectContainerRuntime(definition);
|
const runtimeInfo = await inspectContainerRuntime(definition);
|
||||||
|
|
||||||
if (runtimeInfo.startedAt) {
|
if (runtimeInfo.startedAt) {
|
||||||
return runtimeInfo;
|
return {
|
||||||
|
...runtimeInfo,
|
||||||
|
composeDetails: appendComposeDetails(['slot:proxy', runtimeInfo.composeDetails]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return inspectCurrentProcessRuntime();
|
return inspectCurrentProcessRuntime();
|
||||||
|
|||||||
@@ -128,6 +128,34 @@ function normalizeExecutionPhase(value: unknown): RestartReservationExecutionPha
|
|||||||
: 'idle';
|
: 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReservationTarget(value: unknown): RestartReservationTarget {
|
||||||
|
return value === 'test' || value === 'work-server' ? value : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReservationTargetKeys(target: RestartReservationTarget): Array<'test' | 'work-server'> {
|
||||||
|
if (target === 'test') {
|
||||||
|
return ['test'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'work-server') {
|
||||||
|
return ['work-server'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['test', 'work-server'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReservationTargetLabel(target: RestartReservationTarget) {
|
||||||
|
if (target === 'test') {
|
||||||
|
return 'TEST 서버';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'work-server') {
|
||||||
|
return 'WORK 서버';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'TEST / WORK 서버';
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
||||||
return {
|
return {
|
||||||
codexRunningCount: 0,
|
codexRunningCount: 0,
|
||||||
@@ -331,7 +359,7 @@ function mapReservationRow(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(row?.enabled),
|
enabled: Boolean(row?.enabled),
|
||||||
target: row?.target === 'all' ? 'all' : 'all',
|
target: normalizeReservationTarget(row?.target),
|
||||||
status: row?.status ?? 'idle',
|
status: row?.status ?? 'idle',
|
||||||
requestedAt: row?.requested_at ?? null,
|
requestedAt: row?.requested_at ?? null,
|
||||||
requestedByClientId: row?.requested_by_client_id ?? null,
|
requestedByClientId: row?.requested_by_client_id ?? null,
|
||||||
@@ -917,13 +945,19 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
|
|||||||
const statuses = await listServerCommands();
|
const statuses = await listServerCommands();
|
||||||
const testServer = statuses.find((item) => item.key === 'test') ?? null;
|
const testServer = statuses.find((item) => item.key === 'test') ?? null;
|
||||||
const workServer = statuses.find((item) => item.key === 'work-server') ?? null;
|
const workServer = statuses.find((item) => item.key === 'work-server') ?? null;
|
||||||
const testVerified = hasReservedRestartVerification('test', testServer, row.started_at);
|
const target = normalizeReservationTarget(row.target);
|
||||||
const workVerified = hasReservedRestartVerification('work-server', workServer, row.started_at);
|
const targetKeys = getReservationTargetKeys(target);
|
||||||
|
const verificationResults = {
|
||||||
|
test: targetKeys.includes('test') ? hasReservedRestartVerification('test', testServer, row.started_at) : true,
|
||||||
|
'work-server': targetKeys.includes('work-server')
|
||||||
|
? hasReservedRestartVerification('work-server', workServer, row.started_at)
|
||||||
|
: true,
|
||||||
|
};
|
||||||
|
|
||||||
if (!testVerified || !workVerified) {
|
if (!verificationResults.test || !verificationResults['work-server']) {
|
||||||
const waitingTargets = [
|
const waitingTargets = [
|
||||||
!testVerified ? 'TEST 서버' : null,
|
!verificationResults.test ? 'TEST 서버' : null,
|
||||||
!workVerified ? 'WORK 서버' : null,
|
!verificationResults['work-server'] ? 'WORK 서버' : null,
|
||||||
].filter((value): value is string => Boolean(value));
|
].filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
@@ -1081,6 +1115,9 @@ export async function requestImmediateRestartRecovery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
|
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
|
||||||
|
const target = normalizeReservationTarget(row.target);
|
||||||
|
const targetLabel = getReservationTargetLabel(target);
|
||||||
|
const targetKeys = getReservationTargetKeys(target);
|
||||||
const activeClients = await listActiveClients();
|
const activeClients = await listActiveClients();
|
||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -1088,7 +1125,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
|||||||
started_at: row.started_at ?? db.fn.now(),
|
started_at: row.started_at ?? db.fn.now(),
|
||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
execution_phase: 'commit-main-worktree',
|
execution_phase: 'commit-main-worktree',
|
||||||
waiting_reason: 'main 작업트리 커밋 단계를 확인한 뒤 예약된 재기동을 이어갑니다.',
|
waiting_reason: `${targetLabel} 무중단 재기동을 위해 main 작업트리 상태를 확인합니다.`,
|
||||||
active_client_count: activeClients.length,
|
active_client_count: activeClients.length,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
});
|
});
|
||||||
@@ -1096,12 +1133,12 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
|||||||
if (activeClients.length > 0) {
|
if (activeClients.length > 0) {
|
||||||
await createNotificationMessage({
|
await createNotificationMessage({
|
||||||
title: '예약된 재기동 시작',
|
title: '예약된 재기동 시작',
|
||||||
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 TEST / WORK 서버 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
|
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 ${targetLabel} 무중단 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
|
||||||
category: 'system',
|
category: 'system',
|
||||||
source: SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE,
|
source: SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE,
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
metadata: {
|
metadata: {
|
||||||
previewText: `예약된 재기동 시작 · 활성 클라이언트 ${activeClients.length}건`,
|
previewText: `${targetLabel} 재기동 시작 · 활성 클라이언트 ${activeClients.length}건`,
|
||||||
linkUrl: '/?topMenu=plans',
|
linkUrl: '/?topMenu=plans',
|
||||||
linkLabel: '작업 화면 열기',
|
linkLabel: '작업 화면 열기',
|
||||||
},
|
},
|
||||||
@@ -1128,31 +1165,40 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
|||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
status: 'executing',
|
status: 'executing',
|
||||||
execution_phase: 'restart-test',
|
execution_phase: targetKeys.includes('test') ? 'restart-test' : 'restart-work-server',
|
||||||
waiting_reason: syncResult.committed
|
waiting_reason: syncResult.committed
|
||||||
? 'main 변경을 정리한 뒤 TEST 서버 재기동을 시작합니다.'
|
? `main 변경을 정리한 뒤 ${targetLabel} 재기동을 시작합니다.`
|
||||||
: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
|
: `main 작업트리 상태를 확인한 뒤 ${targetLabel} 재기동을 시작합니다.`,
|
||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (targetKeys.includes('test')) {
|
||||||
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
||||||
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
}
|
||||||
|
|
||||||
|
if (targetKeys.includes('test') && targetKeys.includes('work-server')) {
|
||||||
|
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetKeys.includes('work-server')) {
|
||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
status: 'executing',
|
status: 'executing',
|
||||||
execution_phase: 'restart-work-server',
|
execution_phase: 'restart-work-server',
|
||||||
waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
|
waiting_reason: target === 'work-server'
|
||||||
|
? 'WORK 서버 무중단 재기동 후 정상 기동을 확인하는 중입니다.'
|
||||||
|
: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
|
||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
await updateReservationRow({
|
await updateReservationRow({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
status: 'executing',
|
status: 'executing',
|
||||||
execution_phase: 'verify-runtime',
|
execution_phase: 'verify-runtime',
|
||||||
waiting_reason: 'TEST / WORK 서버 새 런타임과 정상 기동을 확인하는 중입니다.',
|
waiting_reason: `${targetLabel} 새 런타임과 정상 기동을 확인하는 중입니다.`,
|
||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1172,14 +1218,16 @@ export async function getServerRestartReservation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleServerRestartReservation(options?: {
|
export async function scheduleServerRestartReservation(options?: {
|
||||||
|
target?: RestartReservationTarget | null;
|
||||||
clientId?: string | null;
|
clientId?: string | null;
|
||||||
appOrigin?: string | null;
|
appOrigin?: string | null;
|
||||||
autoExecuteDelaySeconds?: number | null;
|
autoExecuteDelaySeconds?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const autoExecuteDelaySeconds = resolveAutoExecuteDelaySeconds(options?.autoExecuteDelaySeconds);
|
const autoExecuteDelaySeconds = resolveAutoExecuteDelaySeconds(options?.autoExecuteDelaySeconds);
|
||||||
|
const target = normalizeReservationTarget(options?.target);
|
||||||
const row = await updateReservationRow({
|
const row = await updateReservationRow({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
target: 'all',
|
target,
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
requested_at: db.fn.now(),
|
requested_at: db.fn.now(),
|
||||||
requested_by_client_id: options?.clientId?.trim() || null,
|
requested_by_client_id: options?.clientId?.trim() || null,
|
||||||
@@ -1241,7 +1289,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
|
|||||||
status: 'executing',
|
status: 'executing',
|
||||||
started_at: db.fn.now(),
|
started_at: db.fn.now(),
|
||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
waiting_reason: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
|
waiting_reason: `${getReservationTargetLabel(normalizeReservationTarget(row.target))} 재기동 준비를 시작합니다.`,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
auto_execute_at: null,
|
auto_execute_at: null,
|
||||||
execution_phase: 'commit-main-worktree',
|
execution_phase: 'commit-main-worktree',
|
||||||
@@ -1327,6 +1375,7 @@ export class ServerRestartReservationWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoExecuteDelaySeconds = await resolveReservationAutoExecuteDelaySeconds(row);
|
const autoExecuteDelaySeconds = await resolveReservationAutoExecuteDelaySeconds(row);
|
||||||
|
const targetLabel = getReservationTargetLabel(normalizeReservationTarget(row.target));
|
||||||
const autoExecuteAt = buildAutoExecuteAt(
|
const autoExecuteAt = buildAutoExecuteAt(
|
||||||
row.status === 'ready' && row.auto_execute_at ? row.auto_execute_at : new Date().toISOString(),
|
row.status === 'ready' && row.auto_execute_at ? row.auto_execute_at : new Date().toISOString(),
|
||||||
autoExecuteDelaySeconds,
|
autoExecuteDelaySeconds,
|
||||||
@@ -1336,7 +1385,7 @@ export class ServerRestartReservationWorker {
|
|||||||
status: waitingReason ? 'waiting' : 'ready',
|
status: waitingReason ? 'waiting' : 'ready',
|
||||||
last_checked_at: db.fn.now(),
|
last_checked_at: db.fn.now(),
|
||||||
waiting_reason: waitingReason
|
waiting_reason: waitingReason
|
||||||
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 TEST/WORK 서버 재기동을 자동 시작합니다.`,
|
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 ${targetLabel} 무중단 재기동을 자동 시작합니다.`,
|
||||||
workload_summary_json: workloadSummary,
|
workload_summary_json: workloadSummary,
|
||||||
auto_execute_at: waitingReason
|
auto_execute_at: waitingReason
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { processDueBaseballTicketBayAlerts } from '../services/baseball-ticket-bay-service.js';
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
export class BaseballTicketBayWorker {
|
||||||
|
private readonly logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(logger: FastifyBaseLogger) {
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.tick();
|
||||||
|
}, DEFAULT_INTERVAL_MS);
|
||||||
|
this.timer.unref?.();
|
||||||
|
this.logger.info({ intervalMs: DEFAULT_INTERVAL_MS }, 'Baseball Ticket Bay worker started');
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick() {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await processDueBaseballTicketBayAlerts(new Date());
|
||||||
|
const executed = results.length;
|
||||||
|
const failed = results.filter((item) => !item.ok).length;
|
||||||
|
|
||||||
|
if (executed > 0) {
|
||||||
|
this.logger.info({ executed, failed }, 'Baseball Ticket Bay batch processed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error }, 'Baseball Ticket Bay worker tick failed');
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/App.tsx
63
src/App.tsx
@@ -1,15 +1,21 @@
|
|||||||
|
import { App as AntdApp } from 'antd';
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { getOrCreateClientId } from './app/main/clientIdentity';
|
import { getOrCreateClientId } from './app/main/clientIdentity';
|
||||||
import { reportClientError } from './app/main/errorLogApi';
|
import { reportClientError } from './app/main/errorLogApi';
|
||||||
import { AppShell } from './app/main';
|
import { AppShell } from './app/main';
|
||||||
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
|
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
|
||||||
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
|
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
|
||||||
|
import { buildChatPath } from './app/main/routes';
|
||||||
|
import { isPreviewRuntime } from './app/main/previewRuntime';
|
||||||
import { bindViewportCssVars } from './app/main/viewportCssVars';
|
import { bindViewportCssVars } from './app/main/viewportCssVars';
|
||||||
import { reportVisitorPageView } from './features/history/api';
|
import { reportVisitorPageView } from './features/history/api';
|
||||||
import { useAppStore } from './store';
|
import { useAppStore } from './store';
|
||||||
|
|
||||||
const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried';
|
const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried';
|
||||||
|
const CACHE_RECOVERY_SESSION_KEY = 'ai-code-app.cache-recovery-completed';
|
||||||
const INITIAL_LOADING_MIN_VISIBLE_MS = 450;
|
const INITIAL_LOADING_MIN_VISIBLE_MS = 450;
|
||||||
|
const CACHE_RECOVERY_NOTICE = '캐시된 화면 정보가 맞지 않아 홈으로 이동합니다. 다시 열어 주세요.';
|
||||||
|
const CACHE_RECOVERY_DELAY_MS = 900;
|
||||||
|
|
||||||
function shouldRetryChunkLoad(errorMessage: string) {
|
function shouldRetryChunkLoad(errorMessage: string) {
|
||||||
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test(
|
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test(
|
||||||
@@ -17,6 +23,12 @@ function shouldRetryChunkLoad(errorMessage: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRecoverFromCacheError(errorMessage: string) {
|
||||||
|
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError|Loading chunk|Failed to load module script|does not provide an export named|Cannot find module/i.test(
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function retryChunkLoadOnce(errorMessage: string) {
|
function retryChunkLoadOnce(errorMessage: string) {
|
||||||
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
@@ -39,7 +51,45 @@ function retryChunkLoadOnce(errorMessage: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHomeRecoveryUrl() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return buildChatPath('live');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(buildChatPath('live'), window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRecoverToHomeFromCacheError(errorMessage: string, notify: (text: string) => void) {
|
||||||
|
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreviewRuntime()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRecoverFromCacheError(errorMessage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(CACHE_RECOVERY_SESSION_KEY) === '1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(CACHE_RECOVERY_SESSION_KEY, '1');
|
||||||
|
notify(CACHE_RECOVERY_NOTICE);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.replace(getHomeRecoveryUrl());
|
||||||
|
}, CACHE_RECOVERY_DELAY_MS);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { message } = AntdApp.useApp();
|
||||||
const { currentPage } = useAppStore();
|
const { currentPage } = useAppStore();
|
||||||
const lastTrackedPageIdRef = useRef<string | null>(null);
|
const lastTrackedPageIdRef = useRef<string | null>(null);
|
||||||
const [showInitialLoading, setShowInitialLoading] = useState(true);
|
const [showInitialLoading, setShowInitialLoading] = useState(true);
|
||||||
@@ -51,6 +101,13 @@ function App() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notifyCacheRecovery = (text: string) => {
|
||||||
|
message.warning({
|
||||||
|
content: text,
|
||||||
|
duration: 1.5,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleError = (event: ErrorEvent) => {
|
const handleError = (event: ErrorEvent) => {
|
||||||
const reportedError = event.error instanceof Error ? event.error : null;
|
const reportedError = event.error instanceof Error ? event.error : null;
|
||||||
const errorMessage = event.message || reportedError?.message || '클라이언트 오류가 발생했습니다.';
|
const errorMessage = event.message || reportedError?.message || '클라이언트 오류가 발생했습니다.';
|
||||||
@@ -71,6 +128,8 @@ function App() {
|
|||||||
column: event.colno || null,
|
column: event.colno || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
@@ -93,6 +152,8 @@ function App() {
|
|||||||
reasonType: typeof reason,
|
reasonType: typeof reason,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('error', handleError);
|
window.addEventListener('error', handleError);
|
||||||
@@ -102,7 +163,7 @@ function App() {
|
|||||||
window.removeEventListener('error', handleError);
|
window.removeEventListener('error', handleError);
|
||||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [message]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getOrCreateClientId();
|
getOrCreateClientId();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DocsPage } from './pages/DocsPage';
|
|||||||
import { PlansPage } from './pages/PlansPage';
|
import { PlansPage } from './pages/PlansPage';
|
||||||
import { PlayPage } from './pages/PlayPage';
|
import { PlayPage } from './pages/PlayPage';
|
||||||
import { buildChatPath, buildDocsPath } from './routes';
|
import { buildChatPath, buildDocsPath } from './routes';
|
||||||
|
import { isPreviewRuntime } from './previewRuntime';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +15,7 @@ export function AppShell() {
|
|||||||
<Route path="/chat-share/:token" element={<ChatSharePage />} />
|
<Route path="/chat-share/:token" element={<ChatSharePage />} />
|
||||||
<Route path="/chat/share/:token" element={<ChatSharePage />} />
|
<Route path="/chat/share/:token" element={<ChatSharePage />} />
|
||||||
<Route path="/" element={<MainLayout />}>
|
<Route path="/" element={<MainLayout />}>
|
||||||
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
|
<Route index element={<Navigate to={isPreviewRuntime() ? buildDocsPath() : buildChatPath('live')} replace />} />
|
||||||
<Route path="docs/:folder" element={<DocsPage />} />
|
<Route path="docs/:folder" element={<DocsPage />} />
|
||||||
<Route path="apis/:section" element={<ApisPage />} />
|
<Route path="apis/:section" element={<ApisPage />} />
|
||||||
<Route path="plans/:section" element={<PlansPage />} />
|
<Route path="plans/:section" element={<PlansPage />} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
showLocalClientNotification,
|
showLocalClientNotification,
|
||||||
} from './notificationApi';
|
} from './notificationApi';
|
||||||
import { chatGateway } from './chatV2';
|
import { chatGateway } from './chatV2';
|
||||||
import { resolveChatPathForSession } from './isolatedChatRooms';
|
import { resolveChatPathForSession } from './chatSessionRouting';
|
||||||
import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types';
|
import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types';
|
||||||
|
|
||||||
const MAX_NOTIFICATION_DETAIL_POLLS = 3;
|
const MAX_NOTIFICATION_DETAIL_POLLS = 3;
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ import type {
|
|||||||
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
|
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
|
||||||
import { useChatActionContextSnapshot } from './chatActionContextStore';
|
import { useChatActionContextSnapshot } from './chatActionContextStore';
|
||||||
import { getOrCreateClientId } from './clientIdentity';
|
import { getOrCreateClientId } from './clientIdentity';
|
||||||
import { requestScopedChatRoomsWindowAction } from './ScopedChatRoomsWindow';
|
import { requestChatWindowAction } from './chatWindowActions';
|
||||||
import {
|
import {
|
||||||
buildIsolatedChatRoomContextSupplement,
|
buildIsolatedChatRoomContextSupplement,
|
||||||
buildIsolatedChatRoomRequestBadgeLabel,
|
buildIsolatedChatRoomRequestBadgeLabel,
|
||||||
@@ -146,6 +146,7 @@ import {
|
|||||||
createIsolatedChatRoomSessionId,
|
createIsolatedChatRoomSessionId,
|
||||||
doesIsolatedChatRoomScopeMatch,
|
doesIsolatedChatRoomScopeMatch,
|
||||||
isIsolatedChatRoomSessionId,
|
isIsolatedChatRoomSessionId,
|
||||||
|
normalizeIsolatedChatRoomScope,
|
||||||
resolveChatPathForSession,
|
resolveChatPathForSession,
|
||||||
shouldShowConversationForMode,
|
shouldShowConversationForMode,
|
||||||
type IsolatedChatRoomScope,
|
type IsolatedChatRoomScope,
|
||||||
@@ -771,6 +772,7 @@ function buildOptimisticConversationSummary(args: {
|
|||||||
roomScope: normalizedRoomScope,
|
roomScope: normalizedRoomScope,
|
||||||
notifyOffline: true,
|
notifyOffline: true,
|
||||||
hasUnreadResponse: false,
|
hasUnreadResponse: false,
|
||||||
|
hasPendingAttention: false,
|
||||||
currentRequestId: null,
|
currentRequestId: null,
|
||||||
currentJobStatus: null,
|
currentJobStatus: null,
|
||||||
currentJobMessage: null,
|
currentJobMessage: null,
|
||||||
@@ -1924,6 +1926,32 @@ function hasDuplicateActivePromptRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasConversationAttentionPromptPart(part: ChatMessagePart) {
|
||||||
|
return part.type === 'prompt' && part.readOnly !== true && part.resolvedBy == null && !(part.resolvedAt?.trim() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConversationAttentionResponseTarget(message: ChatMessage) {
|
||||||
|
if (message.author !== 'codex' && message.author !== 'system') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((message.parts ?? []).some((part) => hasConversationAttentionPromptPart(part))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = String(message.text ?? '').trim();
|
||||||
|
|
||||||
|
if (!normalizedText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedText.length > 720) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalizedText);
|
||||||
|
}
|
||||||
|
|
||||||
function mergeConversationRequestPreservingContent(
|
function mergeConversationRequestPreservingContent(
|
||||||
previousItem: ChatConversationRequest | null | undefined,
|
previousItem: ChatConversationRequest | null | undefined,
|
||||||
nextItem: ChatConversationRequest,
|
nextItem: ChatConversationRequest,
|
||||||
@@ -2526,6 +2554,7 @@ function mergeConversationSummaryPreservingChatType(
|
|||||||
contextDescription: nextItem.contextDescription?.trim() || null,
|
contextDescription: nextItem.contextDescription?.trim() || null,
|
||||||
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
|
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
|
||||||
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
|
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
|
||||||
|
hasPendingAttention: nextItem.hasPendingAttention === true || previousItem.hasPendingAttention === true,
|
||||||
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
||||||
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||||
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
||||||
@@ -4267,6 +4296,7 @@ export function MainChatPanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasMeaningfulCodexResponse = isMeaningfulCodexResponseMessage(incomingMessage);
|
const hasMeaningfulCodexResponse = isMeaningfulCodexResponseMessage(incomingMessage);
|
||||||
|
const hasPendingAttentionResponse = hasConversationAttentionResponseTarget(incomingMessage);
|
||||||
const isForegroundSession = isActiveChatSessionInForeground({
|
const isForegroundSession = isActiveChatSessionInForeground({
|
||||||
sessionId,
|
sessionId,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
@@ -4292,6 +4322,8 @@ export function MainChatPanel({
|
|||||||
updatedAt: responseTimestamp,
|
updatedAt: responseTimestamp,
|
||||||
hasUnreadResponse:
|
hasUnreadResponse:
|
||||||
hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
|
hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
|
||||||
|
hasPendingAttention:
|
||||||
|
hasPendingAttentionResponse && incomingMessage.author === 'codex' ? true : item.hasPendingAttention,
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
@@ -4906,7 +4938,7 @@ export function MainChatPanel({
|
|||||||
);
|
);
|
||||||
}, [conversationItems, effectiveActiveRoomScope, mode, resolveConversationRoomScope, roomLaunchScope]);
|
}, [conversationItems, effectiveActiveRoomScope, mode, resolveConversationRoomScope, roomLaunchScope]);
|
||||||
const unreadFilteredConversationItems = useMemo(
|
const unreadFilteredConversationItems = useMemo(
|
||||||
() => filteredConversationItems.filter((item) => item.hasUnreadResponse),
|
() => filteredConversationItems.filter((item) => item.hasUnreadResponse || item.hasPendingAttention),
|
||||||
[filteredConversationItems],
|
[filteredConversationItems],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -4932,7 +4964,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.hasUnreadResponse) {
|
if (item.hasUnreadResponse || item.hasPendingAttention) {
|
||||||
groupedItems.unread.push(item);
|
groupedItems.unread.push(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4984,7 +5016,7 @@ export function MainChatPanel({
|
|||||||
return [
|
return [
|
||||||
{ key: 'processing', title: '처리 중', tone: 'processing', items: groupedItems.processing, defaultOpen: true },
|
{ key: 'processing', title: '처리 중', tone: 'processing', items: groupedItems.processing, defaultOpen: true },
|
||||||
{ key: 'failed', title: '오류', tone: 'failed', items: groupedItems.failed, defaultOpen: true },
|
{ key: 'failed', title: '오류', tone: 'failed', items: groupedItems.failed, defaultOpen: true },
|
||||||
{ key: 'unread', title: '답변 도착', tone: 'unread', items: groupedItems.unread, defaultOpen: true },
|
{ key: 'unread', title: '답변 도착·미확인', tone: 'unread', items: groupedItems.unread, defaultOpen: true },
|
||||||
...normalizedGeneralSectionOrder
|
...normalizedGeneralSectionOrder
|
||||||
.map((key) => generalSectionMap.get(key))
|
.map((key) => generalSectionMap.get(key))
|
||||||
.filter((section): section is ConversationListSection => Boolean(section)),
|
.filter((section): section is ConversationListSection => Boolean(section)),
|
||||||
@@ -5563,9 +5595,10 @@ export function MainChatPanel({
|
|||||||
item: ConversationListViewItem,
|
item: ConversationListViewItem,
|
||||||
sectionKey = 'general',
|
sectionKey = 'general',
|
||||||
) => {
|
) => {
|
||||||
const isUnread = item.hasUnreadResponse;
|
const isUnread = item.hasUnreadResponse || item.hasPendingAttention;
|
||||||
const isProcessing = isConversationProcessing(item);
|
const isProcessing = isConversationProcessing(item);
|
||||||
const isFailed = isConversationFailed(item);
|
const isFailed = isConversationFailed(item);
|
||||||
|
const unreadFlagLabel = item.hasUnreadResponse ? '답변 도착' : '미확인';
|
||||||
const generalSectionName = normalizeGeneralSectionName(item.generalSectionName);
|
const generalSectionName = normalizeGeneralSectionName(item.generalSectionName);
|
||||||
const isUnreadSection = sectionKey === 'unread';
|
const isUnreadSection = sectionKey === 'unread';
|
||||||
const isFailedSection = sectionKey === 'failed';
|
const isFailedSection = sectionKey === 'failed';
|
||||||
@@ -5634,7 +5667,7 @@ export function MainChatPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
{isUnread ? (
|
{isUnread ? (
|
||||||
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
|
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
|
||||||
답변 도착
|
{unreadFlagLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{generalSectionName ? (
|
{generalSectionName ? (
|
||||||
@@ -6250,7 +6283,9 @@ export function MainChatPanel({
|
|||||||
setIsResourceStripOpen,
|
setIsResourceStripOpen,
|
||||||
setIsConversationPaneClosed,
|
setIsConversationPaneClosed,
|
||||||
setIsMobileConversationView,
|
setIsMobileConversationView,
|
||||||
setPreserveEmptyConversationSelection,
|
setPreserveEmptyConversationSelection: (value: boolean) => {
|
||||||
|
setPreserveEmptyConversationSelection(mode === 'rooms' ? value : false);
|
||||||
|
},
|
||||||
updatePendingMessageStatus,
|
updatePendingMessageStatus,
|
||||||
sendChatRequest,
|
sendChatRequest,
|
||||||
createLocalMessage,
|
createLocalMessage,
|
||||||
@@ -6367,7 +6402,7 @@ export function MainChatPanel({
|
|||||||
if (succeededSessionIdSet.has(activeSessionId)) {
|
if (succeededSessionIdSet.has(activeSessionId)) {
|
||||||
isClosingConversationRef.current = true;
|
isClosingConversationRef.current = true;
|
||||||
handledRequestedSessionIdRef.current = '';
|
handledRequestedSessionIdRef.current = '';
|
||||||
setPreserveEmptyConversationSelection(true);
|
setPreserveEmptyConversationSelection(mode === 'rooms');
|
||||||
replaceChatSessionInUrl('');
|
replaceChatSessionInUrl('');
|
||||||
chatConnectionGateway.resetLastReceivedEventId('');
|
chatConnectionGateway.resetLastReceivedEventId('');
|
||||||
setActiveSessionId('');
|
setActiveSessionId('');
|
||||||
@@ -6918,7 +6953,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeView !== 'chat' || isConversationListLoading) {
|
if (isConversationListLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6940,14 +6975,16 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialChatType = selectedChatType ?? availableChatTypes[0] ?? null;
|
const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null) ?? availableChatTypes[0] ?? null;
|
||||||
|
|
||||||
if (!initialChatType) {
|
if (!initialChatType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAttemptedInitialConversationRef.current = true;
|
hasAttemptedInitialConversationRef.current = true;
|
||||||
|
setActiveView('chat');
|
||||||
setSelectedChatTypeId(initialChatType.id);
|
setSelectedChatTypeId(initialChatType.id);
|
||||||
|
|
||||||
void handleCreateConversation({
|
void handleCreateConversation({
|
||||||
chatTypeOverride: initialChatType,
|
chatTypeOverride: initialChatType,
|
||||||
persist: true,
|
persist: true,
|
||||||
@@ -6965,6 +7002,7 @@ export function MainChatPanel({
|
|||||||
preserveEmptyConversationSelection,
|
preserveEmptyConversationSelection,
|
||||||
requestedSessionId,
|
requestedSessionId,
|
||||||
selectedChatType,
|
selectedChatType,
|
||||||
|
isSelectedChatTypeAllowed,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -7407,6 +7445,7 @@ export function MainChatPanel({
|
|||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
}>;
|
}>;
|
||||||
summaryText?: string | null;
|
summaryText?: string | null;
|
||||||
|
attachments?: ChatComposerAttachment[];
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
@@ -7459,6 +7498,7 @@ export function MainChatPanel({
|
|||||||
freeText: selection.freeText,
|
freeText: selection.freeText,
|
||||||
stepSelections: selection.stepSelections,
|
stepSelections: selection.stepSelections,
|
||||||
summaryText: selection.summaryText ?? null,
|
summaryText: selection.summaryText ?? null,
|
||||||
|
attachments: selection.attachments ?? [],
|
||||||
followupText: trimmed,
|
followupText: trimmed,
|
||||||
mode: resolvedMode,
|
mode: resolvedMode,
|
||||||
contextRef: contextRef ?? null,
|
contextRef: contextRef ?? null,
|
||||||
@@ -7618,11 +7658,12 @@ export function MainChatPanel({
|
|||||||
);
|
);
|
||||||
return nextItems;
|
return nextItems;
|
||||||
});
|
});
|
||||||
|
void reloadConversationItems();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, setRequestItems],
|
[activeSessionId, reloadConversationItems, setRequestItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRetryFailedRequest = useCallback(
|
const handleRetryFailedRequest = useCallback(
|
||||||
@@ -7707,7 +7748,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('close');
|
requestChatWindowAction('close');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7882,7 +7923,45 @@ export function MainChatPanel({
|
|||||||
conversationSections.map(renderConversationListSection)
|
conversationSections.map(renderConversationListSection)
|
||||||
) : (
|
) : (
|
||||||
<div className="app-chat-panel__conversation-empty-list">
|
<div className="app-chat-panel__conversation-empty-list">
|
||||||
|
{isRoomsMode ? (
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={12} align="center">
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={availableChatTypes.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null)
|
||||||
|
?? availableChatTypes[0]
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (!initialChatType) {
|
||||||
|
openCreateConversationModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedChatTypeId(initialChatType.id);
|
||||||
|
void handleCreateConversation({
|
||||||
|
chatTypeOverride: initialChatType,
|
||||||
|
persist: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
대화 시작
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
openCreateConversationModal();
|
||||||
|
}}
|
||||||
|
disabled={availableChatTypes.length === 0}
|
||||||
|
>
|
||||||
|
채팅유형 선택
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -8038,7 +8117,7 @@ export function MainChatPanel({
|
|||||||
: '웹소켓 끊김'}
|
: '웹소켓 끊김'}
|
||||||
</span>
|
</span>
|
||||||
<span className="app-chat-panel__rooms-share-summary">
|
<span className="app-chat-panel__rooms-share-summary">
|
||||||
{activeRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '시스템 채팅방'}
|
{activeRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '채팅방'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -8106,7 +8185,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('minimize');
|
requestChatWindowAction('minimize');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8116,7 +8195,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('close');
|
requestChatWindowAction('close');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -8132,15 +8211,15 @@ export function MainChatPanel({
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<MinusOutlined />}
|
icon={<MinusOutlined />}
|
||||||
aria-label="시스템 채팅 최소화"
|
aria-label="채팅 최소화"
|
||||||
title="시스템 채팅 최소화"
|
title="채팅 최소화"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onRoomsMinimize) {
|
if (onRoomsMinimize) {
|
||||||
onRoomsMinimize();
|
onRoomsMinimize();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('minimize');
|
requestChatWindowAction('minimize');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -8149,15 +8228,15 @@ export function MainChatPanel({
|
|||||||
size="small"
|
size="small"
|
||||||
danger
|
danger
|
||||||
icon={<CloseOutlined />}
|
icon={<CloseOutlined />}
|
||||||
aria-label="시스템 채팅 닫기"
|
aria-label="채팅 닫기"
|
||||||
title="시스템 채팅 닫기"
|
title="채팅 닫기"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onRoomsClose) {
|
if (onRoomsClose) {
|
||||||
onRoomsClose();
|
onRoomsClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('close');
|
requestChatWindowAction('close');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -8407,7 +8486,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('minimize');
|
requestChatWindowAction('minimize');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8417,7 +8496,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('close');
|
requestChatWindowAction('close');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onRoomsHeaderMinimize={
|
onRoomsHeaderMinimize={
|
||||||
@@ -8428,7 +8507,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('minimize');
|
requestChatWindowAction('minimize');
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -8440,7 +8519,7 @@ export function MainChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestScopedChatRoomsWindowAction('close');
|
requestChatWindowAction('close');
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -8556,15 +8635,53 @@ export function MainChatPanel({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="app-chat-panel__conversation-empty">
|
<div className="app-chat-panel__conversation-empty">
|
||||||
|
{isRoomsMode ? (
|
||||||
<Empty
|
<Empty
|
||||||
description={
|
description={
|
||||||
isRoomsMode
|
canReturnToRoomsList
|
||||||
? canReturnToRoomsList
|
|
||||||
? '설정 메뉴에서 대화 목록을 열어 대화를 선택하세요.'
|
? '설정 메뉴에서 대화 목록을 열어 대화를 선택하세요.'
|
||||||
: '시스템 채팅방을 준비하는 중입니다.'
|
: '채팅방을 준비하는 중입니다.'
|
||||||
: '왼쪽 목록에서 대화를 선택하세요.'
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : conversationItems.length === 0 ? (
|
||||||
|
<Space direction="vertical" size={12} align="center">
|
||||||
|
<Empty description="저장된 대화가 없습니다. 바로 새 대화를 시작할 수 있습니다." />
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={availableChatTypes.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null)
|
||||||
|
?? availableChatTypes[0]
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (!initialChatType) {
|
||||||
|
openCreateConversationModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedChatTypeId(initialChatType.id);
|
||||||
|
void handleCreateConversation({
|
||||||
|
chatTypeOverride: initialChatType,
|
||||||
|
persist: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
대화 시작
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
openCreateConversationModal();
|
||||||
|
}}
|
||||||
|
disabled={availableChatTypes.length === 0}
|
||||||
|
>
|
||||||
|
채팅유형 선택
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Empty description="왼쪽 목록에서 대화를 선택하세요." />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { SharedChatManagementPage } from './SharedChatManagementPage';
|
|||||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||||
import { MainChatPanel } from './MainChatPanel';
|
import { MainChatPanel } from './MainChatPanel';
|
||||||
import { SystemChatPanel } from './SystemChatPanel';
|
|
||||||
import { PlayAppOverlay } from './PlayAppOverlay';
|
import { PlayAppOverlay } from './PlayAppOverlay';
|
||||||
import { PreviewAppOverlay } from './PreviewAppOverlay';
|
import { PreviewAppOverlay } from './PreviewAppOverlay';
|
||||||
import { SharedResourceManagementPage } from './SharedResourceManagementPage';
|
import { SharedResourceManagementPage } from './SharedResourceManagementPage';
|
||||||
@@ -273,9 +272,6 @@ export function MainContent({
|
|||||||
return <MainChatPanel initialView="live" />;
|
return <MainChatPanel initialView="live" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectionId === 'page:chat:rooms') {
|
|
||||||
return <SystemChatPanel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionId === 'page:chat:errors') {
|
if (selectionId === 'page:chat:errors') {
|
||||||
return <MainChatPanel initialView="errors" />;
|
return <MainChatPanel initialView="errors" />;
|
||||||
|
|||||||
@@ -58,16 +58,16 @@ import {
|
|||||||
type PlanCostTimeUnit,
|
type PlanCostTimeUnit,
|
||||||
} from './appConfig';
|
} from './appConfig';
|
||||||
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
||||||
import {
|
import { fetchWebPushConfig } from './notificationApi';
|
||||||
fetchWebPushConfig,
|
|
||||||
registerWebPushSubscription,
|
|
||||||
unregisterWebPushSubscription,
|
|
||||||
type WebPushSubscriptionPayload,
|
|
||||||
} from './notificationApi';
|
|
||||||
import {
|
import {
|
||||||
clearNotificationIdentity,
|
clearNotificationIdentity,
|
||||||
getSavedNotificationDeviceId,
|
getSavedNotificationDeviceId,
|
||||||
} from './notificationIdentity';
|
} from './notificationIdentity';
|
||||||
|
import {
|
||||||
|
clearWebPushSubscriptionRegistration,
|
||||||
|
ensureWebPushSubscriptionRegistered,
|
||||||
|
syncExistingWebPushSubscriptionRegistration,
|
||||||
|
} from './webPushRegistration';
|
||||||
import { resetNonAuthClientState } from './appMaintenance';
|
import { resetNonAuthClientState } from './appMaintenance';
|
||||||
import {
|
import {
|
||||||
ALLOWED_REGISTRATION_TOKEN,
|
ALLOWED_REGISTRATION_TOKEN,
|
||||||
@@ -658,7 +658,7 @@ function areGestureShortcutSettingsEqual(
|
|||||||
left: AppConfig['gestureShortcuts'],
|
left: AppConfig['gestureShortcuts'],
|
||||||
right: AppConfig['gestureShortcuts'],
|
right: AppConfig['gestureShortcuts'],
|
||||||
) {
|
) {
|
||||||
return left.openSearch === right.openSearch && left.openWindowSearch === right.openWindowSearch;
|
return left.openSearch === right.openSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draft: AppConfig['gestureShortcuts']) {
|
function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draft: AppConfig['gestureShortcuts']) {
|
||||||
@@ -668,10 +668,6 @@ function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draf
|
|||||||
changedLabels.push('통합 검색 열기');
|
changedLabels.push('통합 검색 열기');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saved.openWindowSearch !== draft.openWindowSearch) {
|
|
||||||
changedLabels.push('시스템 채팅 열기');
|
|
||||||
}
|
|
||||||
|
|
||||||
return changedLabels;
|
return changedLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,55 +1322,6 @@ function getAppHeaderDomainClassName() {
|
|||||||
return 'app-header--prod';
|
return 'app-header--prod';
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String: string) {
|
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const rawData = window.atob(base64);
|
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < rawData.length; index += 1) {
|
|
||||||
outputArray[index] = rawData.charCodeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSamePushApplicationServerKey(
|
|
||||||
leftKey: ArrayBuffer | null | undefined,
|
|
||||||
rightKey: Uint8Array,
|
|
||||||
) {
|
|
||||||
if (!leftKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftBytes = new Uint8Array(leftKey);
|
|
||||||
|
|
||||||
if (leftBytes.byteLength !== rightKey.byteLength) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < leftBytes.byteLength; index += 1) {
|
|
||||||
if (leftBytes[index] !== rightKey[index]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializePushSubscription(subscription: PushSubscription): WebPushSubscriptionPayload {
|
|
||||||
const json = subscription.toJSON();
|
|
||||||
|
|
||||||
return {
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
expirationTime: subscription.expirationTime,
|
|
||||||
keys: {
|
|
||||||
p256dh: json.keys?.p256dh ?? '',
|
|
||||||
auth: json.keys?.auth ?? '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPushServiceWorkerRegistration() {
|
async function getPushServiceWorkerRegistration() {
|
||||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1954,45 +1901,6 @@ export function MainHeader({
|
|||||||
);
|
);
|
||||||
const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory);
|
const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory);
|
||||||
|
|
||||||
const ensureWebPushSubscriptionRegistered = async (registration: ServiceWorkerRegistration) => {
|
|
||||||
const config = await fetchWebPushConfig();
|
|
||||||
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
|
|
||||||
|
|
||||||
if (!config.enabled || !config.publicKey) {
|
|
||||||
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let subscription = await registration.pushManager.getSubscription();
|
|
||||||
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
|
|
||||||
|
|
||||||
if (
|
|
||||||
subscription &&
|
|
||||||
!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)
|
|
||||||
) {
|
|
||||||
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
|
|
||||||
await subscription.unsubscribe().catch(() => undefined);
|
|
||||||
subscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: expectedApplicationServerKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId());
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearWebPushSubscriptionRegistration = async (registration: ServiceWorkerRegistration) => {
|
|
||||||
const subscription = await registration.pushManager.getSubscription();
|
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
await unregisterWebPushSubscription(subscription.endpoint);
|
|
||||||
await subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncRegisteredWebPushStatus = async () => {
|
const syncRegisteredWebPushStatus = async () => {
|
||||||
const permission = getClientNotificationPermission();
|
const permission = getClientNotificationPermission();
|
||||||
setClientNotificationPermission(permission);
|
setClientNotificationPermission(permission);
|
||||||
@@ -2036,7 +1944,9 @@ export function MainHeader({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureWebPushSubscriptionRegistered(registration);
|
await syncExistingWebPushSubscriptionRegistration(registration, {
|
||||||
|
deviceId: getSavedNotificationDeviceId(),
|
||||||
|
});
|
||||||
setNotificationEnabled(true);
|
setNotificationEnabled(true);
|
||||||
setNotificationPendingRegistration(false);
|
setNotificationPendingRegistration(false);
|
||||||
setNotificationFeedback(null);
|
setNotificationFeedback(null);
|
||||||
@@ -2078,7 +1988,9 @@ export function MainHeader({
|
|||||||
throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
|
throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureWebPushSubscriptionRegistered(registration);
|
await ensureWebPushSubscriptionRegistered(registration, {
|
||||||
|
deviceId: getSavedNotificationDeviceId(),
|
||||||
|
});
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -2118,6 +2030,7 @@ export function MainHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('focus', handleSync);
|
window.addEventListener('focus', handleSync);
|
||||||
|
window.addEventListener('online', handleSync);
|
||||||
window.addEventListener('pageshow', handleSync);
|
window.addEventListener('pageshow', handleSync);
|
||||||
document.addEventListener('visibilitychange', handleSync);
|
document.addEventListener('visibilitychange', handleSync);
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
@@ -2130,6 +2043,7 @@ export function MainHeader({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('focus', handleSync);
|
window.removeEventListener('focus', handleSync);
|
||||||
|
window.removeEventListener('online', handleSync);
|
||||||
window.removeEventListener('pageshow', handleSync);
|
window.removeEventListener('pageshow', handleSync);
|
||||||
document.removeEventListener('visibilitychange', handleSync);
|
document.removeEventListener('visibilitychange', handleSync);
|
||||||
};
|
};
|
||||||
@@ -2472,7 +2386,9 @@ export function MainHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextEnabled) {
|
if (nextEnabled) {
|
||||||
await ensureWebPushSubscriptionRegistered(registration);
|
await ensureWebPushSubscriptionRegistered(registration, {
|
||||||
|
deviceId: getSavedNotificationDeviceId(),
|
||||||
|
});
|
||||||
setNotificationEnabled(true);
|
setNotificationEnabled(true);
|
||||||
setNotificationPendingRegistration(false);
|
setNotificationPendingRegistration(false);
|
||||||
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' });
|
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' });
|
||||||
@@ -2957,92 +2873,29 @@ export function MainHeader({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestartBothServers = async () => {
|
const handleScheduleServerRestartReservation = async (
|
||||||
if (!hasAccess || serverRestartingKey) {
|
target: 'all' | 'work-server' = 'all',
|
||||||
return;
|
) => {
|
||||||
}
|
|
||||||
|
|
||||||
const testBaselineStatus = testServerStatus;
|
|
||||||
const progressTaskId = beginRestartProgress(getServerRestartTargetLabel('all'));
|
|
||||||
setServerRestartCopyFeedback(null);
|
|
||||||
setServerRestartFeedback(null);
|
|
||||||
setServerRestartingKey('all');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const testRestartedItem = await restartServerWithVerification('test', 'all', progressTaskId);
|
|
||||||
|
|
||||||
if (!testRestartedItem) {
|
|
||||||
closeRestartProgress();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRestartProgress(
|
|
||||||
progressTaskId,
|
|
||||||
'다음 서버 진행 중',
|
|
||||||
'TEST 서버 부팅 확인이 끝났습니다. 이어서 WORK 서버 재기동을 진행합니다.',
|
|
||||||
);
|
|
||||||
const workServerRestartedItem = await restartServerWithVerification('work-server', 'all', progressTaskId);
|
|
||||||
|
|
||||||
if (!workServerRestartedItem) {
|
|
||||||
closeRestartProgress();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const completionAction = shouldResetClientStateAfterRestart('test', testBaselineStatus, testRestartedItem)
|
|
||||||
? 'reset-client-state'
|
|
||||||
: 'reload';
|
|
||||||
|
|
||||||
setServerRestartFeedback({
|
|
||||||
tone: 'success',
|
|
||||||
message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.',
|
|
||||||
});
|
|
||||||
updateRestartProgress(
|
|
||||||
progressTaskId,
|
|
||||||
'전체 재기동 확인 완료',
|
|
||||||
`TEST 서버와 WORK 서버 모두 실제 부팅 완료를 확인했습니다. ${getRestartCompletionActionLabel(completionAction)} 여부를 선택해 주세요.`,
|
|
||||||
{ cancellable: false },
|
|
||||||
);
|
|
||||||
closeRestartProgress();
|
|
||||||
openRestartCompletionConfirm({
|
|
||||||
title: '전체 재기동 완료',
|
|
||||||
targetLabel: 'TEST 서버와 WORK 서버',
|
|
||||||
action: completionAction,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
||||||
setServerRestartFeedback({
|
|
||||||
tone: 'warning',
|
|
||||||
message: '전체 재기동 대기를 취소했습니다. 이미 접수된 서버 재기동은 계속될 수 있으니 상태를 다시 확인해 주세요.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setServerRestartFeedback({
|
|
||||||
tone: 'error',
|
|
||||||
message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
closeRestartProgress();
|
|
||||||
setServerRestartingKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScheduleServerRestartReservation = async () => {
|
|
||||||
if (!hasAccess || serverRestartReservationLoading || serverRestartingKey) {
|
if (!hasAccess || serverRestartReservationLoading || serverRestartingKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerRestartReservationLoading(true);
|
setServerRestartReservationLoading(true);
|
||||||
setServerRestartReservationFeedback(null);
|
setServerRestartReservationFeedback(null);
|
||||||
|
setServerRestartFeedback(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextReservation = await scheduleServerRestartReservation({
|
const nextReservation = await scheduleServerRestartReservation({
|
||||||
|
target,
|
||||||
autoExecuteDelaySeconds: appConfig.chat.restartReservationCompletionDelaySeconds,
|
autoExecuteDelaySeconds: appConfig.chat.restartReservationCompletionDelaySeconds,
|
||||||
});
|
});
|
||||||
setServerRestartReservation(nextReservation);
|
setServerRestartReservation(nextReservation);
|
||||||
setServerRestartReservationFeedback({
|
setServerRestartReservationFeedback({
|
||||||
tone: 'success',
|
tone: 'success',
|
||||||
message: `전체 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`,
|
message:
|
||||||
|
target === 'work-server'
|
||||||
|
? `WORK 서버 무중단 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`
|
||||||
|
: `전체 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setServerRestartReservationFeedback({
|
setServerRestartReservationFeedback({
|
||||||
@@ -4364,8 +4217,8 @@ export function MainHeader({
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
gestureShortcutSettingsDirty
|
gestureShortcutSettingsDirty
|
||||||
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
|
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}`
|
||||||
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch}`
|
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{appConfigFeedback ? (
|
{appConfigFeedback ? (
|
||||||
@@ -4417,23 +4270,6 @@ export function MainHeader({
|
|||||||
/>
|
/>
|
||||||
<Text type="secondary">통합 검색을 엽니다. 예: `Mod+K`, `Alt+/`</Text>
|
<Text type="secondary">통합 검색을 엽니다. 예: `Mod+K`, `Alt+/`</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
||||||
<Text strong>오른쪽 가운데 왼쪽 당기기 액션</Text>
|
|
||||||
<Input
|
|
||||||
value={appConfigDraft.gestureShortcuts.openWindowSearch}
|
|
||||||
placeholder={DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch}
|
|
||||||
onChange={(event) => {
|
|
||||||
updateAppConfigDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
gestureShortcuts: {
|
|
||||||
...current.gestureShortcuts,
|
|
||||||
openWindowSearch: event.target.value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text type="secondary">시스템 채팅을 엽니다. 모바일 제스처와 동일하게 현재 메뉴 기준 세션 채팅방을 실행합니다. 예: `Mod+Shift+K`</Text>
|
|
||||||
</Space>
|
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
||||||
설정 저장
|
설정 저장
|
||||||
@@ -5198,7 +5034,7 @@ export function MainHeader({
|
|||||||
<Text strong style={{ marginTop: 8 }}>
|
<Text strong style={{ marginTop: 8 }}>
|
||||||
서버 재기동
|
서버 재기동
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary">전체 재기동은 TEST와 WORK 서버만 순서대로 진행합니다.</Text>
|
<Text type="secondary">전체 재기동은 TEST와 WORK 서버만 순서대로 예약 실행합니다.</Text>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -5225,24 +5061,24 @@ export function MainHeader({
|
|||||||
block={screens.xs}
|
block={screens.xs}
|
||||||
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||||
loading={serverRestartingKey === 'work-server'}
|
loading={serverRestartingKey === 'work-server'}
|
||||||
disabled={!canRestartServers}
|
disabled={!canRestartServers || serverRestartReservationLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleRestartSingleServer('work-server');
|
void handleScheduleServerRestartReservation('work-server');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
워크 재기동
|
워크 재기동 예약
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
block={screens.xs}
|
block={screens.xs}
|
||||||
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||||
loading={serverRestartingKey === 'all'}
|
loading={serverRestartingKey === 'all' || serverRestartReservationLoading}
|
||||||
disabled={!canRestartServers}
|
disabled={!canRestartServers || serverRestartReservationLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleRestartBothServers();
|
void handleScheduleServerRestartReservation('all');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
전체 재기동
|
전체 재기동 예약
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Text strong style={{ marginTop: 8 }}>
|
<Text strong style={{ marginTop: 8 }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CloseOutlined } from '@ant-design/icons';
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { BaseballTicketBayPlayAppView } from '../../views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView';
|
||||||
import { EReaderAppView } from '../../views/play/apps/e-reader/EReaderAppView';
|
import { EReaderAppView } from '../../views/play/apps/e-reader/EReaderAppView';
|
||||||
import { PhotoPuzzleAppView } from '../../views/play/apps/photo-puzzle/PhotoPuzzleAppView';
|
import { PhotoPuzzleAppView } from '../../views/play/apps/photo-puzzle/PhotoPuzzleAppView';
|
||||||
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
|
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
|
||||||
@@ -20,6 +21,10 @@ function renderPlayApp(appId: string, onClose: () => void) {
|
|||||||
return <EReaderAppView onBack={onClose} launchContext="embedded" />;
|
return <EReaderAppView onBack={onClose} launchContext="embedded" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appId === 'baseball-ticket-bay') {
|
||||||
|
return <BaseballTicketBayPlayAppView onBack={onClose} launchContext="embedded" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (appId === 'photoprism') {
|
if (appId === 'photoprism') {
|
||||||
return <PhotoPrismAppView onBack={onClose} launchContext="embedded" />;
|
return <PhotoPrismAppView onBack={onClose} launchContext="embedded" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
.scoped-chat-rooms-window {
|
|
||||||
position: fixed;
|
|
||||||
right: 16px;
|
|
||||||
bottom: 16px;
|
|
||||||
z-index: 1400;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: min(620px, calc(100vw - 32px));
|
|
||||||
height: min(860px, calc(100vh - 32px));
|
|
||||||
border: 1px solid rgba(196, 210, 226, 0.92);
|
|
||||||
border-radius: 26px;
|
|
||||||
overflow: hidden;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(247, 249, 252, 0.98), rgba(242, 245, 250, 0.98)),
|
|
||||||
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 30%);
|
|
||||||
box-shadow:
|
|
||||||
0 30px 72px rgba(15, 23, 42, 0.16),
|
|
||||||
0 10px 24px rgba(148, 163, 184, 0.18);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window--mobile {
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100dvh;
|
|
||||||
border-radius: 0;
|
|
||||||
border-inline: 0;
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window--minimized {
|
|
||||||
right: auto;
|
|
||||||
bottom: auto;
|
|
||||||
width: 176px;
|
|
||||||
height: auto;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 20px;
|
|
||||||
overflow: visible;
|
|
||||||
box-shadow: none;
|
|
||||||
touch-action: none;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 56px;
|
|
||||||
padding: 0 14px 0 16px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.24);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window--minimized .scoped-chat-rooms-window__header {
|
|
||||||
min-height: 0;
|
|
||||||
padding: 8px 8px 10px;
|
|
||||||
border: 1px solid rgba(196, 210, 226, 0.92);
|
|
||||||
border-radius: 18px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(247, 249, 252, 0.98), rgba(242, 245, 250, 0.98)),
|
|
||||||
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 32%);
|
|
||||||
box-shadow:
|
|
||||||
0 18px 34px rgba(15, 23, 42, 0.14),
|
|
||||||
0 6px 18px rgba(148, 163, 184, 0.16);
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__title-mark {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
flex: 0 0 28px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
color: #2563eb;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(148, 163, 184, 0.24),
|
|
||||||
0 6px 16px rgba(148, 163, 184, 0.12);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__title-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__title-text {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__title-subtitle {
|
|
||||||
min-width: 0;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__actions {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__action.ant-btn {
|
|
||||||
width: 30px;
|
|
||||||
min-width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
color: #334155;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 0;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 249, 0.9));
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(148, 163, 184, 0.24),
|
|
||||||
0 6px 16px rgba(148, 163, 184, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__action.ant-btn:hover {
|
|
||||||
color: #1d4ed8;
|
|
||||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(219, 234, 254, 0.94));
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
|
|
||||||
0 8px 18px rgba(96, 165, 250, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__action--close.ant-btn:hover {
|
|
||||||
color: #b91c1c;
|
|
||||||
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.92));
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(248, 113, 113, 0.3),
|
|
||||||
0 8px 18px rgba(248, 113, 113, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__drag-handle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 20px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #334155;
|
|
||||||
cursor: grab;
|
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__drag-handle:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__drag-grip {
|
|
||||||
width: 20px;
|
|
||||||
height: 10px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: 999px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle, rgba(100, 116, 139, 0.9) 1.2px, transparent 1.4px) 0 0 / 6px 6px;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__drag-title {
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__minimized-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__actions--minimized {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__restore-button.ant-btn {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
height: 32px;
|
|
||||||
padding-inline: 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
border: 0;
|
|
||||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoped-chat-rooms-window__body .app-chat-panel {
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.scoped-chat-rooms-window--minimized {
|
|
||||||
width: min(176px, calc(100vw - 24px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { AppstoreOutlined, CloseOutlined } from '@ant-design/icons';
|
|
||||||
import { Button } from 'antd';
|
|
||||||
import { useCallback, useEffect, type ReactNode } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { FullscreenPreviewModal } from '../../components/previewer/FullscreenPreviewModal';
|
|
||||||
import {
|
|
||||||
removeMinimizedIsolatedChatRoomEntry,
|
|
||||||
upsertMinimizedIsolatedChatRoomEntry,
|
|
||||||
useActiveIsolatedChatRoomScope,
|
|
||||||
useMinimizedIsolatedChatRoomEntries,
|
|
||||||
writeActiveIsolatedChatRoomScope,
|
|
||||||
writeIsolatedChatRoomsWindowOpen,
|
|
||||||
} from './isolatedChatRoomScopeStore';
|
|
||||||
import './ScopedChatRoomsWindow.css';
|
|
||||||
|
|
||||||
const SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT = 'scoped-chat-rooms-window:action';
|
|
||||||
const MODAL_Z_INDEX = 1400;
|
|
||||||
const MINIMIZED_Z_INDEX = MODAL_Z_INDEX + 5;
|
|
||||||
|
|
||||||
type ScopedChatRoomsWindowProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
onClose?: (() => void) | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function requestScopedChatRoomsWindowAction(action: 'minimize' | 'close') {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, {
|
|
||||||
detail: { action },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScopedChatRoomsWindow({ children, onClose = null }: ScopedChatRoomsWindowProps) {
|
|
||||||
const activeScope = useActiveIsolatedChatRoomScope();
|
|
||||||
const title = activeScope?.featureTitle?.trim() || activeScope?.menuTitle?.trim() || '시스템 채팅방';
|
|
||||||
|
|
||||||
const handleMinimize = useCallback(() => {
|
|
||||||
upsertMinimizedIsolatedChatRoomEntry(activeScope);
|
|
||||||
writeIsolatedChatRoomsWindowOpen(false);
|
|
||||||
}, [activeScope]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWindowAction = (event: Event) => {
|
|
||||||
const detail =
|
|
||||||
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
|
|
||||||
? (event.detail as { action?: 'minimize' | 'close' })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (detail?.action === 'close') {
|
|
||||||
writeIsolatedChatRoomsWindowOpen(false);
|
|
||||||
onClose?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail?.action === 'minimize') {
|
|
||||||
handleMinimize();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, handleWindowAction);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, handleWindowAction);
|
|
||||||
};
|
|
||||||
}, [handleMinimize, onClose]);
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<FullscreenPreviewModal
|
|
||||||
open
|
|
||||||
hideHeader
|
|
||||||
zIndex={MODAL_Z_INDEX}
|
|
||||||
maskClosable={false}
|
|
||||||
className="scoped-chat-rooms-window__program-modal scoped-chat-rooms-window__program-modal--system-chat-room"
|
|
||||||
contentClassName="scoped-chat-rooms-window__program-modal-content"
|
|
||||||
fillContent
|
|
||||||
title={title}
|
|
||||||
onMinimize={handleMinimize}
|
|
||||||
onClose={() => {
|
|
||||||
writeIsolatedChatRoomsWindowOpen(false);
|
|
||||||
onClose?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="scoped-chat-rooms-window__program-app-shell scoped-chat-rooms-window__program-app-shell--system-chat-room">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</FullscreenPreviewModal>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScopedChatRoomsWindowDock() {
|
|
||||||
const minimizedEntries = useMinimizedIsolatedChatRoomEntries();
|
|
||||||
|
|
||||||
if (typeof document === 'undefined' || minimizedEntries.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="scoped-chat-rooms-window__dock" style={{ zIndex: MINIMIZED_Z_INDEX }}>
|
|
||||||
{minimizedEntries.map((entry) => {
|
|
||||||
const title = entry.scope.featureTitle?.trim() || entry.scope.menuTitle?.trim() || '시스템 채팅방';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={entry.id} className="scoped-chat-rooms-window__program-minimized">
|
|
||||||
<div className="scoped-chat-rooms-window__program-minimized-drag">
|
|
||||||
<span className="scoped-chat-rooms-window__program-minimized-drag-grip" aria-hidden="true" />
|
|
||||||
<span className="scoped-chat-rooms-window__program-minimized-title">{title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="scoped-chat-rooms-window__program-minimized-actions">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
icon={<AppstoreOutlined />}
|
|
||||||
className="scoped-chat-rooms-window__program-minimized-button"
|
|
||||||
onClick={() => {
|
|
||||||
writeActiveIsolatedChatRoomScope(entry.scope);
|
|
||||||
removeMinimizedIsolatedChatRoomEntry(entry.id);
|
|
||||||
writeIsolatedChatRoomsWindowOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
열기
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
className="scoped-chat-rooms-window__program-minimized-icon scoped-chat-rooms-window__program-minimized-close"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
aria-label="최소화 항목 닫기"
|
|
||||||
onClick={() => {
|
|
||||||
removeMinimizedIsolatedChatRoomEntry(entry.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -237,9 +237,6 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
<Form.Item label="검색 단축키" name={['gestureShortcuts', 'openSearch']}>
|
<Form.Item label="검색 단축키" name={['gestureShortcuts', 'openSearch']}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="시스템 채팅 단축키" name={['gestureShortcuts', 'openWindowSearch']}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTokenSettingRegistry, type TokenSettingRecord } from './tokenSetting
|
|||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import { createManagedChatShareRoom, type ManagedChatShareRoom } from './mainChatPanel';
|
import { createManagedChatShareRoom, type ManagedChatShareRoom } from './mainChatPanel';
|
||||||
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
|
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
|
||||||
import { resolveChatPathForSession } from './isolatedChatRooms';
|
import { resolveChatPathForSession } from './chatSessionRouting';
|
||||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import './SharedChatManagementPage.css';
|
import './SharedChatManagementPage.css';
|
||||||
|
|
||||||
@@ -74,10 +74,10 @@ export function SharedChatManagementPage() {
|
|||||||
() =>
|
() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
selectedTokenSetting &&
|
selectedTokenSetting &&
|
||||||
(hasAllowedApp(selectedTokenSetting, 'chat-rooms') ||
|
(hasAllowedApp(selectedTokenSetting, 'chat-room-settings') ||
|
||||||
hasAllowedApp(selectedTokenSetting, 'chat-room-settings') ||
|
|
||||||
hasAllowedApp(selectedTokenSetting, 'token-setting') ||
|
hasAllowedApp(selectedTokenSetting, 'token-setting') ||
|
||||||
hasAllowedApp(selectedTokenSetting, 'shared-resource')),
|
hasAllowedApp(selectedTokenSetting, 'shared-resource') ||
|
||||||
|
hasAllowedApp(selectedTokenSetting, 'server-command')),
|
||||||
),
|
),
|
||||||
[selectedTokenSetting],
|
[selectedTokenSetting],
|
||||||
);
|
);
|
||||||
@@ -241,10 +241,10 @@ export function SharedChatManagementPage() {
|
|||||||
tokenSettingId: item.id,
|
tokenSettingId: item.id,
|
||||||
allowManageAccess:
|
allowManageAccess:
|
||||||
previous.allowManageAccess &&
|
previous.allowManageAccess &&
|
||||||
(hasAllowedApp(item, 'chat-rooms') ||
|
(hasAllowedApp(item, 'chat-room-settings') ||
|
||||||
hasAllowedApp(item, 'chat-room-settings') ||
|
|
||||||
hasAllowedApp(item, 'token-setting') ||
|
hasAllowedApp(item, 'token-setting') ||
|
||||||
hasAllowedApp(item, 'shared-resource')),
|
hasAllowedApp(item, 'shared-resource') ||
|
||||||
|
hasAllowedApp(item, 'server-command')),
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEv
|
|||||||
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
|
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
|
||||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||||
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
|
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
|
||||||
import { resolveChatPathForSession } from './isolatedChatRooms';
|
import { resolveChatPathForSession } from './chatSessionRouting';
|
||||||
import { useTokenAccess } from './tokenAccess';
|
import { useTokenAccess } from './tokenAccess';
|
||||||
import { confirmWithKeyboard } from './modalKeyboard';
|
import { confirmWithKeyboard } from './modalKeyboard';
|
||||||
import {
|
import {
|
||||||
@@ -44,12 +44,12 @@ const RESOURCE_TYPE_OPTIONS: Array<{ value: SharedResourceType; label: string }>
|
|||||||
|
|
||||||
const MANAGEMENT_APP_OPTIONS = [
|
const MANAGEMENT_APP_OPTIONS = [
|
||||||
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
||||||
{ value: 'chat-rooms', label: '시스템 채팅방', description: '메뉴별 시스템 채팅방 화면 접근', category: '관리' },
|
|
||||||
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
||||||
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
||||||
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
||||||
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
||||||
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
|
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
|
||||||
|
{ value: 'server-command', label: '서버관리', description: '서버 상태 확인과 재기동 예약/실행 접근', category: '관리' },
|
||||||
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
|
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
|
||||||
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
|
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -1514,7 +1514,7 @@ export function SharedResourceManagementPage({
|
|||||||
<div className="shared-resource-management-page__inline-option-row">
|
<div className="shared-resource-management-page__inline-option-row">
|
||||||
<Button onClick={applyAdminPreset}>관리자 권한 바로 추가</Button>
|
<Button onClick={applyAdminPreset}>관리자 권한 바로 추가</Button>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
시스템 채팅방, 채팅방 설정, 토큰/공유 리소스 관리 등 운영용 앱과 전체 관리 권한을 한 번에 채웁니다.
|
채팅방 설정, 토큰/공유 리소스 관리 등 운영용 앱과 전체 관리 권한을 한 번에 채웁니다.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
2432
src/app/main/SystemChatPage.css
Normal file
2432
src/app/main/SystemChatPage.css
Normal file
File diff suppressed because it is too large
Load Diff
915
src/app/main/SystemChatPage.tsx
Normal file
915
src/app/main/SystemChatPage.tsx
Normal file
@@ -0,0 +1,915 @@
|
|||||||
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { App, Alert, Button, Dropdown, Input, Modal, Select, Tag, Typography, type MenuProps } from 'antd';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ChatLinkCardPreview } from './mainChatPanel/ChatLinkCardPreview';
|
||||||
|
import { ChatPreviewBody, type ChatPreviewTarget } from './mainChatPanel/ChatPreviewBody';
|
||||||
|
import {
|
||||||
|
ChatPromptCard,
|
||||||
|
buildPromptTargetSignature,
|
||||||
|
type PromptDraftSelection,
|
||||||
|
type PromptSubmitPayload,
|
||||||
|
} from './mainChatPanel/ChatPromptCard';
|
||||||
|
import { extractChatMessageParts } from './mainChatPanel/messageParts';
|
||||||
|
import { extractPreviewItems, type PreviewItem } from './mainChatPanel/previewItems';
|
||||||
|
import { stripHiddenPreviewTags } from './mainChatPanel/previewMarkers';
|
||||||
|
import type { ChatMessage, ChatMessagePart } from './mainChatPanel/types';
|
||||||
|
import './systemChatStyles/MainChatPanel.conversation.css';
|
||||||
|
import './systemChatStyles/MainChatPanel.preview-runtime.css';
|
||||||
|
import './SystemChatPage.css';
|
||||||
|
|
||||||
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
|
type MockRequestStatus = 'completed' | 'pending';
|
||||||
|
|
||||||
|
type MockRequest = {
|
||||||
|
requestId: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: MockRequestStatus;
|
||||||
|
statusLabel: string;
|
||||||
|
statusColor: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
answerParts?: ChatMessagePart[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchPanelMode = 'all' | 'apps';
|
||||||
|
type ExpandMode = 'latest' | 'pending' | 'all';
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: 'request' | 'response' | 'resource' | 'activity';
|
||||||
|
};
|
||||||
|
|
||||||
|
type SystemRenderedMessage = {
|
||||||
|
visibleText: string;
|
||||||
|
linkCardParts: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||||||
|
promptParts: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||||||
|
previewItems: PreviewItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_REQUESTS: MockRequest[] = [
|
||||||
|
{
|
||||||
|
requestId: 'system-request-001',
|
||||||
|
createdAt: '05. 25. 21:30:00',
|
||||||
|
status: 'completed',
|
||||||
|
statusLabel: '완료',
|
||||||
|
statusColor: 'green',
|
||||||
|
question: '시스템 채팅 전용 기본 화면을 공유채팅과 같은 감성으로 맞춰 주세요.',
|
||||||
|
answer: [
|
||||||
|
'현재 화면은 UI 전용 mock 입니다. 서버 호출 없이 공유채팅과 같은 구조를 그대로 보이도록 구성했습니다.',
|
||||||
|
'',
|
||||||
|
'[[link-card:시스템 채팅 화면 열기|/chat/system|열기]]',
|
||||||
|
'',
|
||||||
|
'[[preview:/src/app/main/SystemChatPage.tsx]]',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestId: 'system-request-002',
|
||||||
|
createdAt: '05. 25. 21:31:10',
|
||||||
|
status: 'pending',
|
||||||
|
statusLabel: '처리중·미확인',
|
||||||
|
statusColor: 'gold',
|
||||||
|
question: 'Codex Live, 공유채팅과 소스를 분리한 상태에서 이후 시스템 채팅 전용 기능만 추가할 수 있게 해 주세요.',
|
||||||
|
answer: '이 페이지는 별도 파일과 시스템 채팅 전용 CSS 레이어를 사용합니다. 렌더 표현은 Codex Live와 같은 컴포넌트를 쓰더라도 상태와 배치는 여기서만 분리합니다.',
|
||||||
|
answerParts: [
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
title: '답변 방식 선택',
|
||||||
|
description: '시스템 채팅 mock prompt 입니다.',
|
||||||
|
submitLabel: 'mock 전송',
|
||||||
|
mode: 'queue',
|
||||||
|
freeTextLabel: '추가 요청',
|
||||||
|
freeTextPlaceholder: '시스템 채팅에서 이어서 확인할 내용을 입력하세요.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'brief',
|
||||||
|
label: '짧게 정리',
|
||||||
|
description: '핵심만 바로 답합니다.',
|
||||||
|
preview: {
|
||||||
|
type: 'markdown',
|
||||||
|
title: '짧게 정리 예시',
|
||||||
|
content: '짧은 답변 예시: 핵심만 먼저 전달하고 필요시 후속 설명을 확장합니다.',
|
||||||
|
url: null,
|
||||||
|
alt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full',
|
||||||
|
label: '상세 설명',
|
||||||
|
description: '배경과 이유까지 함께 답합니다.',
|
||||||
|
preview: {
|
||||||
|
type: 'markdown',
|
||||||
|
title: '상세 설명 예시',
|
||||||
|
content: '상세 설명 예시: 시스템 채팅 전용 흐름의 배경과 이유까지 함께 설명합니다.',
|
||||||
|
url: null,
|
||||||
|
alt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestId: 'system-request-003',
|
||||||
|
createdAt: '05. 25. 21:33:40',
|
||||||
|
status: 'completed',
|
||||||
|
statusLabel: '완료',
|
||||||
|
statusColor: 'green',
|
||||||
|
question: '시스템 채팅 검색 모달과 토큰 관리 모달 배치를 실제 화면처럼 먼저 잡아 주세요.',
|
||||||
|
answer: [
|
||||||
|
'검색 모달, 토큰 관리, 채팅방 설정 모달은 현재 mock 데이터로 열리며, 향후 시스템 채팅 전용 서버 연결만 붙일 수 있게 비워 두었습니다.',
|
||||||
|
'',
|
||||||
|
'[[link-card:토큰 관리 UI 안내|https://preview.sm-home.cloud/docs|열기]]',
|
||||||
|
'',
|
||||||
|
'[[preview:/src/app/main/SystemChatPage.css]]',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestId: 'system-request-004',
|
||||||
|
createdAt: '05. 25. 21:35:15',
|
||||||
|
status: 'pending',
|
||||||
|
statusLabel: '처리중·미확인',
|
||||||
|
statusColor: 'gold',
|
||||||
|
question: '이전 다음 이동이 현재 보이는 필터 기준으로만 동작하게 해 주세요.',
|
||||||
|
answer: '필터가 전체면 전체 목록 기준, 처리중·미확인이면 해당 건들만 기준, 마지막건이면 현재 선택된 1건만 보이도록 맞춥니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestId: 'system-request-005',
|
||||||
|
createdAt: '05. 25. 21:38:55',
|
||||||
|
status: 'pending',
|
||||||
|
statusLabel: '처리중·미확인',
|
||||||
|
statusColor: 'gold',
|
||||||
|
question: '웹소켓 상태는 제목 옆 점으로만 간단히 보여 주고, 현재 진행 상황 패널은 빼 주세요.',
|
||||||
|
answer: '상태 표현은 헤더 옆 점으로 단순화하고, 별도 진행 상황 카드 없이 채팅 흐름만 보도록 구성할 수 있습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEARCH_RESULTS: SearchResult[] = [
|
||||||
|
{
|
||||||
|
key: 'request-001',
|
||||||
|
title: '질문 / 시스템 채팅 전용 기본 화면',
|
||||||
|
description: '공유채팅과 같은 레이아웃으로 구성된 mock 질문 카드입니다.',
|
||||||
|
category: 'request',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'response-001',
|
||||||
|
title: '답변 / UI 전용 mock 상태',
|
||||||
|
description: '서버 호출 없이 동작하는 현재 화면 설명입니다.',
|
||||||
|
category: 'response',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'resource-001',
|
||||||
|
title: '리소스 / 시스템 채팅 전용 스타일 복사본',
|
||||||
|
description: '공유채팅과 분리된 전용 CSS 파일을 사용합니다.',
|
||||||
|
category: 'resource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'activity-001',
|
||||||
|
title: '활동 / 필터와 이전 다음 mock 흐름',
|
||||||
|
description: '마지막건, 처리중·미확인, 전체 필터에 따라 보이는 흐름이 달라집니다.',
|
||||||
|
category: 'activity',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function dedupePromptParts(parts: Extract<ChatMessagePart, { type: 'prompt' }>[]) {
|
||||||
|
const promptByKey = new Map<string, Extract<ChatMessagePart, { type: 'prompt' }>>();
|
||||||
|
|
||||||
|
parts.forEach((part) => {
|
||||||
|
const key = `${part.title}:${buildPromptTargetSignature(part)}`;
|
||||||
|
if (!promptByKey.has(key)) {
|
||||||
|
promptByKey.set(key, part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...promptByKey.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeLinkCardParts(parts: Extract<ChatMessagePart, { type: 'link_card' }>[]) {
|
||||||
|
const linkCardByKey = new Map<string, Extract<ChatMessagePart, { type: 'link_card' }>>();
|
||||||
|
|
||||||
|
parts.forEach((part) => {
|
||||||
|
const key = `${part.title}:${part.url}:${part.actionLabel ?? ''}`;
|
||||||
|
if (!linkCardByKey.has(key)) {
|
||||||
|
linkCardByKey.set(key, part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...linkCardByKey.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSystemMessageRenderPayload(message: ChatMessage): SystemRenderedMessage {
|
||||||
|
const extracted = extractChatMessageParts(message.text);
|
||||||
|
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
|
||||||
|
const visibleText = stripHiddenPreviewTags(extracted.strippedText || message.text).trim();
|
||||||
|
const promptParts = dedupePromptParts([
|
||||||
|
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||||
|
...extracted.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||||
|
]);
|
||||||
|
const linkCardParts = dedupeLinkCardParts([
|
||||||
|
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||||
|
...extracted.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleText,
|
||||||
|
linkCardParts,
|
||||||
|
promptParts,
|
||||||
|
previewItems: extractPreviewItems([message]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPromptSelectionKey(messageId: number, promptIndex: number, target: Extract<ChatMessagePart, { type: 'prompt' }>) {
|
||||||
|
return `${messageId}:${promptIndex}:${target.title}:${buildPromptTargetSignature(target)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemChatPreviewCard({ item }: { item: PreviewItem }) {
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [previewText, setPreviewText] = useState('');
|
||||||
|
const [previewError, setPreviewError] = useState('');
|
||||||
|
const [previewContentType, setPreviewContentType] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const target = useMemo<ChatPreviewTarget>(
|
||||||
|
() => ({
|
||||||
|
label: item.label,
|
||||||
|
url: item.url,
|
||||||
|
kind: item.kind,
|
||||||
|
}),
|
||||||
|
[item.kind, item.label, item.url],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPreviewOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'image' || item.kind === 'video' || item.kind === 'pdf' || item.kind === 'file') {
|
||||||
|
setPreviewText('');
|
||||||
|
setPreviewError('');
|
||||||
|
setPreviewContentType('');
|
||||||
|
setIsLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setIsLoading(true);
|
||||||
|
setPreviewError('');
|
||||||
|
|
||||||
|
fetch(item.url, {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewContentType(response.headers.get('content-type') ?? '');
|
||||||
|
const text = await response.text();
|
||||||
|
setPreviewText(text);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewText('');
|
||||||
|
setPreviewContentType('');
|
||||||
|
setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [isPreviewOpen, item.kind, item.url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="system-chat-page__preview-card app-chat-preview-card">
|
||||||
|
<div className="app-chat-preview-card__header">
|
||||||
|
<div className="app-chat-preview-card__meta">
|
||||||
|
<div className="app-chat-preview-card__titles">
|
||||||
|
<span className="app-chat-preview-card__label">{item.label}</span>
|
||||||
|
<span className="app-chat-preview-card__kind">{item.kind} preview</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="app-chat-preview-card__actions">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="app-chat-preview-card__open-link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
aria-label={isPreviewOpen ? `${item.label} 접기` : `${item.label} 미리보기`}
|
||||||
|
onClick={() => setIsPreviewOpen((current) => !current)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isPreviewOpen ? (
|
||||||
|
<div className="app-chat-preview-card__body system-chat-page__preview-card-body">
|
||||||
|
<ChatPreviewBody
|
||||||
|
target={target}
|
||||||
|
previewText={previewText}
|
||||||
|
isPreviewLoading={isLoading}
|
||||||
|
previewError={previewError}
|
||||||
|
previewContentType={previewContentType || undefined}
|
||||||
|
renderHtmlAsFrame
|
||||||
|
maxMarkdownBlocks={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemChatMessageArtifacts({
|
||||||
|
message,
|
||||||
|
promptDraftSelections,
|
||||||
|
promptSubmittedSelections,
|
||||||
|
onPromptSelectionChange,
|
||||||
|
onPromptSubmitted,
|
||||||
|
onPromptSubmit,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
promptDraftSelections: Record<string, PromptDraftSelection | null>;
|
||||||
|
promptSubmittedSelections: Record<string, PromptDraftSelection | null>;
|
||||||
|
onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void;
|
||||||
|
onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void;
|
||||||
|
onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { linkCardParts, promptParts, previewItems } = useMemo(
|
||||||
|
() => extractSystemMessageRenderPayload(message),
|
||||||
|
[message],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (linkCardParts.length === 0 && promptParts.length === 0 && previewItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-chat-page__artifact-stack">
|
||||||
|
{linkCardParts.length > 0 ? (
|
||||||
|
<div className="system-chat-page__artifact-list">
|
||||||
|
{linkCardParts.map((target) => (
|
||||||
|
<ChatLinkCardPreview key={`${target.title}:${target.url}`} target={target} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{promptParts.length > 0 ? (
|
||||||
|
<div className="system-chat-page__artifact-list">
|
||||||
|
{promptParts.map((target, promptIndex) => {
|
||||||
|
const selectionKey = buildSystemPromptSelectionKey(message.id, promptIndex, target);
|
||||||
|
return (
|
||||||
|
<ChatPromptCard
|
||||||
|
key={selectionKey}
|
||||||
|
target={target}
|
||||||
|
draftSelection={promptDraftSelections[selectionKey] ?? null}
|
||||||
|
submittedSelection={promptSubmittedSelections[selectionKey] ?? null}
|
||||||
|
onSelectionChange={(selection) => onPromptSelectionChange(selectionKey, selection)}
|
||||||
|
onSubmitted={(selection) => onPromptSubmitted(selectionKey, selection)}
|
||||||
|
onSubmit={(payload) => onPromptSubmit(selectionKey, payload)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{previewItems.length > 0 ? (
|
||||||
|
<div className="system-chat-page__artifact-list">
|
||||||
|
{previewItems.map((item) => (
|
||||||
|
<SystemChatPreviewCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemChatRequestCard({
|
||||||
|
request,
|
||||||
|
isReplyActive,
|
||||||
|
promptDraftSelections,
|
||||||
|
promptSubmittedSelections,
|
||||||
|
onReplyToggle,
|
||||||
|
onComplete,
|
||||||
|
onPromptSelectionChange,
|
||||||
|
onPromptSubmitted,
|
||||||
|
onPromptSubmit,
|
||||||
|
}: {
|
||||||
|
request: MockRequest;
|
||||||
|
isReplyActive: boolean;
|
||||||
|
promptDraftSelections: Record<string, PromptDraftSelection | null>;
|
||||||
|
promptSubmittedSelections: Record<string, PromptDraftSelection | null>;
|
||||||
|
onReplyToggle: (requestId: string) => void;
|
||||||
|
onComplete: (requestId: string) => void;
|
||||||
|
onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void;
|
||||||
|
onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void;
|
||||||
|
onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const isCompleted = request.status === 'completed';
|
||||||
|
const questionMessage = useMemo<ChatMessage>(
|
||||||
|
() => ({
|
||||||
|
id: Number(`${request.requestId.replace(/\D+/g, '') || '0'}1`),
|
||||||
|
author: 'user',
|
||||||
|
text: request.question,
|
||||||
|
timestamp: request.createdAt,
|
||||||
|
}),
|
||||||
|
[request.createdAt, request.question, request.requestId],
|
||||||
|
);
|
||||||
|
const answerMessage = useMemo<ChatMessage>(
|
||||||
|
() => ({
|
||||||
|
id: Number(`${request.requestId.replace(/\D+/g, '') || '0'}2`),
|
||||||
|
author: 'codex',
|
||||||
|
text: request.answer,
|
||||||
|
timestamp: request.createdAt,
|
||||||
|
clientRequestId: request.requestId,
|
||||||
|
parts: request.answerParts,
|
||||||
|
}),
|
||||||
|
[request.answer, request.answerParts, request.createdAt, request.requestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={`system-chat-request-${request.requestId}`} className="chat-share-page__request-block">
|
||||||
|
<span className="chat-share-page__message-time">{request.createdAt}</span>
|
||||||
|
<div className="chat-share-page__message-tone chat-share-page__message-tone--question">
|
||||||
|
<span className="chat-share-page__message-tone-label">질문</span>
|
||||||
|
<Paragraph className="chat-share-page__message-body system-chat-page__message-text" style={{ marginBottom: 0 }}>
|
||||||
|
{extractSystemMessageRenderPayload(questionMessage).visibleText}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<SystemChatMessageArtifacts
|
||||||
|
message={questionMessage}
|
||||||
|
promptDraftSelections={promptDraftSelections}
|
||||||
|
promptSubmittedSelections={promptSubmittedSelections}
|
||||||
|
onPromptSelectionChange={onPromptSelectionChange}
|
||||||
|
onPromptSubmitted={onPromptSubmitted}
|
||||||
|
onPromptSubmit={onPromptSubmit}
|
||||||
|
/>
|
||||||
|
<div className="chat-share-page__message-divider" aria-hidden="true" />
|
||||||
|
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
|
||||||
|
{!isCompleted ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="chat-share-page__prompt-complete-button"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => onComplete(request.requestId)}
|
||||||
|
>
|
||||||
|
완료 처리
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className={`chat-share-page__prompt-complete-button chat-share-page__response-reply-button${isReplyActive ? ' chat-share-page__response-reply-button--active' : ''}`}
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={() => onReplyToggle(request.requestId)}
|
||||||
|
>
|
||||||
|
{isReplyActive ? '답변 참조 중' : '답변하기'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__message-tone chat-share-page__message-tone--answer">
|
||||||
|
<span className="chat-share-page__message-tone-label">답변</span>
|
||||||
|
<Paragraph className="chat-share-page__message-body system-chat-page__message-text" style={{ marginBottom: 0 }}>
|
||||||
|
{extractSystemMessageRenderPayload(answerMessage).visibleText}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<SystemChatMessageArtifacts
|
||||||
|
message={answerMessage}
|
||||||
|
promptDraftSelections={promptDraftSelections}
|
||||||
|
promptSubmittedSelections={promptSubmittedSelections}
|
||||||
|
onPromptSelectionChange={onPromptSelectionChange}
|
||||||
|
onPromptSubmitted={onPromptSubmitted}
|
||||||
|
onPromptSubmit={onPromptSubmit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemChatPage() {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [requests, setRequests] = useState<MockRequest[]>(INITIAL_REQUESTS);
|
||||||
|
const [draftText, setDraftText] = useState('');
|
||||||
|
const [replyReferenceRequestId, setReplyReferenceRequestId] = useState<string | null>('system-request-002');
|
||||||
|
const [promptDraftSelections, setPromptDraftSelections] = useState<Record<string, PromptDraftSelection | null>>({});
|
||||||
|
const [promptSubmittedSelections, setPromptSubmittedSelections] = useState<Record<string, PromptDraftSelection | null>>({});
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [isTokenUsageOpen, setIsTokenUsageOpen] = useState(false);
|
||||||
|
const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searchPanelMode, setSearchPanelMode] = useState<SearchPanelMode>('all');
|
||||||
|
const [expandMode, setExpandMode] = useState<ExpandMode>('pending');
|
||||||
|
const [selectedRequestId, setSelectedRequestId] = useState('system-request-002');
|
||||||
|
const [selectedAppEnvironment, setSelectedAppEnvironment] = useState<'preview' | 'test' | 'prod'>('preview');
|
||||||
|
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState('system-chat');
|
||||||
|
const [editingRoomUseAccessPin, setEditingRoomUseAccessPin] = useState(true);
|
||||||
|
const [editingRoomAccessPin, setEditingRoomAccessPin] = useState('1234');
|
||||||
|
const [editingRoomAccessPinPromptTtl, setEditingRoomAccessPinPromptTtl] = useState('30');
|
||||||
|
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const filteredSearchResults = useMemo(() => {
|
||||||
|
const normalizedKeyword = searchKeyword.trim().toLowerCase();
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return SEARCH_RESULTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SEARCH_RESULTS.filter((item) => `${item.title} ${item.description} ${item.category}`.toLowerCase().includes(normalizedKeyword));
|
||||||
|
}, [searchKeyword]);
|
||||||
|
|
||||||
|
const pendingRequests = useMemo(() => requests.filter((request) => request.status === 'pending'), [requests]);
|
||||||
|
const navigationRequests = useMemo(() => (expandMode === 'pending' ? pendingRequests : requests), [expandMode, pendingRequests, requests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigationRequests.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!navigationRequests.some((request) => request.requestId === selectedRequestId)) {
|
||||||
|
setSelectedRequestId(navigationRequests[0].requestId);
|
||||||
|
}
|
||||||
|
}, [navigationRequests, selectedRequestId]);
|
||||||
|
|
||||||
|
const selectedRequest = useMemo(
|
||||||
|
() => navigationRequests.find((request) => request.requestId === selectedRequestId) ?? navigationRequests[0] ?? requests[0],
|
||||||
|
[navigationRequests, requests, selectedRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayedRequests = useMemo(() => {
|
||||||
|
if (expandMode === 'latest') {
|
||||||
|
return selectedRequest ? [selectedRequest] : [];
|
||||||
|
}
|
||||||
|
return navigationRequests;
|
||||||
|
}, [expandMode, navigationRequests, selectedRequest]);
|
||||||
|
|
||||||
|
const selectedRequestIndex = useMemo(
|
||||||
|
() => navigationRequests.findIndex((request) => request.requestId === selectedRequest?.requestId),
|
||||||
|
[navigationRequests, selectedRequest],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canMoveToPreviousRequest = expandMode === 'latest' && selectedRequestIndex > 0;
|
||||||
|
const canMoveToNextRequest = expandMode === 'latest' && selectedRequestIndex >= 0 && selectedRequestIndex < navigationRequests.length - 1;
|
||||||
|
const hiddenPreviousCount = expandMode === 'latest' && selectedRequestIndex > 0 ? selectedRequestIndex : 0;
|
||||||
|
const hiddenNextCount = expandMode === 'latest' && selectedRequestIndex >= 0 ? Math.max(0, navigationRequests.length - selectedRequestIndex - 1) : 0;
|
||||||
|
const pendingCount = pendingRequests.length;
|
||||||
|
const aggregateStatusTag = pendingCount > 0 ? { color: 'gold', label: '처리중·미확인' } : { color: 'green', label: '완료' };
|
||||||
|
const headerSummaryLabel = `입력 대기 · 처리 건수 ${requests.length}건 · 미확인 ${pendingCount}건`;
|
||||||
|
|
||||||
|
const shareHeaderSettingsItems = useMemo<MenuProps['items']>(
|
||||||
|
() => [
|
||||||
|
{ key: 'conversation-summary', label: '현재 시스템 채팅방' },
|
||||||
|
{ key: 'conversation-search', label: '통합검색' },
|
||||||
|
{ key: 'conversation-refresh', label: '화면 새로고침' },
|
||||||
|
{ key: 'conversation-apps', label: 'Apps' },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'conversation-filter-title', label: '콘텐츠 필터', disabled: true },
|
||||||
|
{ key: 'conversation-filter-latest', label: '마지막건' },
|
||||||
|
{ key: 'conversation-filter-pending', label: '처리중·미확인' },
|
||||||
|
{ key: 'conversation-filter-all', label: '전체' },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'conversation-token-usage', label: '토큰 관리' },
|
||||||
|
{ key: 'conversation-room-settings', label: '채팅방 설정' },
|
||||||
|
{ key: 'conversation-clear', label: '채팅방 비우기' },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderMenuClick = ({ key }: { key: string }) => {
|
||||||
|
if (key === 'conversation-search') {
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
setSearchPanelMode('all');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'conversation-apps') {
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
setSearchPanelMode('apps');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'conversation-filter-latest') {
|
||||||
|
setExpandMode('latest');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'conversation-filter-pending') {
|
||||||
|
setExpandMode('pending');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'conversation-filter-all') {
|
||||||
|
setExpandMode('all');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'conversation-token-usage') {
|
||||||
|
setIsTokenUsageOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'conversation-room-settings') {
|
||||||
|
setIsRoomSettingsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.info('시스템 채팅 UI mock 화면입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
message.info('시스템 채팅 UI mock 화면입니다. 현재는 서버 호출 없이 형태만 제공합니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAttachPicker = () => {
|
||||||
|
attachInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachSelection = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
message.info('파일 또는 사진 ' + String(files.length) + '건 선택 mock 상태입니다.');
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveRequest = (direction: -1 | 1) => {
|
||||||
|
const nextIndex = selectedRequestIndex + direction;
|
||||||
|
const nextRequest = navigationRequests[nextIndex];
|
||||||
|
if (nextRequest) {
|
||||||
|
setSelectedRequestId(nextRequest.requestId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteRequest = (requestId: string) => {
|
||||||
|
setRequests((current) =>
|
||||||
|
current.map((request) =>
|
||||||
|
request.requestId === requestId
|
||||||
|
? {
|
||||||
|
...request,
|
||||||
|
status: 'completed',
|
||||||
|
statusLabel: '완료',
|
||||||
|
statusColor: 'green',
|
||||||
|
}
|
||||||
|
: request,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
message.success('완료 처리 mock 상태를 반영했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplyToggle = (requestId: string) => {
|
||||||
|
setReplyReferenceRequestId((current) => (current === requestId ? null : requestId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptSelectionChange = (key: string, selection: PromptDraftSelection | null) => {
|
||||||
|
setPromptDraftSelections((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: selection,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptSubmitted = (key: string, selection: PromptDraftSelection) => {
|
||||||
|
setPromptSubmittedSelections((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: selection,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptSubmit = async (key: string, payload: PromptSubmitPayload) => {
|
||||||
|
setPromptSubmittedSelections((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: payload.selection,
|
||||||
|
}));
|
||||||
|
message.success(`${payload.promptTitle} mock 선택을 반영했습니다.`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-share-page system-chat-page">
|
||||||
|
<div className="chat-share-page__shell">
|
||||||
|
<div className="chat-share-page__prompt-layout">
|
||||||
|
<section className="chat-share-page__panel chat-share-page__conversation-panel">
|
||||||
|
<div className="chat-share-page__section-head">
|
||||||
|
<div className="chat-share-page__section-copy">
|
||||||
|
<div className="chat-share-page__section-title-row">
|
||||||
|
<div className="system-chat-page__title-status">
|
||||||
|
<Title level={5}>채팅</Title>
|
||||||
|
<span className="system-chat-page__ws-indicator system-chat-page__ws-indicator--connected" aria-label="웹소켓 연결 정상" title="웹소켓 연결 정상" />
|
||||||
|
</div>
|
||||||
|
<Tag color={aggregateStatusTag.color}>{aggregateStatusTag.label}</Tag>
|
||||||
|
<Text type="secondary" className="chat-share-page__header-summary">{headerSummaryLabel}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__section-actions">
|
||||||
|
<div className="chat-share-page__request-nav" aria-label="요청 이동">
|
||||||
|
<Button type="text" size="small" className="chat-share-page__section-action" icon={<LeftOutlined />} disabled={!canMoveToPreviousRequest} onClick={() => handleMoveRequest(-1)}>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button type="text" size="small" className="chat-share-page__section-action" icon={<RightOutlined />} iconPosition="end" disabled={!canMoveToNextRequest} onClick={() => handleMoveRequest(1)}>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{ items: shareHeaderSettingsItems, className: 'chat-share-page__settings-menu', onClick: handleHeaderMenuClick }}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" className="chat-share-page__section-action chat-share-page__section-action--tool system-chat-page__icon-tool-button" aria-label="채팅 설정" title="채팅 설정" icon={<SettingOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-share-page__message-list">
|
||||||
|
{hiddenPreviousCount > 0 ? (
|
||||||
|
<div className="system-chat-page__omitted-divider" aria-label={'이전 요청 ' + String(hiddenPreviousCount) + '건 생략'}>
|
||||||
|
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
|
||||||
|
<span className="system-chat-page__omitted-divider-text">이전 요청 {hiddenPreviousCount}건 생략</span>
|
||||||
|
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{displayedRequests.map((request) => (
|
||||||
|
<SystemChatRequestCard
|
||||||
|
key={request.requestId}
|
||||||
|
request={request}
|
||||||
|
isReplyActive={replyReferenceRequestId === request.requestId}
|
||||||
|
promptDraftSelections={promptDraftSelections}
|
||||||
|
promptSubmittedSelections={promptSubmittedSelections}
|
||||||
|
onReplyToggle={handleReplyToggle}
|
||||||
|
onComplete={handleCompleteRequest}
|
||||||
|
onPromptSelectionChange={handlePromptSelectionChange}
|
||||||
|
onPromptSubmitted={handlePromptSubmitted}
|
||||||
|
onPromptSubmit={handlePromptSubmit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hiddenNextCount > 0 ? (
|
||||||
|
<div className="system-chat-page__omitted-divider" aria-label={'다음 요청 ' + String(hiddenNextCount) + '건 생략'}>
|
||||||
|
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
|
||||||
|
<span className="system-chat-page__omitted-divider-text">다음 요청 {hiddenNextCount}건 생략</span>
|
||||||
|
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="chat-share-page__panel chat-share-page__composer-panel">
|
||||||
|
<div className="chat-share-page__composer-shell app-chat-panel__composer">
|
||||||
|
<input ref={attachInputRef} className="chat-share-page__composer-file-input app-chat-panel__composer-file-input" type="file" multiple accept="image/*,.pdf,.txt,.md,.csv,.json,.zip,.heic,.heif" onChange={handleAttachSelection} style={{ display: 'none' }} />
|
||||||
|
<div className="chat-share-page__composer-topline">
|
||||||
|
<div className="app-chat-panel__composer-utility-buttons">
|
||||||
|
<Button className="system-chat-page__composer-icon-button system-chat-page__composer-attach-button" icon={<PlusOutlined />} aria-label="파일" title="파일" onClick={handleOpenAttachPicker} />
|
||||||
|
</div>
|
||||||
|
<div className="app-chat-panel__composer-type chat-share-page__composer-type-readonly">
|
||||||
|
<Select value="시스템 채팅" aria-label="현재 채팅유형" options={[{ value: '시스템 채팅', label: '시스템 채팅' }]} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="app-chat-panel__composer-actions chat-share-page__composer-topline-actions">
|
||||||
|
<div className="app-chat-panel__composer-action-buttons system-chat-page__composer-action-buttons">
|
||||||
|
<Button className="system-chat-page__composer-icon-button system-chat-page__composer-icon-button--instant" icon={<ThunderboltOutlined />} aria-label="즉시전송" title="즉시전송" onClick={handleSend} />
|
||||||
|
<Button type="primary" className="system-chat-page__composer-icon-button system-chat-page__composer-icon-button--send" icon={<SendOutlined />} aria-label="답변 전송" title="답변 전송" onClick={handleSend} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{replyReferenceRequestId ? (
|
||||||
|
<div className="chat-share-page__reply-reference system-chat-page__reply-reference">
|
||||||
|
<div className="chat-share-page__reply-reference-copy">
|
||||||
|
<span className="chat-share-page__reply-reference-label">답변 참조 중</span>
|
||||||
|
<span className="chat-share-page__reply-reference-text">{requests.find((request) => request.requestId === replyReferenceRequestId)?.question ?? '선택된 요청'}</span>
|
||||||
|
</div>
|
||||||
|
<Button type="text" size="small" className="chat-share-page__reply-reference-clear" onClick={() => setReplyReferenceRequestId(null)}>해제</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="chat-share-page__composer-entry-row">
|
||||||
|
<div className="app-chat-panel__composer-input-shell chat-share-page__composer-input-shell">
|
||||||
|
<Input.TextArea value={draftText} onChange={(event) => setDraftText(event.target.value)} placeholder="시스템 채팅에 보낼 내용을 입력하세요. 현재는 UI 형태만 제공됩니다." rows={6} maxLength={20000} autoSize={{ minRows: 6, maxRows: 10 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={isTokenUsageOpen} footer={null} title="토큰 관리" className="chat-share-page__token-usage-modal" onCancel={() => setIsTokenUsageOpen(false)}>
|
||||||
|
<div className="chat-share-page__token-usage-modal-body">
|
||||||
|
<div className="chat-share-page__token-usage-select-row">
|
||||||
|
<Text type="secondary">적용 토큰 설정</Text>
|
||||||
|
<Text strong>시스템 채팅 UI 전용 mock-admin</Text>
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-overview-card" aria-label="토큰 집계 요약">
|
||||||
|
<div className="chat-share-page__token-usage-overview-head">
|
||||||
|
<div>
|
||||||
|
<div className="chat-share-page__token-usage-overview-label">지금 사용 가능</div>
|
||||||
|
<div className="chat-share-page__token-usage-overview-value">100,000</div>
|
||||||
|
</div>
|
||||||
|
<Tag color="gold">5시간 기준 12%</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-meter-card">
|
||||||
|
<div className="chat-share-page__token-usage-meter-track chat-share-page__token-usage-meter-track--merged" aria-hidden="true">
|
||||||
|
<span className="chat-share-page__token-usage-meter-fill chat-share-page__token-usage-meter-fill--overall" style={{ width: '12%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-meter-legend">
|
||||||
|
<div className="chat-share-page__token-usage-meter-row">
|
||||||
|
<span className="chat-share-page__token-usage-meter-dot chat-share-page__token-usage-meter-fill--overall" aria-hidden="true" />
|
||||||
|
<span className="chat-share-page__token-usage-meter-label">전체 사용량</span>
|
||||||
|
<span className="chat-share-page__token-usage-meter-value">12,000 / 100,000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-summary-copy">현재 시스템 채팅은 UI mock 상태이므로 수치는 예시입니다.</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-select-row">
|
||||||
|
<Text type="secondary">현재 공유 토큰</Text>
|
||||||
|
<div className="chat-share-page__token-usage-share-url-row">
|
||||||
|
<Paragraph className="chat-share-page__token-usage-share-url" style={{ maxWidth: '100%', marginBottom: 0 }}>
|
||||||
|
https://preview.sm-home.cloud/chat/system
|
||||||
|
</Paragraph>
|
||||||
|
<Button type="text" size="small" className="chat-share-page__token-usage-copy-button" icon={<CopyOutlined />} onClick={() => message.success('예시 URL을 복사했습니다.')} />
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-token-meta">
|
||||||
|
<Text type="secondary">UI mock 상태 · 만료 없음</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isRoomSettingsOpen}
|
||||||
|
title="시스템 채팅 설정"
|
||||||
|
okText="저장"
|
||||||
|
cancelText="취소"
|
||||||
|
onCancel={() => setIsRoomSettingsOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
message.success('시스템 채팅 UI 설정을 저장한 것처럼 표시합니다. 현재는 mock 상태입니다.');
|
||||||
|
setIsRoomSettingsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="chat-share-page__token-usage-modal">
|
||||||
|
<Alert showIcon type="info" message="현재 화면은 서버 연결 없는 UI 전용 시스템 채팅입니다." />
|
||||||
|
<div className="chat-share-page__token-usage-panel">
|
||||||
|
<Text strong>채팅유형</Text>
|
||||||
|
<Select value={editingRoomChatTypeId} onChange={setEditingRoomChatTypeId} options={[{ value: 'system-chat', label: '시스템 채팅' }]} />
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-panel">
|
||||||
|
<Text strong>방 전용 문맥 제목</Text>
|
||||||
|
<Input value="시스템 채팅 기본 규칙" readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-panel">
|
||||||
|
<Text strong>방 전용 문맥 본문</Text>
|
||||||
|
<Input.TextArea rows={8} value="이 화면은 공유채팅과 동일한 구조의 UI mock이며, 향후 시스템 채팅 전용 기능을 여기에만 추가합니다." readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="chat-share-page__token-usage-panel">
|
||||||
|
<Text strong>공유 비밀번호</Text>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input type="checkbox" checked={editingRoomUseAccessPin} onChange={(event) => setEditingRoomUseAccessPin(event.target.checked)} />
|
||||||
|
<span>숫자 4자리 비밀번호 요구</span>
|
||||||
|
</label>
|
||||||
|
<Input.Password value={editingRoomAccessPin} maxLength={4} onChange={(event) => setEditingRoomAccessPin(event.target.value)} placeholder="숫자 4자리" />
|
||||||
|
<Text strong style={{ marginTop: 12 }}>비밀번호 유지시간</Text>
|
||||||
|
<Select value={editingRoomAccessPinPromptTtl} onChange={setEditingRoomAccessPinPromptTtl} options={[{ value: 'always', label: '매번 묻기' }, { value: '30', label: '30분 유지' }, { value: '60', label: '1시간 유지' }]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={isSearchOpen} footer={null} title={searchPanelMode === 'apps' ? '시스템 채팅 Apps' : '시스템 채팅 통합검색'} className="chat-share-page__search-modal" onCancel={() => setIsSearchOpen(false)}>
|
||||||
|
<div className="chat-share-page__search-modal-body">
|
||||||
|
<Input autoFocus allowClear size="large" prefix={<SearchOutlined />} placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'} value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} />
|
||||||
|
<div className="chat-share-page__search-summary">
|
||||||
|
<Text type="secondary">{searchKeyword.trim() ? `검색 결과 ${filteredSearchResults.length}건` : searchPanelMode === 'apps' ? '시스템 채팅에서 허용할 앱 예시를 보여줍니다.' : '질문, 답변, 리소스, 활동 로그 mock 데이터를 함께 찾습니다.'}</Text>
|
||||||
|
</div>
|
||||||
|
{searchPanelMode === 'apps' ? (
|
||||||
|
<div className="chat-share-page__search-app-environment">
|
||||||
|
<Text type="secondary">실행 환경</Text>
|
||||||
|
<Select value={selectedAppEnvironment} options={[{ value: 'preview', label: 'preview' }, { value: 'test', label: 'test' }, { value: 'prod', label: 'prod' }]} onChange={setSelectedAppEnvironment} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="chat-share-page__search-results">
|
||||||
|
{filteredSearchResults.length > 0 ? (
|
||||||
|
filteredSearchResults.map((result) => (
|
||||||
|
<div key={result.key} className="chat-share-page__search-result">
|
||||||
|
<button type="button" className="chat-share-page__search-result-main" onClick={() => message.info(`${result.title} 항목은 UI mock 데이터입니다.`)}>
|
||||||
|
<span className="chat-share-page__search-result-title">{result.title}</span>
|
||||||
|
<span className="chat-share-page__search-result-description">{result.description}</span>
|
||||||
|
</button>
|
||||||
|
{searchPanelMode === 'apps' ? (
|
||||||
|
<div className="chat-share-page__search-result-action-group">
|
||||||
|
<Tag bordered={false} className="chat-share-page__search-result-tag">지원 {selectedAppEnvironment}</Tag>
|
||||||
|
<Button type="text" size="small" className="chat-share-page__search-result-action chat-share-page__search-result-action--environment" icon={<AppstoreOutlined />} onClick={() => message.info('앱 실행은 연결되지 않았습니다.')}>앱 실행</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="chat-share-page__search-empty">
|
||||||
|
<Text type="secondary">검색 결과가 없습니다.</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
@import './mainChatPanel/styles/MainChatPanel.layout.css';
|
|
||||||
@import './mainChatPanel/styles/MainChatPanel.conversation.css';
|
|
||||||
@import './mainChatPanel/styles/MainChatPanel.preview-runtime.css';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -53,12 +53,12 @@ type SharedTokenSettingAccess = {
|
|||||||
|
|
||||||
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
|
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
|
||||||
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
||||||
{ value: 'chat-rooms', label: '시스템 채팅방', description: '메뉴별 시스템 채팅방 화면 접근', category: '관리' },
|
|
||||||
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
||||||
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
||||||
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
||||||
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
||||||
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
|
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
|
||||||
|
{ value: 'server-command', label: '서버관리', description: '서버 상태 확인과 재기동 예약/실행 접근', category: '관리' },
|
||||||
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
|
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
|
||||||
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
|
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
|
||||||
];
|
];
|
||||||
|
|||||||
13
src/app/main/chatSessionRouting.ts
Normal file
13
src/app/main/chatSessionRouting.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-';
|
||||||
|
|
||||||
|
export function isManagedChatShareSessionId(sessionId: string | null | undefined) {
|
||||||
|
return String(sessionId ?? '').trim().startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChatPathForSession(_sessionId: string) {
|
||||||
|
return '/chat/live';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowPrimaryConversation(sessionId: string) {
|
||||||
|
return !isManagedChatShareSessionId(sessionId);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button, Empty, Input, List, Spin, Typography } from 'antd';
|
import { Button, Empty, Input, List, Spin, Typography } from 'antd';
|
||||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||||
import { shouldShowConversationForMode } from '../../isolatedChatRooms';
|
import { shouldShowPrimaryConversation } from '../../chatSessionRouting';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function ConversationListPane({
|
|||||||
onSelectSession,
|
onSelectSession,
|
||||||
onCreateConversation,
|
onCreateConversation,
|
||||||
}: ConversationListPaneProps) {
|
}: ConversationListPaneProps) {
|
||||||
const visibleItems = items.filter((item) => shouldShowConversationForMode(item.sessionId, 'live'));
|
const visibleItems = items.filter((item) => shouldShowPrimaryConversation(item.sessionId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="chat-v2__pane chat-v2__pane--list">
|
<section className="chat-v2__pane chat-v2__pane--list">
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import type {
|
|||||||
ChatRuntimeSnapshot,
|
ChatRuntimeSnapshot,
|
||||||
} from '../../mainChatPanel/types';
|
} from '../../mainChatPanel/types';
|
||||||
|
|
||||||
|
const SCROLL_JUMP_HIDE_THRESHOLD = 24;
|
||||||
|
const SCROLL_JUMP_MIN_OVERFLOW = 48;
|
||||||
|
const SCROLL_JUMP_DIRECTION_THRESHOLD = 6;
|
||||||
|
const SCROLL_JUMP_IDLE_HIDE_DELAY_MS = 900;
|
||||||
|
|
||||||
type UseConversationViewportControllerOptions = {
|
type UseConversationViewportControllerOptions = {
|
||||||
activeConversation: ChatConversationSummary | null;
|
activeConversation: ChatConversationSummary | null;
|
||||||
activeQueuedComposerRequestsCount: number;
|
activeQueuedComposerRequestsCount: number;
|
||||||
@@ -50,6 +55,7 @@ export function useConversationViewportController({
|
|||||||
const systemStatusTimerRef = useRef<number | null>(null);
|
const systemStatusTimerRef = useRef<number | null>(null);
|
||||||
const restoreAutoScrollFrameRef = useRef<number | null>(null);
|
const restoreAutoScrollFrameRef = useRef<number | null>(null);
|
||||||
const showScrollToBottomRef = useRef(false);
|
const showScrollToBottomRef = useRef(false);
|
||||||
|
const scrollJumpIdleTimerRef = useRef<number | null>(null);
|
||||||
const shouldStickToBottomRef = useRef(true);
|
const shouldStickToBottomRef = useRef(true);
|
||||||
const lastViewportScrollTopRef = useRef(0);
|
const lastViewportScrollTopRef = useRef(0);
|
||||||
const autoScrollSuspendedUntilRef = useRef(0);
|
const autoScrollSuspendedUntilRef = useRef(0);
|
||||||
@@ -74,6 +80,13 @@ export function useConversationViewportController({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearScrollJumpIdleTimer = useCallback(() => {
|
||||||
|
if (scrollJumpIdleTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(scrollJumpIdleTimerRef.current);
|
||||||
|
scrollJumpIdleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const syncShowScrollToBottom = useCallback((nextValue: boolean) => {
|
const syncShowScrollToBottom = useCallback((nextValue: boolean) => {
|
||||||
if (showScrollToBottomRef.current === nextValue) {
|
if (showScrollToBottomRef.current === nextValue) {
|
||||||
return;
|
return;
|
||||||
@@ -144,8 +157,11 @@ export function useConversationViewportController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||||
const isNearBottom = remainingDistance <= 24;
|
const maxScrollDistance = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||||
const isScrollingUp = viewport.scrollTop < lastViewportScrollTopRef.current - 2;
|
const currentScrollTop = viewport.scrollTop;
|
||||||
|
const scrollDelta = currentScrollTop - lastViewportScrollTopRef.current;
|
||||||
|
const isNearBottom = remainingDistance <= SCROLL_JUMP_HIDE_THRESHOLD;
|
||||||
|
const isScrollingUp = scrollDelta < -2;
|
||||||
|
|
||||||
if (isNearBottom) {
|
if (isNearBottom) {
|
||||||
releaseAutoScrollSuspension();
|
releaseAutoScrollSuspension();
|
||||||
@@ -155,9 +171,30 @@ export function useConversationViewportController({
|
|||||||
|
|
||||||
const shouldStickToBottom = isNearBottom && !isAutoScrollSuspended();
|
const shouldStickToBottom = isNearBottom && !isAutoScrollSuspended();
|
||||||
shouldStickToBottomRef.current = shouldStickToBottom;
|
shouldStickToBottomRef.current = shouldStickToBottom;
|
||||||
lastViewportScrollTopRef.current = viewport.scrollTop;
|
lastViewportScrollTopRef.current = currentScrollTop;
|
||||||
syncShowScrollToBottom(!shouldStickToBottom);
|
|
||||||
}, [isAutoScrollSuspended, releaseAutoScrollSuspension, syncShowScrollToBottom, viewportRef]);
|
if (maxScrollDistance < SCROLL_JUMP_MIN_OVERFLOW || shouldStickToBottom) {
|
||||||
|
clearScrollJumpIdleTimer();
|
||||||
|
syncShowScrollToBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(scrollDelta) >= SCROLL_JUMP_DIRECTION_THRESHOLD) {
|
||||||
|
clearScrollJumpIdleTimer();
|
||||||
|
scrollJumpIdleTimerRef.current = window.setTimeout(() => {
|
||||||
|
scrollJumpIdleTimerRef.current = null;
|
||||||
|
syncShowScrollToBottom(false);
|
||||||
|
}, SCROLL_JUMP_IDLE_HIDE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncShowScrollToBottom(true);
|
||||||
|
}, [
|
||||||
|
clearScrollJumpIdleTimer,
|
||||||
|
isAutoScrollSuspended,
|
||||||
|
releaseAutoScrollSuspension,
|
||||||
|
syncShowScrollToBottom,
|
||||||
|
viewportRef,
|
||||||
|
]);
|
||||||
|
|
||||||
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
|
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
|
||||||
if (options?.forceStickToBottom) {
|
if (options?.forceStickToBottom) {
|
||||||
@@ -491,12 +528,13 @@ export function useConversationViewportController({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearSystemStatusTimer();
|
clearSystemStatusTimer();
|
||||||
|
clearScrollJumpIdleTimer();
|
||||||
|
|
||||||
if (restoreAutoScrollFrameRef.current !== null) {
|
if (restoreAutoScrollFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [clearSystemStatusTimer]);
|
}, [clearScrollJumpIdleTimer, clearSystemStatusTimer]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSystemStatus,
|
activeSystemStatus,
|
||||||
|
|||||||
3
src/app/main/chatWindowActions.ts
Normal file
3
src/app/main/chatWindowActions.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function requestChatWindowAction(_action: 'minimize' | 'close') {
|
||||||
|
// System chat was removed; shared/live panels no longer use a separate floating window.
|
||||||
|
}
|
||||||
@@ -1,23 +1,12 @@
|
|||||||
import { Layout } from 'antd';
|
import { Layout } from 'antd';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
|
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
|
||||||
import { useAppStore } from '../../../store';
|
import { useAppStore } from '../../../store';
|
||||||
import { useTokenAccess } from '../tokenAccess';
|
|
||||||
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
|
|
||||||
import { getChatActionContextSnapshot } from '../chatActionContextStore';
|
|
||||||
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
|
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
|
||||||
import { SystemChatPanel } from '../SystemChatPanel';
|
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
|
||||||
import { ScopedChatRoomsWindow, ScopedChatRoomsWindowDock } from '../ScopedChatRoomsWindow';
|
import { useTokenAccess } from '../tokenAccess';
|
||||||
import {
|
|
||||||
removeMinimizedIsolatedChatRoomEntryByScope,
|
|
||||||
useActiveIsolatedChatRoomScope,
|
|
||||||
useIsolatedChatRoomsWindowOpen,
|
|
||||||
writeActiveIsolatedChatRoomScope,
|
|
||||||
writeIsolatedChatRoomsWindowOpen,
|
|
||||||
} from '../isolatedChatRoomScopeStore';
|
|
||||||
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
|
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
|
||||||
import { normalizeIsolatedChatRoomScope } from '../isolatedChatRooms';
|
|
||||||
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
|
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
|
||||||
import { MainContent } from '../MainContent';
|
import { MainContent } from '../MainContent';
|
||||||
import { MainHeader } from '../MainHeader';
|
import { MainHeader } from '../MainHeader';
|
||||||
@@ -120,7 +109,7 @@ function parseRoute(pathname: string): {
|
|||||||
if (
|
if (
|
||||||
top === 'chat' &&
|
top === 'chat' &&
|
||||||
(first === 'live' ||
|
(first === 'live' ||
|
||||||
first === 'rooms' ||
|
first === 'system' ||
|
||||||
first === 'changes' ||
|
first === 'changes' ||
|
||||||
first === 'resources' ||
|
first === 'resources' ||
|
||||||
first === 'errors' ||
|
first === 'errors' ||
|
||||||
@@ -254,8 +243,6 @@ export function MainLayout() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore();
|
const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore();
|
||||||
const { hasAccess } = useTokenAccess();
|
const { hasAccess } = useTokenAccess();
|
||||||
const activeScopedChatRoomScope = useActiveIsolatedChatRoomScope();
|
|
||||||
const isScopedChatRoomsWindowOpen = useIsolatedChatRoomsWindowOpen();
|
|
||||||
const appConfig = useAppConfig();
|
const appConfig = useAppConfig();
|
||||||
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
|
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
|
||||||
const layoutData = useMainLayoutData();
|
const layoutData = useMainLayoutData();
|
||||||
@@ -286,30 +273,6 @@ export function MainLayout() {
|
|||||||
navigate(nextPath, options?.replace == null ? undefined : { replace: options.replace });
|
navigate(nextPath, options?.replace == null ? undefined : { replace: options.replace });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openScopedChatRooms = useCallback(() => {
|
|
||||||
const actionSnapshot = getChatActionContextSnapshot();
|
|
||||||
const scope = normalizeIsolatedChatRoomScope({
|
|
||||||
topMenu: currentPage.topMenu,
|
|
||||||
menuTitle: currentPage.title,
|
|
||||||
featureTitle: actionSnapshot.featureTitle ?? focusedComponentId ?? currentPage.title,
|
|
||||||
focusedComponentId,
|
|
||||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
||||||
selectionSummary: actionSnapshot.selectionSummary,
|
|
||||||
selectionIds: actionSnapshot.selectionIds,
|
|
||||||
sourceAppId: actionSnapshot.sourceAppId,
|
|
||||||
launchedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
writeActiveIsolatedChatRoomScope(scope);
|
|
||||||
removeMinimizedIsolatedChatRoomEntryByScope(scope);
|
|
||||||
|
|
||||||
if (routeState.chatMenu === 'rooms') {
|
|
||||||
writeIsolatedChatRoomsWindowOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeIsolatedChatRoomsWindowOpen(true);
|
|
||||||
}, [currentPage.title, currentPage.topMenu, focusedComponentId, routeState.chatMenu]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void syncAppConfigFromServer();
|
void syncAppConfigFromServer();
|
||||||
@@ -421,20 +384,9 @@ export function MainLayout() {
|
|||||||
openSearch();
|
openSearch();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'mobile-middle-right-search-window',
|
|
||||||
activeStates: ['anyway'],
|
|
||||||
mobileOnly: true,
|
|
||||||
trigger: 'pull-left-middle-right' as const,
|
|
||||||
hotZoneSize: 36,
|
|
||||||
minDistance: 180,
|
|
||||||
minViewportDistanceRatio: 0.35,
|
|
||||||
maxHorizontalDrift: 72,
|
|
||||||
onTrigger: openScopedChatRooms,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[isEReaderImmersiveActive, isMobileViewport, openScopedChatRooms, openSearch, routeState.docsMenu, routeState.topMenu],
|
[isEReaderImmersiveActive, isMobileViewport, openSearch, routeState.docsMenu, routeState.topMenu],
|
||||||
);
|
);
|
||||||
|
|
||||||
useGesturePageState('anyway');
|
useGesturePageState('anyway');
|
||||||
@@ -446,11 +398,6 @@ export function MainLayout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
|
|
||||||
event.preventDefault();
|
|
||||||
openScopedChatRooms();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
|
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -465,9 +412,7 @@ export function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
appConfig.gestureShortcuts.openSearch,
|
appConfig.gestureShortcuts.openSearch,
|
||||||
appConfig.gestureShortcuts.openWindowSearch,
|
|
||||||
isEReaderImmersiveActive,
|
isEReaderImmersiveActive,
|
||||||
openScopedChatRooms,
|
|
||||||
openSearch,
|
openSearch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -664,16 +609,6 @@ export function MainLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</Layout>
|
</Layout>
|
||||||
{routeState.chatMenu !== 'rooms' && isScopedChatRoomsWindowOpen ? (
|
|
||||||
<ScopedChatRoomsWindow
|
|
||||||
onClose={() => {
|
|
||||||
writeIsolatedChatRoomsWindowOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SystemChatPanel lockOuterScrollOnMobile />
|
|
||||||
</ScopedChatRoomsWindow>
|
|
||||||
) : null}
|
|
||||||
{routeState.chatMenu !== 'rooms' ? <ScopedChatRoomsWindowDock /> : null}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</MainLayoutContextProvider>
|
</MainLayoutContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -237,18 +237,6 @@ export function buildSearchOptions({
|
|||||||
},
|
},
|
||||||
onSelectWindow,
|
onSelectWindow,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'page:chat:rooms',
|
|
||||||
label: '시스템 채팅 / 시스템 채팅',
|
|
||||||
group: 'Page',
|
|
||||||
keywords: ['system chat', 'shared chat', 'room chat', '시스템 채팅', '공유채팅', '채팅방'],
|
|
||||||
onSelect: () => {
|
|
||||||
requestPlanQuickFilter(null);
|
|
||||||
navigateTo(buildChatPath('rooms'));
|
|
||||||
setFocusedComponentId(null);
|
|
||||||
},
|
|
||||||
onSelectWindow,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'page:chat:live',
|
id: 'page:chat:live',
|
||||||
label: 'Codex Live / Codex Live',
|
label: 'Codex Live / Codex Live',
|
||||||
@@ -261,6 +249,18 @@ export function buildSearchOptions({
|
|||||||
},
|
},
|
||||||
onSelectWindow,
|
onSelectWindow,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'page:chat:system',
|
||||||
|
label: '채팅 / 시스템 채팅',
|
||||||
|
group: 'Page',
|
||||||
|
keywords: ['system chat', 'system', 'chat', '시스템 채팅', '시스템', '채팅 ui'],
|
||||||
|
onSelect: () => {
|
||||||
|
requestPlanQuickFilter(null);
|
||||||
|
navigateTo(buildChatPath('system'));
|
||||||
|
setFocusedComponentId(null);
|
||||||
|
},
|
||||||
|
onSelectWindow,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'page:chat:changes',
|
id: 'page:chat:changes',
|
||||||
label: 'Codex Live / 변경 이력',
|
label: 'Codex Live / 변경 이력',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
PaperClipOutlined,
|
PaperClipOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ProfileOutlined,
|
ProfileOutlined,
|
||||||
|
RedoOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -482,7 +484,6 @@ 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;
|
||||||
const IMMEDIATE_SEND_TOGGLE_HOLD_MS = 2000;
|
const IMMEDIATE_SEND_TOGGLE_HOLD_MS = 2000;
|
||||||
const SYSTEM_EXECUTION_JUMP_MAX_RETRIES = 4;
|
const SYSTEM_EXECUTION_JUMP_MAX_RETRIES = 4;
|
||||||
const SYSTEM_EXECUTION_JUMP_TOP_OFFSET = 12;
|
|
||||||
const MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH = 767;
|
const MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH = 767;
|
||||||
const DEFAULT_QUEUE_SUMMARY_MAX_LENGTH = 32;
|
const DEFAULT_QUEUE_SUMMARY_MAX_LENGTH = 32;
|
||||||
const TABLET_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 88;
|
const TABLET_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 88;
|
||||||
@@ -690,6 +691,47 @@ function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLEle
|
|||||||
return targetRect.top - containerRect.top + container.scrollTop;
|
return targetRect.top - containerRect.top + container.scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getContainerScrollPaddingTop(container: HTMLElement) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingTop = Number.parseFloat(window.getComputedStyle(container).paddingTop || '0');
|
||||||
|
return Number.isFinite(paddingTop) ? Math.max(0, paddingTop) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScrollableAnchorContainer(target: HTMLElement, preferredContainer?: HTMLElement | null) {
|
||||||
|
const candidates: Array<HTMLElement | null | undefined> = [preferredContainer, target.parentElement];
|
||||||
|
|
||||||
|
let currentAncestor = target.parentElement;
|
||||||
|
while (currentAncestor) {
|
||||||
|
candidates.push(currentAncestor);
|
||||||
|
currentAncestor = currentAncestor.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!(candidate instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(candidate);
|
||||||
|
const overflowY = style.overflowY;
|
||||||
|
const isScrollableOverflow = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
|
||||||
|
|
||||||
|
if (!isScrollableOverflow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.scrollHeight <= candidate.clientHeight + 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function isPhoneLikeViewport() {
|
function isPhoneLikeViewport() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
@@ -703,6 +745,25 @@ function isPhoneLikeViewport() {
|
|||||||
return isNarrowViewport && (hasCoarsePointer || hasTouchPoints);
|
return isNarrowViewport && (hasCoarsePointer || hasTouchPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSystemExecutionJumpTargetPriority(
|
||||||
|
target: ReturnType<typeof resolveSystemExecutionJumpTarget>,
|
||||||
|
) {
|
||||||
|
if (!target) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (target.kind) {
|
||||||
|
case 'prompt':
|
||||||
|
return 3;
|
||||||
|
case 'response':
|
||||||
|
return 2;
|
||||||
|
case 'request':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
|
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
|
||||||
const fileName = buildPreviewFileName(item).toLowerCase();
|
const fileName = buildPreviewFileName(item).toLowerCase();
|
||||||
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
|
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
|
||||||
@@ -1502,6 +1563,18 @@ function isRequestRunningStatus(status: ChatConversationRequestStatus | undefine
|
|||||||
return status === 'started';
|
return status === 'started';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDisconnectedRequestNeedingAttention(request: ChatConversationRequest | undefined) {
|
||||||
|
if (!request || request.hasResponse) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== 'failed') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (request.statusMessage?.trim() ?? '') === '중단된 오래된 요청';
|
||||||
|
}
|
||||||
|
|
||||||
function isRequestUserFinalized(
|
function isRequestUserFinalized(
|
||||||
request: ChatConversationRequest,
|
request: ChatConversationRequest,
|
||||||
attentionState?: SystemExecutionAttentionState,
|
attentionState?: SystemExecutionAttentionState,
|
||||||
@@ -1589,6 +1662,21 @@ function resolveAggregatedRequestStatusSummary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveActiveSystemExecutionActionTargetRequest(
|
||||||
|
requests: ChatConversationRequest[],
|
||||||
|
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||||||
|
) {
|
||||||
|
const activeRequests = requests.filter(
|
||||||
|
(request) => isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeRequests.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveRepresentativeSystemExecutionRequest(activeRequests, attentionStateByRequestId) ?? activeRequests[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
function getRepresentativeAttentionPriority(
|
function getRepresentativeAttentionPriority(
|
||||||
request: ChatConversationRequest,
|
request: ChatConversationRequest,
|
||||||
attentionState: SystemExecutionAttentionState | undefined,
|
attentionState: SystemExecutionAttentionState | undefined,
|
||||||
@@ -1659,13 +1747,22 @@ function resolveRepresentativeSystemExecutionRequest(
|
|||||||
function formatRequestStatusLabel(
|
function formatRequestStatusLabel(
|
||||||
request: ChatConversationRequest | undefined,
|
request: ChatConversationRequest | undefined,
|
||||||
attentionState?: SystemExecutionAttentionState,
|
attentionState?: SystemExecutionAttentionState,
|
||||||
|
options?: {
|
||||||
|
hideFinalizedLabel?: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
|
const hideFinalizedLabel = options?.hideFinalizedLabel === true;
|
||||||
|
|
||||||
if (hasAnsweredRequest(request)) {
|
if (hasAnsweredRequest(request)) {
|
||||||
if (request?.status === "completed") {
|
if (request?.status === "completed") {
|
||||||
|
if (hideFinalizedLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료";
|
return attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "답변도착";
|
return hideFinalizedLabel ? null : "답변도착";
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (request?.status) {
|
switch (request?.status) {
|
||||||
@@ -1845,10 +1942,11 @@ function hasVisibleActivityOverviewContent(
|
|||||||
request.status === 'accepted' ||
|
request.status === 'accepted' ||
|
||||||
request.status === 'queued' ||
|
request.status === 'queued' ||
|
||||||
request.status === 'started';
|
request.status === 'started';
|
||||||
|
const hasPendingAttention = attentionState?.hasOwnAttentionState === true;
|
||||||
const hasChecklistEntries = buildChatActivityChecklistEntries(activityOverview.lines, request).length > 0;
|
const hasChecklistEntries = buildChatActivityChecklistEntries(activityOverview.lines, request).length > 0;
|
||||||
const hasExecutorEntries = activityOverview.executors.length > 0;
|
const hasExecutorEntries = activityOverview.executors.length > 0;
|
||||||
|
|
||||||
if (isUserFinalized || !isInProgress) {
|
if (isUserFinalized || (!isInProgress && !hasPendingAttention)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3206,15 +3304,40 @@ const ChatComposerInput = memo(function ChatComposerInput({
|
|||||||
|
|
||||||
function SharedRoomsRequestCard({
|
function SharedRoomsRequestCard({
|
||||||
request,
|
request,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
request: ChatConversationRequest;
|
request: ChatConversationRequest;
|
||||||
|
onSelect?: (() => void) | null;
|
||||||
}) {
|
}) {
|
||||||
const questionText = (request.userText ?? "").trim() || "-";
|
const questionText = (request.userText ?? "").trim() || "-";
|
||||||
const answerText = (request.responseText ?? "").trim() || request.statusMessage?.trim() || "아직 답변이 없습니다.";
|
const answerText = (request.responseText ?? "").trim() || request.statusMessage?.trim() || "아직 답변이 없습니다.";
|
||||||
const requestStatusLabel = formatRequestStatusLabel(request);
|
const requestStatusLabel = formatRequestStatusLabel(request);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="app-chat-message-group">
|
<section
|
||||||
|
className={`app-chat-message-group${onSelect ? ' app-chat-message-group--interactive' : ''}`}
|
||||||
|
role={onSelect ? 'button' : undefined}
|
||||||
|
tabIndex={onSelect ? 0 : undefined}
|
||||||
|
onClick={
|
||||||
|
onSelect
|
||||||
|
? () => {
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onKeyDown={
|
||||||
|
onSelect
|
||||||
|
? (event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<header className="app-chat-message-group__header">
|
<header className="app-chat-message-group__header">
|
||||||
<div className="app-chat-message-group__header-meta">
|
<div className="app-chat-message-group__header-meta">
|
||||||
{requestStatusLabel ? (
|
{requestStatusLabel ? (
|
||||||
@@ -3367,6 +3490,7 @@ export function ChatConversationView({
|
|||||||
const childComposerRefs = useRef(new Map<string, TextAreaRef | null>());
|
const childComposerRefs = useRef(new Map<string, TextAreaRef | null>());
|
||||||
const messageAnchorRefs = useRef(new Map<number, HTMLDivElement>());
|
const messageAnchorRefs = useRef(new Map<number, HTMLDivElement>());
|
||||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||||
|
const promptCardAnchorRefs = useRef(new Map<number, HTMLElement>());
|
||||||
const systemExecutionBodyRef = useRef<HTMLDivElement | null>(null);
|
const systemExecutionBodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
const systemExecutionJumpFrameRef = useRef<number | null>(null);
|
const systemExecutionJumpFrameRef = useRef<number | null>(null);
|
||||||
const systemExecutionOlderLoadRequestedRef = useRef(false);
|
const systemExecutionOlderLoadRequestedRef = useRef(false);
|
||||||
@@ -3374,6 +3498,14 @@ export function ChatConversationView({
|
|||||||
const previousSystemExecutionDisplayModeRef = useRef<SystemExecutionDisplayMode>('collapsed');
|
const previousSystemExecutionDisplayModeRef = useRef<SystemExecutionDisplayMode>('collapsed');
|
||||||
const previousSessionIdRef = useRef(sessionId);
|
const previousSessionIdRef = useRef(sessionId);
|
||||||
const shouldFollowLatestRoomShareGroupRef = useRef(false);
|
const shouldFollowLatestRoomShareGroupRef = useRef(false);
|
||||||
|
const pendingRoomShareJumpRef = useRef<
|
||||||
|
| {
|
||||||
|
groupId: string;
|
||||||
|
requestId?: string;
|
||||||
|
fallbackMessageId?: number;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
const immediateSendHoldTimerRef = useRef<number | null>(null);
|
const immediateSendHoldTimerRef = useRef<number | null>(null);
|
||||||
const suppressImmediateSendClickRef = useRef(false);
|
const suppressImmediateSendClickRef = useRef(false);
|
||||||
const composerDraftValueRef = useRef(draft);
|
const composerDraftValueRef = useRef(draft);
|
||||||
@@ -3773,6 +3905,27 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
return nextMap;
|
return nextMap;
|
||||||
}, [messageRenderPayloadById, orderedMessages]);
|
}, [messageRenderPayloadById, orderedMessages]);
|
||||||
|
const firstPromptMessageIdByRequestId = useMemo(() => {
|
||||||
|
const nextMap = new Map<string, number>();
|
||||||
|
|
||||||
|
orderedMessages.forEach((message) => {
|
||||||
|
const requestId = message.clientRequestId?.trim();
|
||||||
|
|
||||||
|
if (!requestId || nextMap.has(requestId) || (message.author !== 'codex' && message.author !== 'system')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { promptTargets } = messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||||||
|
|
||||||
|
if (promptTargets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextMap.set(requestId, message.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextMap;
|
||||||
|
}, [messageRenderPayloadById, orderedMessages]);
|
||||||
const promptFollowupCountByParentRequestId = useMemo(() => {
|
const promptFollowupCountByParentRequestId = useMemo(() => {
|
||||||
const nextMap = new Map<string, number>();
|
const nextMap = new Map<string, number>();
|
||||||
|
|
||||||
@@ -4041,7 +4194,8 @@ export function ChatConversationView({
|
|||||||
hasVerificationTarget,
|
hasVerificationTarget,
|
||||||
hasConfirmedVerificationTarget,
|
hasConfirmedVerificationTarget,
|
||||||
});
|
});
|
||||||
const hasOwnAttentionState = hasPendingPromptBadge || hasPendingVerificationBadge;
|
const hasOwnAttentionState =
|
||||||
|
hasPendingPromptBadge || hasPendingVerificationBadge || isDisconnectedRequestNeedingAttention(request);
|
||||||
|
|
||||||
nextMap.set(request.requestId, {
|
nextMap.set(request.requestId, {
|
||||||
activityLines,
|
activityLines,
|
||||||
@@ -4251,6 +4405,21 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
return roomShareRequestGroups;
|
return roomShareRequestGroups;
|
||||||
}, [roomShareExpandMode, roomShareRequestGroups]);
|
}, [roomShareExpandMode, roomShareRequestGroups]);
|
||||||
|
const roomShareGroupIdByRequestId = useMemo(() => {
|
||||||
|
const nextMap = new Map<string, string>();
|
||||||
|
|
||||||
|
roomShareRequestGroups.forEach((entry) => {
|
||||||
|
entry.groupedRequests.forEach((groupedRequest) => {
|
||||||
|
const normalizedRequestId = groupedRequest.requestId.trim();
|
||||||
|
|
||||||
|
if (normalizedRequestId) {
|
||||||
|
nextMap.set(normalizedRequestId, entry.groupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextMap;
|
||||||
|
}, [roomShareRequestGroups]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showRoomsShareHeader) {
|
if (!showRoomsShareHeader) {
|
||||||
return;
|
return;
|
||||||
@@ -4269,6 +4438,9 @@ export function ChatConversationView({
|
|||||||
shouldFollowLatestRoomShareGroupRef.current = false;
|
shouldFollowLatestRoomShareGroupRef.current = false;
|
||||||
|
|
||||||
if (nextLatestGroupId && nextLatestGroupId !== selectedRoomShareGroupId) {
|
if (nextLatestGroupId && nextLatestGroupId !== selectedRoomShareGroupId) {
|
||||||
|
pendingRoomShareJumpRef.current = {
|
||||||
|
groupId: nextLatestGroupId,
|
||||||
|
};
|
||||||
setSelectedRoomShareGroupId(nextLatestGroupId);
|
setSelectedRoomShareGroupId(nextLatestGroupId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -4514,6 +4686,18 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
messageBodyRefs.current.delete(messageId);
|
messageBodyRefs.current.delete(messageId);
|
||||||
};
|
};
|
||||||
|
const setPromptCardAnchorRef = (messageId: number, element: HTMLElement | null) => {
|
||||||
|
if (!Number.isFinite(messageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
promptCardAnchorRefs.current.set(messageId, element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
promptCardAnchorRefs.current.delete(messageId);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -4729,16 +4913,15 @@ export function ChatConversationView({
|
|||||||
visibleSystemExecutionRequests.length,
|
visibleSystemExecutionRequests.length,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const scrollToMessageAnchor = (messageId: number, behavior: ScrollBehavior) => {
|
const scrollToAnchorElement = (anchorElement: HTMLElement | null | undefined, behavior: ScrollBehavior) => {
|
||||||
const anchorElement = messageAnchorRefs.current.get(messageId);
|
|
||||||
|
|
||||||
if (!anchorElement) {
|
if (!anchorElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewportElement = viewportRef.current;
|
const viewportElement = viewportRef.current;
|
||||||
|
const scrollContainer = resolveScrollableAnchorContainer(anchorElement, viewportElement);
|
||||||
|
|
||||||
if (!viewportElement) {
|
if (!scrollContainer) {
|
||||||
anchorElement.scrollIntoView({
|
anchorElement.scrollIntoView({
|
||||||
behavior,
|
behavior,
|
||||||
block: 'start',
|
block: 'start',
|
||||||
@@ -4748,16 +4931,22 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
const nextTop = Math.max(
|
const nextTop = Math.max(
|
||||||
0,
|
0,
|
||||||
getElementOffsetWithinContainer(anchorElement, viewportElement) - SYSTEM_EXECUTION_JUMP_TOP_OFFSET,
|
getElementOffsetWithinContainer(anchorElement, scrollContainer) - getContainerScrollPaddingTop(scrollContainer),
|
||||||
);
|
);
|
||||||
|
|
||||||
viewportElement.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: nextTop,
|
top: nextTop,
|
||||||
behavior,
|
behavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
const scrollToMessageAnchor = (messageId: number, behavior: ScrollBehavior) =>
|
||||||
|
scrollToAnchorElement(messageAnchorRefs.current.get(messageId), behavior);
|
||||||
|
const scrollToPromptCardAnchor = (messageId: number | null | undefined, behavior: ScrollBehavior) =>
|
||||||
|
typeof messageId === 'number' && Number.isFinite(messageId)
|
||||||
|
? scrollToAnchorElement(promptCardAnchorRefs.current.get(messageId), behavior)
|
||||||
|
: false;
|
||||||
|
|
||||||
const triggerOlderSystemExecutionLoad = () => {
|
const triggerOlderSystemExecutionLoad = () => {
|
||||||
if (
|
if (
|
||||||
@@ -4799,31 +4988,94 @@ export function ChatConversationView({
|
|||||||
triggerOlderSystemExecutionLoad();
|
triggerOlderSystemExecutionLoad();
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleSystemExecutionJump = (anchorMessageId: number, attempt = 0) => {
|
const scheduleSystemExecutionJump = (scroll: (behavior: ScrollBehavior) => boolean, attempt = 0) => {
|
||||||
if (systemExecutionJumpFrameRef.current !== null) {
|
if (systemExecutionJumpFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(systemExecutionJumpFrameRef.current);
|
window.cancelAnimationFrame(systemExecutionJumpFrameRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
systemExecutionJumpFrameRef.current = window.requestAnimationFrame(() => {
|
systemExecutionJumpFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
systemExecutionJumpFrameRef.current = null;
|
systemExecutionJumpFrameRef.current = null;
|
||||||
const didScroll = scrollToMessageAnchor(anchorMessageId, attempt === 0 ? 'smooth' : 'auto');
|
const didScroll = scroll(attempt === 0 ? 'smooth' : 'auto');
|
||||||
|
|
||||||
if (!didScroll && attempt < SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
|
if (attempt < SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
|
||||||
scheduleSystemExecutionJump(anchorMessageId, attempt + 1);
|
scheduleSystemExecutionJump(scroll, attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!didScroll && attempt >= SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToSystemExecutionRequest = (requestId: string, fallbackMessageId?: number) => {
|
const resolveSystemExecutionJumpTarget = (request: ChatConversationRequest | undefined) => {
|
||||||
|
if (!request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRequestId = request.requestId.trim();
|
||||||
|
|
||||||
|
if (!normalizedRequestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPromptMessageId = firstPromptMessageIdByRequestId.get(normalizedRequestId) ?? null;
|
||||||
|
const hasPromptTarget = firstPromptMessageId != null || (promptTargetsByRequestId.get(normalizedRequestId)?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasPromptTarget) {
|
||||||
|
const anchorMessageId =
|
||||||
|
[firstPromptMessageId, request.responseMessageId].find(
|
||||||
|
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'prompt' as const,
|
||||||
|
requestId: normalizedRequestId,
|
||||||
|
promptMessageId: firstPromptMessageId,
|
||||||
|
anchorMessageId,
|
||||||
|
buttonLabel: 'prompt 위치로 이동',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof request.responseMessageId === 'number' && Number.isFinite(request.responseMessageId)) {
|
||||||
|
return {
|
||||||
|
kind: 'response' as const,
|
||||||
|
requestId: normalizedRequestId,
|
||||||
|
anchorMessageId: request.responseMessageId,
|
||||||
|
buttonLabel: '답변 위치로 이동',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof request.userMessageId === 'number' && Number.isFinite(request.userMessageId)) {
|
||||||
|
return {
|
||||||
|
kind: 'request' as const,
|
||||||
|
requestId: normalizedRequestId,
|
||||||
|
anchorMessageId: request.userMessageId,
|
||||||
|
buttonLabel: '요청 위치로 이동',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToSystemExecutionRequest = (
|
||||||
|
requestId: string,
|
||||||
|
options?: {
|
||||||
|
fallbackMessageId?: number;
|
||||||
|
jumpTarget?: ReturnType<typeof resolveSystemExecutionJumpTarget>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const request = requestStateMap.get(requestId);
|
const request = requestStateMap.get(requestId);
|
||||||
const normalizedRequestId = requestId.trim();
|
const normalizedRequestId = requestId.trim();
|
||||||
const rootRequestId =
|
const rootRequestId =
|
||||||
(normalizedRequestId ? resolveConversationRootRequestId(normalizedRequestId, requestStateMap) : '') ||
|
(normalizedRequestId ? resolveConversationRootRequestId(normalizedRequestId, requestStateMap) : '') ||
|
||||||
normalizedRequestId;
|
normalizedRequestId;
|
||||||
|
const resolvedJumpTarget = options?.jumpTarget ?? resolveSystemExecutionJumpTarget(request);
|
||||||
const anchorMessageId =
|
const anchorMessageId =
|
||||||
[request?.responseMessageId, request?.userMessageId, fallbackMessageId].find(
|
resolvedJumpTarget?.anchorMessageId ??
|
||||||
|
[request?.responseMessageId, request?.userMessageId, options?.fallbackMessageId].find(
|
||||||
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
|
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
|
||||||
) ?? null;
|
) ??
|
||||||
|
null;
|
||||||
|
|
||||||
if (!anchorMessageId) {
|
if (!anchorMessageId) {
|
||||||
return false;
|
return false;
|
||||||
@@ -4833,7 +5085,16 @@ export function ChatConversationView({
|
|||||||
setExpandedGroupIds((current) => (current.includes(rootRequestId) ? current : [...current, rootRequestId]));
|
setExpandedGroupIds((current) => (current.includes(rootRequestId) ? current : [...current, rootRequestId]));
|
||||||
}
|
}
|
||||||
setExpandedMessageIds((current) => (current.includes(anchorMessageId) ? current : [...current, anchorMessageId]));
|
setExpandedMessageIds((current) => (current.includes(anchorMessageId) ? current : [...current, anchorMessageId]));
|
||||||
scheduleSystemExecutionJump(anchorMessageId);
|
scheduleSystemExecutionJump((behavior) => {
|
||||||
|
if (resolvedJumpTarget?.kind === 'prompt') {
|
||||||
|
return (
|
||||||
|
scrollToPromptCardAnchor(resolvedJumpTarget.promptMessageId, behavior) ||
|
||||||
|
scrollToMessageAnchor(anchorMessageId, behavior)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrollToMessageAnchor(anchorMessageId, behavior);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4847,7 +5108,10 @@ export function ChatConversationView({
|
|||||||
|
|
||||||
scrollToSystemExecutionRequest(
|
scrollToSystemExecutionRequest(
|
||||||
representativeRequest.requestId,
|
representativeRequest.requestId,
|
||||||
representativeRequest.responseMessageId ?? representativeRequest.userMessageId ?? undefined,
|
{
|
||||||
|
fallbackMessageId: representativeRequest.responseMessageId ?? representativeRequest.userMessageId ?? undefined,
|
||||||
|
jumpTarget: resolveSystemExecutionJumpTarget(representativeRequest),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4867,15 +5131,72 @@ export function ChatConversationView({
|
|||||||
scrollToRoomShareGroup(nextGroup.groupId);
|
scrollToRoomShareGroup(nextGroup.groupId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSystemExecutionJump = (request: ChatConversationRequest) => {
|
useEffect(() => {
|
||||||
const didScroll = scrollToSystemExecutionRequest(
|
const pendingJump = pendingRoomShareJumpRef.current;
|
||||||
request.requestId,
|
|
||||||
request.responseMessageId ?? request.userMessageId ?? undefined,
|
if (!pendingJump || pendingJump.groupId !== selectedRoomShareGroupId) {
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRoomShareJumpRef.current = null;
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
if (pendingJump.requestId) {
|
||||||
|
const pendingRequest = requestStateMap.get(pendingJump.requestId);
|
||||||
|
const pendingJumpTarget = resolveSystemExecutionJumpTarget(pendingRequest);
|
||||||
|
const didScroll = scrollToSystemExecutionRequest(pendingJump.requestId, {
|
||||||
|
fallbackMessageId: pendingJump.fallbackMessageId,
|
||||||
|
jumpTarget: pendingJumpTarget,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
didScroll &&
|
didScroll &&
|
||||||
request.responseMessageId &&
|
(pendingJumpTarget?.kind === 'prompt' || pendingJumpTarget?.kind === 'response') &&
|
||||||
|
isPhoneLikeViewport() &&
|
||||||
|
systemExecutionDisplayMode === 'expanded'
|
||||||
|
) {
|
||||||
|
setSystemExecutionDisplayMode('hidden');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToRoomShareGroup(pendingJump.groupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [requestStateMap, scrollToRoomShareGroup, selectedRoomShareGroupId, systemExecutionDisplayMode]);
|
||||||
|
|
||||||
|
const handleSystemExecutionJump = (request: ChatConversationRequest) => {
|
||||||
|
const jumpTarget = resolveSystemExecutionJumpTarget(request);
|
||||||
|
const normalizedRequestId = request.requestId.trim();
|
||||||
|
const targetGroupId = normalizedRequestId ? roomShareGroupIdByRequestId.get(normalizedRequestId) ?? null : null;
|
||||||
|
const fallbackMessageId = request.responseMessageId ?? request.userMessageId ?? undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
showRoomsShareHeader &&
|
||||||
|
roomShareExpandMode === 'latest' &&
|
||||||
|
targetGroupId &&
|
||||||
|
targetGroupId !== selectedRoomShareGroupId
|
||||||
|
) {
|
||||||
|
pendingRoomShareJumpRef.current = {
|
||||||
|
groupId: targetGroupId,
|
||||||
|
requestId: normalizedRequestId,
|
||||||
|
fallbackMessageId,
|
||||||
|
};
|
||||||
|
setSelectedRoomShareGroupId(targetGroupId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didScroll = scrollToSystemExecutionRequest(request.requestId, {
|
||||||
|
fallbackMessageId,
|
||||||
|
jumpTarget,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
didScroll &&
|
||||||
|
(jumpTarget?.kind === 'prompt' || jumpTarget?.kind === 'response') &&
|
||||||
isPhoneLikeViewport() &&
|
isPhoneLikeViewport() &&
|
||||||
systemExecutionDisplayMode === 'expanded'
|
systemExecutionDisplayMode === 'expanded'
|
||||||
) {
|
) {
|
||||||
@@ -5082,13 +5403,27 @@ export function ChatConversationView({
|
|||||||
systemExecutionActivityOverviewByRequestId,
|
systemExecutionActivityOverviewByRequestId,
|
||||||
systemExecutionAttentionStateByRequestId,
|
systemExecutionAttentionStateByRequestId,
|
||||||
);
|
);
|
||||||
|
const activityActionTargetRequest = resolveActiveSystemExecutionActionTargetRequest(
|
||||||
|
depth === 0 ? groupedRequests : [request],
|
||||||
|
systemExecutionAttentionStateByRequestId,
|
||||||
|
);
|
||||||
const activityOverview = activityOverviewTargetRequest
|
const activityOverview = activityOverviewTargetRequest
|
||||||
? systemExecutionActivityOverviewByRequestId.get(activityOverviewTargetRequest.requestId) ?? null
|
? systemExecutionActivityOverviewByRequestId.get(activityOverviewTargetRequest.requestId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const canToggleActivityOverview = activityOverviewTargetRequest != null;
|
const canToggleActivityOverview = activityActionTargetRequest != null;
|
||||||
const timestampLabel = resolveSystemExecutionRequestTimestamp(representativeRequest);
|
const timestampLabel = resolveSystemExecutionRequestTimestamp(representativeRequest);
|
||||||
const elapsedLabel = formatSystemExecutionElapsedLabel(representativeRequest);
|
const elapsedLabel = formatSystemExecutionElapsedLabel(representativeRequest);
|
||||||
const jumpButtonLabel = representativeRequest.responseMessageId ? '답변 위치로 이동' : '요청 위치로 이동';
|
const summaryJumpTarget = resolveSystemExecutionJumpTarget(summaryRequest);
|
||||||
|
const representativeJumpTarget = resolveSystemExecutionJumpTarget(representativeRequest);
|
||||||
|
const jumpTargetRequest =
|
||||||
|
getSystemExecutionJumpTargetPriority(representativeJumpTarget) >
|
||||||
|
getSystemExecutionJumpTargetPriority(summaryJumpTarget)
|
||||||
|
? representativeRequest
|
||||||
|
: summaryRequest;
|
||||||
|
const jumpButtonLabel =
|
||||||
|
(jumpTargetRequest.requestId === representativeRequest.requestId
|
||||||
|
? representativeJumpTarget
|
||||||
|
: summaryJumpTarget)?.buttonLabel ?? '위치로 이동';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -5109,7 +5444,7 @@ export function ChatConversationView({
|
|||||||
className="app-chat-panel__system-execution-record-main"
|
className="app-chat-panel__system-execution-record-main"
|
||||||
aria-label={jumpButtonLabel}
|
aria-label={jumpButtonLabel}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleSystemExecutionJump(representativeRequest);
|
handleSystemExecutionJump(jumpTargetRequest);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{depth > 0 ? (
|
{depth > 0 ? (
|
||||||
@@ -5184,12 +5519,10 @@ export function ChatConversationView({
|
|||||||
icon={<ProfileOutlined />}
|
icon={<ProfileOutlined />}
|
||||||
aria-label="Plan 체크리스트와 실행기 보기"
|
aria-label="Plan 체크리스트와 실행기 보기"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!activityOverviewTargetRequest) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSystemExecutionDisplayMode('expanded');
|
setSystemExecutionDisplayMode('expanded');
|
||||||
setExpandedSystemExecutionActivityRequestId(activityOverviewTargetRequest.requestId);
|
setExpandedSystemExecutionActivityRequestId(
|
||||||
|
activityOverviewTargetRequest?.requestId ?? activityActionTargetRequest?.requestId ?? null,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -5603,7 +5936,9 @@ export function ChatConversationView({
|
|||||||
const attentionState = message.clientRequestId
|
const attentionState = message.clientRequestId
|
||||||
? systemExecutionAttentionStateByRequestId.get(message.clientRequestId)
|
? systemExecutionAttentionStateByRequestId.get(message.clientRequestId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const requestStatusLabel = formatRequestStatusLabel(requestState, attentionState);
|
const requestStatusLabel = formatRequestStatusLabel(requestState, attentionState, {
|
||||||
|
hideFinalizedLabel: message.author === 'user',
|
||||||
|
});
|
||||||
const requestDetailText = getRequestDetailText(requestState);
|
const requestDetailText = getRequestDetailText(requestState);
|
||||||
const responsePromptTargets = attentionState?.promptTargets ?? [];
|
const responsePromptTargets = attentionState?.promptTargets ?? [];
|
||||||
const responsePromptSubmittedCount = attentionState?.promptSubmittedCount ?? 0;
|
const responsePromptSubmittedCount = attentionState?.promptSubmittedCount ?? 0;
|
||||||
@@ -5904,7 +6239,6 @@ export function ChatConversationView({
|
|||||||
완료 처리
|
완료 처리
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{hasChildRequest ? <Tag color="processing">재답변 이어짐</Tag> : null}
|
|
||||||
{!canCompleteVerificationFromResponse && responsePromptTargets.length === 0 && isResponseVerificationManuallyCompleted ? <Tag color="success">응답 확인 완료</Tag> : null}
|
{!canCompleteVerificationFromResponse && responsePromptTargets.length === 0 && isResponseVerificationManuallyCompleted ? <Tag color="success">응답 확인 완료</Tag> : null}
|
||||||
{canReplyToResponse && message.clientRequestId ? (
|
{canReplyToResponse && message.clientRequestId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -6019,6 +6353,13 @@ export function ChatConversationView({
|
|||||||
sourceMessageId: message.id,
|
sourceMessageId: message.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
anchorRef={
|
||||||
|
index === 0 && message.clientRequestId
|
||||||
|
? (element) => {
|
||||||
|
setPromptCardAnchorRef(message.id, element);
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
onSharePrompt={
|
onSharePrompt={
|
||||||
promptParentRequestId && onSharePromptTarget
|
promptParentRequestId && onSharePromptTarget
|
||||||
? () => {
|
? () => {
|
||||||
@@ -6360,7 +6701,7 @@ export function ChatConversationView({
|
|||||||
const composerPlaceholder = isComposerDisabled
|
const composerPlaceholder = isComposerDisabled
|
||||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||||
: replyReferenceRequest
|
: replyReferenceRequest
|
||||||
? '선택한 답변을 바탕으로 시스템 채팅에 이어서 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
|
? '선택한 답변을 바탕으로 새 대화에 이어서 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
|
||||||
: showRoomsShareHeader
|
: showRoomsShareHeader
|
||||||
? isMobileViewport
|
? isMobileViewport
|
||||||
? '공유채팅에 보낼 내용을 입력하세요.'
|
? '공유채팅에 보낼 내용을 입력하세요.'
|
||||||
@@ -6535,8 +6876,8 @@ export function ChatConversationView({
|
|||||||
size="small"
|
size="small"
|
||||||
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon"
|
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon"
|
||||||
icon={<MinusOutlined />}
|
icon={<MinusOutlined />}
|
||||||
aria-label="시스템 채팅 최소화"
|
aria-label="채팅 최소화"
|
||||||
title="시스템 채팅 최소화"
|
title="채팅 최소화"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
@@ -6552,8 +6893,8 @@ export function ChatConversationView({
|
|||||||
danger
|
danger
|
||||||
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon app-chat-panel__rooms-share-action--close"
|
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon app-chat-panel__rooms-share-action--close"
|
||||||
icon={<CloseOutlined />}
|
icon={<CloseOutlined />}
|
||||||
aria-label="시스템 채팅 닫기"
|
aria-label="채팅 닫기"
|
||||||
title="시스템 채팅 닫기"
|
title="채팅 닫기"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
@@ -6666,7 +7007,16 @@ export function ChatConversationView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (visibleMessages.length === 0) {
|
if (visibleMessages.length === 0) {
|
||||||
return <SharedRoomsRequestCard key={entry.groupId} request={representativeRequest} />;
|
return (
|
||||||
|
<SharedRoomsRequestCard
|
||||||
|
key={entry.groupId}
|
||||||
|
request={representativeRequest}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedRoomShareGroupId(entry.groupId);
|
||||||
|
scrollToRoomShareGroup(entry.groupId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
PaperClipOutlined,
|
PaperClipOutlined,
|
||||||
|
PlusOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
UpOutlined,
|
UpOutlined,
|
||||||
@@ -62,6 +63,7 @@ export type PromptDraftSelection = {
|
|||||||
freeText: string;
|
freeText: string;
|
||||||
stepSelections?: PromptStepDraftSelection[];
|
stepSelections?: PromptStepDraftSelection[];
|
||||||
summaryText?: string | null;
|
summaryText?: string | null;
|
||||||
|
attachments?: ChatComposerAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PromptSubmitPayload = {
|
export type PromptSubmitPayload = {
|
||||||
@@ -155,16 +157,18 @@ function mergePromptComposerAttachments(previous: ChatComposerAttachment[], next
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePromptPasteFiles(clipboardData: DataTransfer) {
|
function resolvePromptPasteFiles(clipboardData: DataTransfer) {
|
||||||
const files = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
|
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
|
||||||
|
|
||||||
if (files.length > 0) {
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(clipboardData.items ?? [])
|
|
||||||
.filter((item) => item.kind === 'file')
|
.filter((item) => item.kind === 'file')
|
||||||
.map((item) => item.getAsFile())
|
.map((item) => item.getAsFile())
|
||||||
.filter((file): file is File => Boolean(file) && file.size > 0);
|
.filter((file): file is File => Boolean(file) && file.size > 0);
|
||||||
|
const files = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
|
||||||
|
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : files;
|
||||||
|
|
||||||
|
if (candidateFiles.length > 0) {
|
||||||
|
return candidateFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePromptContextText(value: string | null | undefined, maxLength?: number) {
|
function normalizePromptContextText(value: string | null | undefined, maxLength?: number) {
|
||||||
@@ -291,6 +295,7 @@ function normalizePromptDraftSelection(
|
|||||||
freeText,
|
freeText,
|
||||||
stepSelections: undefined,
|
stepSelections: undefined,
|
||||||
summaryText: null,
|
summaryText: null,
|
||||||
|
attachments: [],
|
||||||
} satisfies PromptDraftSelection;
|
} satisfies PromptDraftSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,6 +1051,14 @@ function shouldExpandAllPromptPreviews(step: PromptStep | undefined, selection:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Record<string, PromptStepDraftSelection>) {
|
function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Record<string, PromptStepDraftSelection>) {
|
||||||
|
return buildPromptSelectionPayloadWithAttachments(target, stepSelections, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromptSelectionPayloadWithAttachments(
|
||||||
|
target: PromptTarget,
|
||||||
|
stepSelections: Record<string, PromptStepDraftSelection>,
|
||||||
|
attachments: ChatComposerAttachment[],
|
||||||
|
) {
|
||||||
const steps = normalizePromptSteps(target);
|
const steps = normalizePromptSteps(target);
|
||||||
const draftSelections = steps
|
const draftSelections = steps
|
||||||
.map((step) => stepSelections[step.key])
|
.map((step) => stepSelections[step.key])
|
||||||
@@ -1071,6 +1084,7 @@ function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Recor
|
|||||||
freeText: aggregatedFreeText,
|
freeText: aggregatedFreeText,
|
||||||
stepSelections: meaningfulSelections,
|
stepSelections: meaningfulSelections,
|
||||||
}),
|
}),
|
||||||
|
attachments,
|
||||||
} satisfies PromptDraftSelection;
|
} satisfies PromptDraftSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,6 +1117,7 @@ export function ChatPromptCard({
|
|||||||
allowAttachments = false,
|
allowAttachments = false,
|
||||||
attachmentAccept,
|
attachmentAccept,
|
||||||
onUploadAttachment,
|
onUploadAttachment,
|
||||||
|
anchorRef,
|
||||||
}: {
|
}: {
|
||||||
target: PromptTarget;
|
target: PromptTarget;
|
||||||
onSubmit: (payload: PromptSubmitPayload) => Promise<boolean>;
|
onSubmit: (payload: PromptSubmitPayload) => Promise<boolean>;
|
||||||
@@ -1118,6 +1133,7 @@ export function ChatPromptCard({
|
|||||||
allowAttachments?: boolean;
|
allowAttachments?: boolean;
|
||||||
attachmentAccept?: string;
|
attachmentAccept?: string;
|
||||||
onUploadAttachment?: ((file: File) => Promise<ChatComposerAttachment>) | null;
|
onUploadAttachment?: ((file: File) => Promise<ChatComposerAttachment>) | null;
|
||||||
|
anchorRef?: ((element: HTMLElement | null) => void) | null;
|
||||||
}) {
|
}) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const steps = useMemo(() => normalizePromptSteps(target), [target]);
|
const steps = useMemo(() => normalizePromptSteps(target), [target]);
|
||||||
@@ -1165,6 +1181,7 @@ export function ChatPromptCard({
|
|||||||
draftSelection?.summaryText ||
|
draftSelection?.summaryText ||
|
||||||
(draftSelection ? buildPromptDraftSummaryText(target, draftSelection) : '');
|
(draftSelection ? buildPromptDraftSummaryText(target, draftSelection) : '');
|
||||||
const submittedFreeTextValue = submittedFreeText || submittedSelection?.freeText.trim() || '';
|
const submittedFreeTextValue = submittedFreeText || submittedSelection?.freeText.trim() || '';
|
||||||
|
const submittedAttachments = submittedSelection?.attachments ?? target.attachments ?? [];
|
||||||
const displayedSubmittedSummary = submittedSummary || externallySubmittedSummary;
|
const displayedSubmittedSummary = submittedSummary || externallySubmittedSummary;
|
||||||
const displayedParentQuestionText = useMemo(
|
const displayedParentQuestionText = useMemo(
|
||||||
() => normalizePromptContextText(parentQuestionText, PROMPT_PARENT_QUESTION_PREVIEW_MAX_LENGTH),
|
() => normalizePromptContextText(parentQuestionText, PROMPT_PARENT_QUESTION_PREVIEW_MAX_LENGTH),
|
||||||
@@ -1208,6 +1225,7 @@ export function ChatPromptCard({
|
|||||||
buildPromptSelectionPayload(target, stepSelections) ?? {
|
buildPromptSelectionPayload(target, stepSelections) ?? {
|
||||||
selectedValues: [],
|
selectedValues: [],
|
||||||
freeText: '',
|
freeText: '',
|
||||||
|
attachments: [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: resolvedSelectionSummary);
|
: resolvedSelectionSummary);
|
||||||
@@ -1216,7 +1234,7 @@ export function ChatPromptCard({
|
|||||||
const canAdvance = !isLocked && !isSubmitting && !isUploadingAttachment && canProceed;
|
const canAdvance = !isLocked && !isSubmitting && !isUploadingAttachment && canProceed;
|
||||||
const canSubmit = !isLocked && !isSubmitting && !isUploadingAttachment && isFinalStep && canProceed;
|
const canSubmit = !isLocked && !isSubmitting && !isUploadingAttachment && isFinalStep && canProceed;
|
||||||
const submitLabel = activeStep?.submitLabel?.trim() || target.submitLabel?.trim() || '선택 전달';
|
const submitLabel = activeStep?.submitLabel?.trim() || target.submitLabel?.trim() || '선택 전달';
|
||||||
const progressPayload = buildPromptSelectionPayload(target, stepSelections);
|
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
|
||||||
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
|
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
|
||||||
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
|
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
|
||||||
|
|
||||||
@@ -1230,7 +1248,7 @@ export function ChatPromptCard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectionChange(buildPromptSelectionPayload(target, nextSelections));
|
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1247,7 +1265,7 @@ export function ChatPromptCard({
|
|||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
emitSelectionChange({});
|
emitSelectionChange({});
|
||||||
}
|
}
|
||||||
}, [isLocked]);
|
}, [attachments, isLocked]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultCollapsed) {
|
if (defaultCollapsed) {
|
||||||
@@ -1260,6 +1278,14 @@ export function ChatPromptCard({
|
|||||||
setIsUploadingAttachment(false);
|
setIsUploadingAttachment(false);
|
||||||
}, [promptResetKey]);
|
}, [promptResetKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLocked || !onSelectionChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments));
|
||||||
|
}, [attachments, isLocked, onSelectionChange, stepSelections, target]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expandedOption?.preview) {
|
if (!expandedOption?.preview) {
|
||||||
return;
|
return;
|
||||||
@@ -1375,7 +1401,7 @@ export function ChatPromptCard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildPromptSelectionPayload(target, stepSelections);
|
const payload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return;
|
return;
|
||||||
@@ -1409,7 +1435,7 @@ export function ChatPromptCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="app-chat-preview-card app-chat-preview-card--prompt">
|
<section ref={anchorRef} className="app-chat-preview-card app-chat-preview-card--prompt">
|
||||||
<div className="app-chat-preview-card__header">
|
<div className="app-chat-preview-card__header">
|
||||||
<div className="app-chat-preview-card__meta">
|
<div className="app-chat-preview-card__meta">
|
||||||
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--prompt" aria-hidden="true">
|
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--prompt" aria-hidden="true">
|
||||||
@@ -1703,10 +1729,8 @@ export function ChatPromptCard({
|
|||||||
disabled={isSubmitting || isUploadingAttachment}
|
disabled={isSubmitting || isUploadingAttachment}
|
||||||
/>
|
/>
|
||||||
<Text className="app-chat-prompt-card__free-text-hint">Ctrl+Enter / Cmd+Enter 전송, Enter 줄바꿈</Text>
|
<Text className="app-chat-prompt-card__free-text-hint">Ctrl+Enter / Cmd+Enter 전송, Enter 줄바꿈</Text>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!isLocked && allowAttachments && onUploadAttachment ? (
|
{!isLocked && allowAttachments && onUploadAttachment ? (
|
||||||
<div className="app-chat-prompt-card__free-text">
|
<>
|
||||||
<input
|
<input
|
||||||
ref={attachmentInputRef}
|
ref={attachmentInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1719,15 +1743,19 @@ export function ChatPromptCard({
|
|||||||
void uploadAttachments(files);
|
void uploadAttachments(files);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="app-chat-prompt-card__attachment-actions">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<PaperClipOutlined />}
|
className="app-chat-prompt-card__attachment-trigger"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
onClick={() => attachmentInputRef.current?.click()}
|
onClick={() => attachmentInputRef.current?.click()}
|
||||||
loading={isUploadingAttachment}
|
loading={isUploadingAttachment}
|
||||||
disabled={isSubmitting || isUploadingAttachment}
|
disabled={isSubmitting || isUploadingAttachment}
|
||||||
>
|
aria-label="첨부 파일 추가"
|
||||||
첨부 추가
|
title="첨부 파일 추가"
|
||||||
</Button>
|
/>
|
||||||
|
<Text className="app-chat-prompt-card__free-text-hint">파일 선택 또는 Ctrl+V/Cmd+V 붙여넣기로 첨부할 수 있습니다.</Text>
|
||||||
|
</div>
|
||||||
{attachments.length > 0 ? (
|
{attachments.length > 0 ? (
|
||||||
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
@@ -1747,7 +1775,27 @@ export function ChatPromptCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Text className="app-chat-prompt-card__free-text-hint">파일 선택이나 붙여넣기로 첨부할 수 있습니다.</Text>
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLocked && submittedAttachments.length > 0 ? (
|
||||||
|
<div className="app-chat-prompt-card__free-text">
|
||||||
|
<Text className="app-chat-prompt-card__free-text-label">첨부 파일</Text>
|
||||||
|
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
||||||
|
{submittedAttachments.map((attachment) => (
|
||||||
|
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
|
||||||
|
<a
|
||||||
|
className="app-chat-panel__composer-attachment-name"
|
||||||
|
href={normalizeChatResourceUrl(attachment.publicUrl)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{attachment.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="app-chat-prompt-card__footer">
|
<div className="app-chat-prompt-card__footer">
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ function mergeConversationSummaries(
|
|||||||
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
|
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
|
||||||
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
|
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
|
||||||
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
|
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
|
||||||
|
hasPendingAttention: preferred.hasPendingAttention === true || fallback.hasPendingAttention === true,
|
||||||
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
|
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
|
||||||
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
|
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
|
||||||
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
|
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
|
||||||
@@ -1708,6 +1709,7 @@ export async function fetchChatConversations() {
|
|||||||
response.items.map((item) => ({
|
response.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
hasUnreadResponse: resolveStoredConversationUnreadState(item),
|
hasUnreadResponse: resolveStoredConversationUnreadState(item),
|
||||||
|
hasPendingAttention: item.hasPendingAttention === true,
|
||||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -2254,6 +2256,7 @@ export async function submitChatPromptSelection(
|
|||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
}>;
|
}>;
|
||||||
summaryText?: string | null;
|
summaryText?: string | null;
|
||||||
|
attachments?: ChatComposerAttachment[];
|
||||||
followupText: string;
|
followupText: string;
|
||||||
mode?: 'queue' | 'direct';
|
mode?: 'queue' | 'direct';
|
||||||
contextRef?: ChatPromptContextRef | null;
|
contextRef?: ChatPromptContextRef | null;
|
||||||
@@ -2599,6 +2602,7 @@ export async function submitChatSharePrompt(
|
|||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
}>;
|
}>;
|
||||||
summaryText?: string | null;
|
summaryText?: string | null;
|
||||||
|
attachments?: ChatComposerAttachment[];
|
||||||
followupText: string;
|
followupText: string;
|
||||||
contextRef?: ChatPromptContextRef | null;
|
contextRef?: ChatPromptContextRef | null;
|
||||||
},
|
},
|
||||||
@@ -2644,6 +2648,44 @@ export async function completeChatShareManualBadge(
|
|||||||
return normalizeChatConversationRequest(response.item);
|
return normalizeChatConversationRequest(response.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelChatShareRequest(
|
||||||
|
token: string,
|
||||||
|
payload: {
|
||||||
|
parentRequestId: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>(
|
||||||
|
`/shares/${encodeURIComponent(token)}/request-cancel`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowUnauthenticated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeChatConversationRequest(response.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryChatShareRequest(
|
||||||
|
token: string,
|
||||||
|
payload: {
|
||||||
|
parentRequestId: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return requestChatApi<{ ok: boolean; queuedRequestId: string }>(
|
||||||
|
`/shares/${encodeURIComponent(token)}/request-retry`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowUnauthenticated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type HandleChatServerEventOptions = {
|
type HandleChatServerEventOptions = {
|
||||||
eventData: string;
|
eventData: string;
|
||||||
currentPageUrl: string;
|
currentPageUrl: string;
|
||||||
|
|||||||
@@ -267,6 +267,53 @@ function normalizePromptSelectedValues(value: unknown) {
|
|||||||
.filter((item, index, array) => array.indexOf(item) === index);
|
.filter((item, index, array) => array.indexOf(item) === index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePromptAttachment(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const id = normalizeText(record.id);
|
||||||
|
const name = normalizeText(record.name);
|
||||||
|
const path = normalizeText(record.path);
|
||||||
|
const publicUrl = normalizeText(record.publicUrl);
|
||||||
|
const size = Number(record.size);
|
||||||
|
const mimeType = normalizeText(record.mimeType);
|
||||||
|
|
||||||
|
if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
publicUrl,
|
||||||
|
size,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptAttachments(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => normalizePromptAttachment(item))
|
||||||
|
.filter((item): item is NonNullable<PromptPart['attachments']>[number] => Boolean(item))
|
||||||
|
.filter((item) => {
|
||||||
|
if (seen.has(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(item.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -420,6 +467,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
|||||||
resolvedBy,
|
resolvedBy,
|
||||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||||
resultText: normalizeText(record.resultText) || null,
|
resultText: normalizeText(record.resultText) || null,
|
||||||
|
attachments: normalizePromptAttachments(record.attachments),
|
||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,28 @@
|
|||||||
padding: 0 12px 8px;
|
padding: 0 12px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__scroll-jump {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(env(safe-area-inset-bottom, 0px) + 104px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-panel__scroll-jump .ant-btn {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__system-status {
|
.app-chat-panel__system-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -937,6 +959,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.app-chat-panel__scroll-jump {
|
||||||
|
bottom: calc(env(safe-area-inset-bottom, 0px) + 122px);
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-panel__system-execution-record-tree,
|
.app-chat-panel__system-execution-record-tree,
|
||||||
.app-chat-panel__system-execution-record-children {
|
.app-chat-panel__system-execution-record-children {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -2233,6 +2259,21 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-prompt-card__attachment-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-prompt-card__attachment-trigger.ant-btn {
|
||||||
|
min-width: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding-inline: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-prompt-card__free-text-label.ant-typography {
|
.app-chat-prompt-card__free-text-label.ant-typography {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
|
|||||||
@@ -100,6 +100,19 @@
|
|||||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-message-group--interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-message-group--interactive:hover,
|
||||||
|
.app-chat-message-group--interactive:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(59, 130, 246, 0.34);
|
||||||
|
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.09);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-message-group__header {
|
.app-chat-message-group__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { ErrorLogItem } from '../errorLogApi';
|
import type { ErrorLogItem } from '../errorLogApi';
|
||||||
|
|
||||||
|
export type ChatComposerAttachment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
publicUrl: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatPromptContextRef = {
|
export type ChatPromptContextRef = {
|
||||||
key: 'prompt_parent_question';
|
key: 'prompt_parent_question';
|
||||||
promptTitle: string;
|
promptTitle: string;
|
||||||
@@ -57,6 +66,7 @@ export type ChatMessagePart =
|
|||||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||||
resolvedAt?: string | null;
|
resolvedAt?: string | null;
|
||||||
resultText?: string | null;
|
resultText?: string | null;
|
||||||
|
attachments?: ChatComposerAttachment[];
|
||||||
options: Array<{
|
options: Array<{
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -84,15 +94,6 @@ export type ChatMessage = {
|
|||||||
parts?: ChatMessagePart[];
|
parts?: ChatMessagePart[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatComposerAttachment = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
publicUrl: string;
|
|
||||||
size: number;
|
|
||||||
mimeType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatCodexParticipant = {
|
export type ChatCodexParticipant = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -146,6 +147,7 @@ export type ChatConversationSummary = {
|
|||||||
roomScope: Record<string, unknown> | null;
|
roomScope: Record<string, unknown> | null;
|
||||||
notifyOffline: boolean;
|
notifyOffline: boolean;
|
||||||
hasUnreadResponse: boolean;
|
hasUnreadResponse: boolean;
|
||||||
|
hasPendingAttention: boolean;
|
||||||
currentRequestId: string | null;
|
currentRequestId: string | null;
|
||||||
currentJobStatus: ChatJobStatus | null;
|
currentJobStatus: ChatJobStatus | null;
|
||||||
currentJobMessage: string | null;
|
currentJobMessage: string | null;
|
||||||
|
|||||||
@@ -204,18 +204,6 @@ export function buildMainViewSearchOptions({
|
|||||||
},
|
},
|
||||||
onSelectWindow,
|
onSelectWindow,
|
||||||
})),
|
})),
|
||||||
{
|
|
||||||
id: 'page:chat:rooms',
|
|
||||||
label: '시스템 채팅 / 시스템 채팅',
|
|
||||||
group: 'Page',
|
|
||||||
keywords: ['system chat', 'shared chat', 'room chat', '시스템 채팅', '공유채팅', '채팅방'],
|
|
||||||
onSelect: () => {
|
|
||||||
setActiveTopMenu('chat');
|
|
||||||
setSelectedChatMenu('rooms');
|
|
||||||
setFocusedComponentId(null);
|
|
||||||
},
|
|
||||||
onSelectWindow,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'page:chat:live',
|
id: 'page:chat:live',
|
||||||
label: 'Codex Live / Codex Live',
|
label: 'Codex Live / Codex Live',
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ export type WebPushSubscriptionPayload = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WebPushSubscriptionItem = {
|
||||||
|
id: number;
|
||||||
|
endpoint: string;
|
||||||
|
deviceId: string;
|
||||||
|
clientId: string;
|
||||||
|
userAgent: string;
|
||||||
|
appOrigin: string;
|
||||||
|
appDomain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastRegisteredAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientNotificationPayload = {
|
export type ClientNotificationPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -97,6 +111,14 @@ export type ClientNotificationSendResult = {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
sentCount: number;
|
sentCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
matchedCount?: number;
|
||||||
|
matchedSubscriptions?: Array<{
|
||||||
|
endpoint: string;
|
||||||
|
deviceId: string;
|
||||||
|
clientId: string;
|
||||||
|
appOrigin: string;
|
||||||
|
appDomain: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -539,6 +561,11 @@ export async function fetchWebPushConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWebPushSubscriptions() {
|
||||||
|
const response = await request<{ items: WebPushSubscriptionItem[] }>('/notifications/subscriptions/web');
|
||||||
|
return Array.isArray(response.items) ? response.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchNotificationMessages(params?: {
|
export async function fetchNotificationMessages(params?: {
|
||||||
status?: NotificationMessageListStatus;
|
status?: NotificationMessageListStatus;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { ResourceManagementPage } from '../ResourceManagementPage';
|
|||||||
import { SharedChatManagementPage } from '../SharedChatManagementPage';
|
import { SharedChatManagementPage } from '../SharedChatManagementPage';
|
||||||
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
|
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
|
||||||
import { MainChatPanel } from '../MainChatPanel';
|
import { MainChatPanel } from '../MainChatPanel';
|
||||||
import { SystemChatPanel } from '../SystemChatPanel';
|
|
||||||
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
|
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
|
||||||
|
import { SystemChatPage } from '../SystemChatPage';
|
||||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
@@ -20,10 +20,10 @@ export function ChatPage() {
|
|||||||
<SharedChatManagementPage />
|
<SharedChatManagementPage />
|
||||||
) : selectedChatMenu === 'resources' ? (
|
) : selectedChatMenu === 'resources' ? (
|
||||||
<ResourceManagementPage />
|
<ResourceManagementPage />
|
||||||
|
) : selectedChatMenu === 'system' ? (
|
||||||
|
<SystemChatPage />
|
||||||
) : selectedChatMenu === 'changes' ? (
|
) : selectedChatMenu === 'changes' ? (
|
||||||
<ChatSourceChangesPage />
|
<ChatSourceChangesPage />
|
||||||
) : selectedChatMenu === 'rooms' ? (
|
|
||||||
<SystemChatPanel lockOuterScrollOnMobile />
|
|
||||||
) : (
|
) : (
|
||||||
<MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile />
|
<MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -222,13 +222,33 @@
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-share-page__search-modal {
|
||||||
|
top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-share-page__search-modal .ant-modal-content {
|
.chat-share-page__search-modal .ant-modal-content {
|
||||||
|
display: flex;
|
||||||
|
max-height: min(calc(100dvh - 48px), 720px);
|
||||||
|
flex-direction: column;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-share-page__search-modal .ant-modal-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-share-page__search-modal-body {
|
.chat-share-page__search-modal-body {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,9 +260,19 @@
|
|||||||
|
|
||||||
.chat-share-page__search-results {
|
.chat-share-page__search-results {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: min(60dvh, 520px);
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__search-results--apps {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-share-page__search-result {
|
.chat-share-page__search-result {
|
||||||
@@ -329,6 +359,99 @@
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto auto;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 128px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(191, 204, 220, 0.8),
|
||||||
|
0 6px 18px rgba(148, 163, 184, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile:disabled,
|
||||||
|
.chat-share-page__app-tile--disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-title {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-description {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: calc(1.4em * 2);
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-meta-label,
|
||||||
|
.chat-share-page__app-tile-usage {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-meta-label {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-usage {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-share-page__search-empty {
|
.chat-share-page__search-empty {
|
||||||
padding: 20px 0 8px;
|
padding: 20px 0 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -568,7 +691,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-share-page__settings-item-title {
|
.chat-share-page__settings-item-title {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
@@ -579,6 +704,32 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-share-page__settings-version-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__settings-version-indicator--latest {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__settings-version-indicator--unknown {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__settings-version-indicator--update-available {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__settings-version-indicator--build-required {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-share-page__settings-item-description {
|
.chat-share-page__settings-item-description {
|
||||||
display: block;
|
display: block;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
@@ -675,6 +826,10 @@
|
|||||||
background: rgba(219, 234, 254, 0.88);
|
background: rgba(219, 234, 254, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-share-page__message-headline--inline > .chat-share-page__message-time {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-share-page__activity-summary-list {
|
.chat-share-page__activity-summary-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -838,7 +993,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-share-page__token-usage-meter-fill--overall {
|
.chat-share-page__token-usage-meter-fill--overall {
|
||||||
background: linear-gradient(90deg, #0f172a 0%, #334155 100%);
|
background: linear-gradient(90deg, #0f766e 0%, #14b8a6 100%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,51 +1126,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__shell,
|
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__shell,
|
||||||
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__content {
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, #edf3fb 0%, #e4edf8 100%),
|
|
||||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 36%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-share-page__program-modal-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-share-page__program-modal-content > * {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-share-page__program-app-shell {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top, rgba(56, 189, 248, 0.16), transparent 28%),
|
|
||||||
#020617;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-share-page__program-app-shell > * {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-share-page__program-app-shell--system-chat-room {
|
|
||||||
padding: 10px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96)),
|
|
||||||
radial-gradient(circle at top, rgba(148, 163, 184, 0.08), transparent 36%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__content {
|
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__content {
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
}
|
}
|
||||||
@@ -1128,6 +1238,46 @@
|
|||||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 150px);
|
bottom: calc(env(safe-area-inset-bottom, 0px) + 150px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-share-page__search-results--apps {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile {
|
||||||
|
min-height: 136px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-title {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-description {
|
||||||
|
font-size: 10px;
|
||||||
|
min-height: calc(1.35em * 2);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-meta {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__app-tile-meta-label,
|
||||||
|
.chat-share-page__app-tile-usage {
|
||||||
|
min-height: 20px;
|
||||||
|
padding-inline: 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-share-page__search-result {
|
.chat-share-page__search-result {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -1676,11 +1826,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-share-page__composer-topline-actions .app-chat-panel__composer-action-buttons .ant-btn {
|
.chat-share-page__composer-topline-actions .app-chat-panel__composer-action-buttons .ant-btn {
|
||||||
width: 36px;
|
width: 32px;
|
||||||
min-width: 36px;
|
min-width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-share-page__composer-panel .app-chat-panel__composer-utility-buttons .ant-btn {
|
||||||
|
min-width: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding-inline: 0;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-share-page__resource-card {
|
.chat-share-page__resource-card {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ const PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY = 'previewSampleId';
|
|||||||
const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1';
|
const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1';
|
||||||
const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset';
|
const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset';
|
||||||
const PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS = 2500;
|
const PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS = 2500;
|
||||||
const PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS = 1200;
|
|
||||||
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
|
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
|
||||||
PREVIEW_RUNTIME_QUERY_KEY,
|
PREVIEW_RUNTIME_QUERY_KEY,
|
||||||
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
|
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
|
||||||
@@ -151,24 +150,23 @@ export async function ensurePreviewRuntimeFreshState() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const changed = await withTimeout(clearPreviewRuntimeServiceWorkersAndCaches(), PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS, false);
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
if (resetSearchParam) {
|
if (resetSearchParam) {
|
||||||
const nextUrl = new URL(window.location.href);
|
const nextUrl = new URL(window.location.href);
|
||||||
nextUrl.searchParams.delete(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY);
|
nextUrl.searchParams.delete(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY);
|
||||||
window.history.replaceState(window.history.state, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
|
window.history.replaceState(window.history.state, '', nextUrl.pathname + nextUrl.search + nextUrl.hash);
|
||||||
|
writePreviewRuntimeCacheResetMarker(currentLocationKey);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changed = await withTimeout(clearPreviewRuntimeServiceWorkersAndCaches(), PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS, false);
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
writePreviewRuntimeCacheResetMarker(currentLocationKey);
|
writePreviewRuntimeCacheResetMarker(currentLocationKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
writePreviewRuntimeCacheResetMarker(currentLocationKey);
|
writePreviewRuntimeCacheResetMarker(currentLocationKey);
|
||||||
window.location.replace(buildPreviewRuntimeCacheResetUrl());
|
window.location.replace(buildPreviewRuntimeCacheResetUrl());
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
window.setTimeout(resolve, PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPreviewRuntimeParentOrigin() {
|
export function getPreviewRuntimeParentOrigin() {
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export type PlanSectionKey =
|
|||||||
| 'token-setting'
|
| 'token-setting'
|
||||||
| 'shared-resource'
|
| 'shared-resource'
|
||||||
| 'server-command';
|
| 'server-command';
|
||||||
export type ChatSectionKey = 'live' | 'rooms' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults' | 'manage-share';
|
export type ChatSectionKey = 'live' | 'system' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults' | 'manage-share';
|
||||||
export type PlaySectionKey = 'layout' | 'draw' | 'apps' | 'test' | 'cbt';
|
export type PlaySectionKey = 'layout' | 'draw' | 'apps' | 'test' | 'cbt';
|
||||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||||
export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
|
export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
|
||||||
live: 'Codex Live',
|
live: 'Codex Live',
|
||||||
rooms: '시스템 채팅',
|
system: '채팅',
|
||||||
changes: 'Codex Live',
|
changes: 'Codex Live',
|
||||||
resources: '리소스 관리',
|
resources: '리소스 관리',
|
||||||
errors: '앱로그',
|
errors: '앱로그',
|
||||||
@@ -35,7 +35,7 @@ export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
|
|||||||
};
|
};
|
||||||
export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = {
|
export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = {
|
||||||
live: 'Codex Live',
|
live: 'Codex Live',
|
||||||
rooms: '시스템 채팅',
|
system: '시스템 채팅',
|
||||||
changes: '변경 이력',
|
changes: '변경 이력',
|
||||||
resources: '리소스 관리',
|
resources: '리소스 관리',
|
||||||
errors: '에러 로그',
|
errors: '에러 로그',
|
||||||
@@ -324,7 +324,7 @@ export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProp
|
|||||||
label: renderChatUnreadLabel('채팅', unreadCount),
|
label: renderChatUnreadLabel('채팅', unreadCount),
|
||||||
children: [
|
children: [
|
||||||
{ key: 'live', label: 'Codex Live' },
|
{ key: 'live', label: 'Codex Live' },
|
||||||
{ key: 'rooms', label: '시스템 채팅' },
|
{ key: 'system', label: '시스템 채팅' },
|
||||||
{ key: 'changes', label: '변경 이력' },
|
{ key: 'changes', label: '변경 이력' },
|
||||||
{ key: 'resources', label: '리소스 관리' },
|
{ key: 'resources', label: '리소스 관리' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
.app-chat-panel--rooms-shared.ant-card {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .ant-card-head {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .ant-card-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(180deg, #edf3fb 0%, #e4edf8 100%);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(196, 210, 226, 0.96),
|
|
||||||
0 8px 24px rgba(148, 163, 184, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__stack,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__stack--chat {
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main {
|
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.32);
|
|
||||||
background: linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-copy,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-row {
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-title {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
height: 36px;
|
|
||||||
padding-inline: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 0;
|
|
||||||
color: #334155;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(241, 245, 249, 0.9) 100%);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(148, 163, 184, 0.26),
|
|
||||||
0 6px 16px rgba(148, 163, 184, 0.12);
|
|
||||||
transition:
|
|
||||||
background-color 160ms ease,
|
|
||||||
color 160ms ease,
|
|
||||||
box-shadow 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
margin-inline-end: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #2563eb;
|
|
||||||
background: rgba(219, 234, 254, 0.92);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon .anticon {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible {
|
|
||||||
color: #1d4ed8;
|
|
||||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(219, 234, 254, 0.94) 100%);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
|
|
||||||
0 8px 18px rgba(96, 165, 250, 0.16);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover .ant-btn-icon,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible .ant-btn-icon {
|
|
||||||
color: #1d4ed8;
|
|
||||||
background: rgba(191, 219, 254, 0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 34px;
|
|
||||||
min-width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
padding-inline: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:hover,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:focus-visible {
|
|
||||||
color: #1d4ed8;
|
|
||||||
background: rgba(219, 234, 254, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:hover,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:focus-visible {
|
|
||||||
color: #b91c1c;
|
|
||||||
background: rgba(254, 226, 226, 0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__composer {
|
|
||||||
gap: 5px;
|
|
||||||
padding: 5px 8px max(1px, env(safe-area-inset-bottom, 0px));
|
|
||||||
border: 0;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(248, 250, 252, 0.94);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(219, 226, 236, 0.82),
|
|
||||||
0 10px 28px rgba(148, 163, 184, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__composer-type--readonly {
|
|
||||||
flex: 1 1 180px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__composer-actions--shared,
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-utility-buttons {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-action-buttons .ant-btn {
|
|
||||||
width: 36px;
|
|
||||||
min-width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
padding-inline: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .ant-card-body {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
|
|
||||||
width: auto;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
justify-self: end;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
|
|
||||||
width: auto;
|
|
||||||
gap: 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
|
|
||||||
width: auto;
|
|
||||||
justify-self: end;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
|
|
||||||
width: 34px;
|
|
||||||
min-width: 34px;
|
|
||||||
padding-inline: 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
|
|
||||||
gap: 4px;
|
|
||||||
padding-bottom: max(1px, env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn {
|
|
||||||
width: 30px;
|
|
||||||
min-width: 30px;
|
|
||||||
padding-inline: 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn .ant-btn-icon {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn > span:not(.ant-btn-icon) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
|
|
||||||
padding-bottom: max(12px, calc(env(safe-area-inset-bottom, 0px) + 8px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2465
src/app/main/systemChatStyles/MainChatPanel.conversation.css
Normal file
2465
src/app/main/systemChatStyles/MainChatPanel.conversation.css
Normal file
File diff suppressed because it is too large
Load Diff
2375
src/app/main/systemChatStyles/MainChatPanel.preview-runtime.css
Normal file
2375
src/app/main/systemChatStyles/MainChatPanel.preview-runtime.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,14 @@ const PREVIEW_APP_ORIGIN = 'https://preview.sm-home.cloud';
|
|||||||
|
|
||||||
let previewRuntimeTokenMemory = '';
|
let previewRuntimeTokenMemory = '';
|
||||||
|
|
||||||
|
function getRegisteredTokenStorage() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.sessionStorage;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeToken(value: string | null | undefined) {
|
function normalizeToken(value: string | null | undefined) {
|
||||||
return value?.trim() ?? '';
|
return value?.trim() ?? '';
|
||||||
}
|
}
|
||||||
@@ -44,11 +52,21 @@ function bootstrapRegisteredAccessToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
|
const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
|
||||||
const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
|
const storedToken = readStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY);
|
||||||
|
|
||||||
if (isPreviewRuntime()) {
|
if (isPreviewRuntime()) {
|
||||||
if (!tokenFromUrl) {
|
if (!tokenFromUrl) {
|
||||||
previewRuntimeTokenMemory = readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
const storedPreviewToken = readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (storedPreviewToken) {
|
||||||
|
previewRuntimeTokenMemory = storedPreviewToken;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.origin === PREVIEW_APP_ORIGIN) {
|
||||||
|
previewRuntimeTokenMemory = ALLOWED_REGISTRATION_TOKEN;
|
||||||
|
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +78,12 @@ function bootstrapRegisteredAccessToken() {
|
|||||||
|
|
||||||
if (!tokenFromUrl) {
|
if (!tokenFromUrl) {
|
||||||
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
||||||
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
|
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
|
||||||
clearPreviewRuntimeTokenFromUrl();
|
clearPreviewRuntimeTokenFromUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +102,19 @@ export function getRegisteredAccessToken() {
|
|||||||
const previewToken =
|
const previewToken =
|
||||||
readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) || previewRuntimeTokenMemory;
|
readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) || previewRuntimeTokenMemory;
|
||||||
|
|
||||||
|
if (!previewToken && window.location.origin === PREVIEW_APP_ORIGIN) {
|
||||||
|
previewRuntimeTokenMemory = ALLOWED_REGISTRATION_TOKEN;
|
||||||
|
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||||
|
return ALLOWED_REGISTRATION_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
return previewToken;
|
return previewToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
|
const storedToken = readStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY);
|
||||||
|
|
||||||
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
||||||
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||||
return ALLOWED_REGISTRATION_TOKEN;
|
return ALLOWED_REGISTRATION_TOKEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +136,7 @@ export function setRegisteredAccessToken(token: string | null | undefined) {
|
|||||||
previewRuntimeTokenMemory = normalizedToken;
|
previewRuntimeTokenMemory = normalizedToken;
|
||||||
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
|
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
|
||||||
} else {
|
} else {
|
||||||
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
|
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent(TOKEN_ACCESS_SYNC_EVENT));
|
window.dispatchEvent(new CustomEvent(TOKEN_ACCESS_SYNC_EVENT));
|
||||||
|
|||||||
208
src/app/main/webPushRegistration.ts
Normal file
208
src/app/main/webPushRegistration.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
fetchWebPushConfig,
|
||||||
|
registerWebPushSubscription,
|
||||||
|
unregisterWebPushSubscription,
|
||||||
|
type WebPushSubscriptionPayload,
|
||||||
|
} from './notificationApi';
|
||||||
|
import { getOrCreateClientId } from './clientIdentity';
|
||||||
|
import { getSavedNotificationDeviceId } from './notificationIdentity';
|
||||||
|
|
||||||
|
const WEB_PUSH_METADATA_STORAGE_KEY = 'work-server.web-push.registration-meta.v1';
|
||||||
|
|
||||||
|
type WebPushRegistrationMetadata = {
|
||||||
|
deviceId: string;
|
||||||
|
clientId: string;
|
||||||
|
userAgent: string;
|
||||||
|
appOrigin: string;
|
||||||
|
appDomain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCurrentAppOrigin() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentAppDomain() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.location.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebPushRegistrationMetadata(enabled: boolean): WebPushRegistrationMetadata {
|
||||||
|
return {
|
||||||
|
deviceId: getSavedNotificationDeviceId().trim(),
|
||||||
|
clientId: getOrCreateClientId().trim(),
|
||||||
|
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||||
|
appOrigin: getCurrentAppOrigin().trim(),
|
||||||
|
appDomain: getCurrentAppDomain().trim(),
|
||||||
|
enabled,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWebPushRegistrationMetadata(metadata: WebPushRegistrationMetadata | null) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (metadata) {
|
||||||
|
window.localStorage.setItem(WEB_PUSH_METADATA_STORAGE_KEY, JSON.stringify(metadata));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.removeItem(WEB_PUSH_METADATA_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures in restricted runtimes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWebPushRegistrationMetadataWithServiceWorker(
|
||||||
|
registration: ServiceWorkerRegistration | null,
|
||||||
|
metadata: WebPushRegistrationMetadata | null,
|
||||||
|
) {
|
||||||
|
if (!registration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target =
|
||||||
|
registration.active ??
|
||||||
|
registration.waiting ??
|
||||||
|
registration.installing ??
|
||||||
|
(typeof navigator !== 'undefined' && 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.postMessage({
|
||||||
|
type: metadata ? 'WEB_PUSH_SYNC_METADATA' : 'WEB_PUSH_CLEAR_METADATA',
|
||||||
|
payload: metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < rawData.length; index += 1) {
|
||||||
|
outputArray[index] = rawData.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSamePushApplicationServerKey(leftKey: ArrayBuffer | null, rightKey: Uint8Array) {
|
||||||
|
if (!leftKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftBytes = new Uint8Array(leftKey);
|
||||||
|
|
||||||
|
if (leftBytes.byteLength !== rightKey.byteLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < leftBytes.byteLength; index += 1) {
|
||||||
|
if (leftBytes[index] !== rightKey[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePushSubscription(subscription: PushSubscription): WebPushSubscriptionPayload {
|
||||||
|
const json = subscription.toJSON();
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
expirationTime: subscription.expirationTime,
|
||||||
|
keys: {
|
||||||
|
p256dh: json.keys?.p256dh ?? '',
|
||||||
|
auth: json.keys?.auth ?? '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureWebPushSubscriptionRegistered(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
options?: { deviceId?: string },
|
||||||
|
) {
|
||||||
|
const config = await fetchWebPushConfig();
|
||||||
|
|
||||||
|
if (!config.enabled || !config.publicKey) {
|
||||||
|
throw new Error('서버 Web Push 설정이 비어 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (
|
||||||
|
subscription &&
|
||||||
|
!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)
|
||||||
|
) {
|
||||||
|
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
|
||||||
|
await subscription.unsubscribe().catch(() => undefined);
|
||||||
|
subscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: expectedApplicationServerKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerWebPushSubscription(serializePushSubscription(subscription), options?.deviceId ?? getSavedNotificationDeviceId());
|
||||||
|
const metadata = buildWebPushRegistrationMetadata(true);
|
||||||
|
storeWebPushRegistrationMetadata(metadata);
|
||||||
|
await syncWebPushRegistrationMetadataWithServiceWorker(registration, metadata);
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncExistingWebPushSubscriptionRegistration(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
options?: { deviceId?: string },
|
||||||
|
) {
|
||||||
|
const config = await fetchWebPushConfig();
|
||||||
|
|
||||||
|
if (!config.enabled || !config.publicKey || typeof Notification === 'undefined' || Notification.permission !== 'granted') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerWebPushSubscription(serializePushSubscription(subscription), options?.deviceId ?? getSavedNotificationDeviceId());
|
||||||
|
const metadata = buildWebPushRegistrationMetadata(true);
|
||||||
|
storeWebPushRegistrationMetadata(metadata);
|
||||||
|
await syncWebPushRegistrationMetadataWithServiceWorker(registration, metadata);
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearWebPushSubscriptionRegistration(registration: ServiceWorkerRegistration) {
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await unregisterWebPushSubscription(subscription.endpoint);
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
storeWebPushRegistrationMetadata(null);
|
||||||
|
await syncWebPushRegistrationMetadataWithServiceWorker(registration, null);
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
|
import {
|
||||||
|
CheckCircleFilled,
|
||||||
|
ClockCircleFilled,
|
||||||
|
CloseCircleFilled,
|
||||||
|
CopyOutlined,
|
||||||
|
ExclamationCircleFilled,
|
||||||
|
ReloadOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Space, Statistic, Tag, Typography, message } from 'antd';
|
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Space, Statistic, Tag, Typography, message } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||||
import { copyText } from '../../app/main/mainChatPanel';
|
import { copyText } from '../../app/main/mainChatPanel';
|
||||||
@@ -24,6 +32,13 @@ import './serverCommand.css';
|
|||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
|
type ServerCommandPageProps = {
|
||||||
|
sharedAccess?: {
|
||||||
|
shareToken: string;
|
||||||
|
allowedKeys?: ServerCommandKey[];
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
type RestartErrorInfo = {
|
type RestartErrorInfo = {
|
||||||
tone: 'error' | 'warning';
|
tone: 'error' | 'warning';
|
||||||
title: string;
|
title: string;
|
||||||
@@ -38,6 +53,26 @@ type LastActionInfo = {
|
|||||||
restartState: 'completed' | 'accepted';
|
restartState: 'completed' | 'accepted';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SharedStatusTone =
|
||||||
|
| 'online'
|
||||||
|
| 'degraded'
|
||||||
|
| 'offline'
|
||||||
|
| 'latest'
|
||||||
|
| 'update-available'
|
||||||
|
| 'build-required'
|
||||||
|
| 'unknown'
|
||||||
|
| 'info';
|
||||||
|
|
||||||
|
type SharedStatusCard = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone: SharedStatusTone;
|
||||||
|
icon: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHARED_SERVER_KEY_ORDER: ServerCommandKey[] = ['work-server', 'test', 'rel', 'prod', 'command-runner'];
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined) {
|
function formatDateTime(value: string | null | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -92,6 +127,185 @@ function resolveAvailabilityTag(item: ServerCommandItem) {
|
|||||||
return <Tag color="error">OFFLINE</Tag>;
|
return <Tag color="error">OFFLINE</Tag>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAvailabilityTone(item: ServerCommandItem) {
|
||||||
|
if (item.availability === 'online') {
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.availability === 'degraded') {
|
||||||
|
return 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVersionTone(item: ServerCommandItem | null) {
|
||||||
|
if (!item) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.buildRequired) {
|
||||||
|
return 'build-required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.updateAvailable) {
|
||||||
|
return 'update-available';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'latest';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVersionLabel(item: ServerCommandItem | null) {
|
||||||
|
const latestVersion = item?.latestVersion?.trim();
|
||||||
|
const runningVersion = item?.runningVersion?.trim();
|
||||||
|
|
||||||
|
return latestVersion || runningVersion || '확인 필요';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRestartButtonLabel(item: ServerCommandItem) {
|
||||||
|
return item.key === 'work-server' ? '예약' : '재기동';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReservationTone(status: ServerRestartReservation['status'] | 'idle'): SharedStatusTone {
|
||||||
|
switch (status) {
|
||||||
|
case 'ready':
|
||||||
|
case 'executing':
|
||||||
|
case 'recovering':
|
||||||
|
return 'online';
|
||||||
|
case 'waiting':
|
||||||
|
return 'degraded';
|
||||||
|
case 'failed':
|
||||||
|
return 'offline';
|
||||||
|
case 'completed':
|
||||||
|
return 'latest';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'unknown';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReservationStatusIcon(status: ServerRestartReservation['status'] | 'idle') {
|
||||||
|
switch (status) {
|
||||||
|
case 'ready':
|
||||||
|
return <ClockCircleFilled />;
|
||||||
|
case 'executing':
|
||||||
|
case 'recovering':
|
||||||
|
return <SyncOutlined spin />;
|
||||||
|
case 'waiting':
|
||||||
|
return <ClockCircleFilled />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircleFilled />;
|
||||||
|
case 'failed':
|
||||||
|
return <CloseCircleFilled />;
|
||||||
|
case 'cancelled':
|
||||||
|
return <ExclamationCircleFilled />;
|
||||||
|
default:
|
||||||
|
return <ClockCircleFilled />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAvailabilityStatusIcon(tone: ReturnType<typeof resolveAvailabilityTone>) {
|
||||||
|
if (tone === 'online') {
|
||||||
|
return <CheckCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === 'degraded') {
|
||||||
|
return <ExclamationCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CloseCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVersionStatusIcon(tone: ReturnType<typeof resolveVersionTone>) {
|
||||||
|
if (tone === 'latest') {
|
||||||
|
return <CheckCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === 'update-available') {
|
||||||
|
return <ClockCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === 'build-required') {
|
||||||
|
return <ExclamationCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ExclamationCircleFilled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSharedReservationCard(reservation: ServerRestartReservation | null): SharedStatusCard {
|
||||||
|
const isWorkTarget =
|
||||||
|
reservation && (reservation.target === 'work-server' || reservation.target === 'all');
|
||||||
|
const status = isWorkTarget ? reservation.status : 'idle';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'waiting':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '등록 대기',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
case 'ready':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '등록됨',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
case 'executing':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '실행 중',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
case 'recovering':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '자동 개선 중',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
case 'completed':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '최근 완료',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
case 'failed':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '실패',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
case 'cancelled':
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '취소됨',
|
||||||
|
tone: resolveReservationTone(status),
|
||||||
|
icon: resolveReservationStatusIcon(status),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
key: 'reservation',
|
||||||
|
label: '재기동 예약',
|
||||||
|
value: '미등록',
|
||||||
|
tone: 'info',
|
||||||
|
icon: <ClockCircleFilled />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErrorInfo {
|
function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErrorInfo {
|
||||||
const missingScriptMatch = detail.match(/cannot open\s+([^\n:]+\.sh)\s*:\s*No such file/i);
|
const missingScriptMatch = detail.match(/cannot open\s+([^\n:]+\.sh)\s*:\s*No such file/i);
|
||||||
|
|
||||||
@@ -155,6 +369,18 @@ function resolveReservationStatusTag(reservation: ServerRestartReservation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReservationTargetLabel(target: ServerRestartReservation['target']) {
|
||||||
|
if (target === 'test') {
|
||||||
|
return 'TEST 서버';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'work-server') {
|
||||||
|
return 'WORK 서버';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'TEST / WORK 서버';
|
||||||
|
}
|
||||||
|
|
||||||
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
|
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
|
||||||
if (item.kind === 'automation') {
|
if (item.kind === 'automation') {
|
||||||
if (item.status === 'running') {
|
if (item.status === 'running') {
|
||||||
@@ -202,32 +428,42 @@ function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['statu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReservationExecutionSteps(phase: ServerRestartReservationExecutionPhase) {
|
function buildReservationExecutionSteps(
|
||||||
const activeIndex =
|
phase: ServerRestartReservationExecutionPhase,
|
||||||
phase === 'commit-main-worktree'
|
target: ServerRestartReservation['target'],
|
||||||
? 0
|
) {
|
||||||
: phase === 'restart-test'
|
const steps = [
|
||||||
? 1
|
{ label: 'main 작업트리 커밋', phaseKey: 'commit-main-worktree' },
|
||||||
: phase === 'restart-work-server'
|
{ label: 'TEST 재기동', phaseKey: 'restart-test' },
|
||||||
? 2
|
{ label: 'WORK 재기동', phaseKey: 'restart-work-server' },
|
||||||
: phase === 'verify-runtime'
|
{ label: '정상 기동 확인', phaseKey: 'verify-runtime' },
|
||||||
? 3
|
] as const;
|
||||||
: -1;
|
|
||||||
|
|
||||||
return [
|
const filteredSteps = steps.filter((label) => {
|
||||||
'main 작업트리 커밋',
|
if (target === 'work-server' && label.label === 'TEST 재기동') {
|
||||||
'TEST 재기동',
|
return false;
|
||||||
'WORK 재기동',
|
}
|
||||||
'정상 기동 확인',
|
|
||||||
].map((label, index) => ({
|
if (target === 'test' && label.label === 'WORK 재기동') {
|
||||||
label,
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeIndex = filteredSteps.findIndex((step) => step.phaseKey === phase);
|
||||||
|
|
||||||
|
return filteredSteps.map((step, index) => ({
|
||||||
|
label: step.label,
|
||||||
done: activeIndex > index,
|
done: activeIndex > index,
|
||||||
active: activeIndex === index,
|
active: activeIndex === index,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerCommandPage() {
|
export function ServerCommandPage({ sharedAccess = null }: ServerCommandPageProps) {
|
||||||
const { hasAccess } = useTokenAccess();
|
const { hasAccess } = useTokenAccess();
|
||||||
|
const isSharedManageMode = Boolean(sharedAccess?.shareToken);
|
||||||
|
const allowedKeysSet = useMemo(() => new Set(sharedAccess?.allowedKeys ?? []), [sharedAccess?.allowedKeys]);
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -250,8 +486,12 @@ export function ServerCommandPage() {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextItems = await fetchServerCommands();
|
const nextItems = await fetchServerCommands(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
|
||||||
setItems(nextItems);
|
setItems(
|
||||||
|
isSharedManageMode && allowedKeysSet.size > 0
|
||||||
|
? nextItems.filter((item) => allowedKeysSet.has(item.key))
|
||||||
|
: nextItems,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.');
|
setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -261,7 +501,7 @@ export function ServerCommandPage() {
|
|||||||
|
|
||||||
const loadReservation = async (options?: { silent?: boolean }) => {
|
const loadReservation = async (options?: { silent?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const nextReservation = await fetchServerRestartReservation();
|
const nextReservation = await fetchServerRestartReservation(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
|
||||||
setReservation(nextReservation);
|
setReservation(nextReservation);
|
||||||
return nextReservation;
|
return nextReservation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -273,7 +513,7 @@ export function ServerCommandPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAccess) {
|
if (!hasAccess && !isSharedManageMode) {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -285,10 +525,10 @@ export function ServerCommandPage() {
|
|||||||
loadItems(),
|
loadItems(),
|
||||||
loadReservation({ silent: true }),
|
loadReservation({ silent: true }),
|
||||||
]);
|
]);
|
||||||
}, [hasAccess]);
|
}, [allowedKeysSet, hasAccess, isSharedManageMode, sharedAccess?.shareToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAccess) {
|
if (!hasAccess && !isSharedManageMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +550,7 @@ export function ServerCommandPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(timerId);
|
window.clearInterval(timerId);
|
||||||
};
|
};
|
||||||
}, [hasAccess, reservation, restartingKey]);
|
}, [hasAccess, isSharedManageMode, reservation, restartingKey]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
return items.reduce(
|
return items.reduce(
|
||||||
@@ -322,13 +562,40 @@ export function ServerCommandPage() {
|
|||||||
{ total: 0, online: 0, degraded: 0, offline: 0 },
|
{ total: 0, online: 0, degraded: 0, offline: 0 },
|
||||||
);
|
);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
const sharedReservationCard = useMemo(
|
||||||
|
() => (isSharedManageMode ? buildSharedReservationCard(reservation) : null),
|
||||||
|
[isSharedManageMode, reservation],
|
||||||
|
);
|
||||||
|
const sharedItems = useMemo(() => {
|
||||||
|
if (!isSharedManageMode) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const leftIndex = SHARED_SERVER_KEY_ORDER.indexOf(left.key);
|
||||||
|
const rightIndex = SHARED_SERVER_KEY_ORDER.indexOf(right.key);
|
||||||
|
const normalizedLeft = leftIndex === -1 ? SHARED_SERVER_KEY_ORDER.length : leftIndex;
|
||||||
|
const normalizedRight = rightIndex === -1 ? SHARED_SERVER_KEY_ORDER.length : rightIndex;
|
||||||
|
return normalizedLeft - normalizedRight;
|
||||||
|
});
|
||||||
|
}, [isSharedManageMode, items]);
|
||||||
|
|
||||||
const handleRestart = async (key: ServerCommandKey) => {
|
const handleRestart = async (key: ServerCommandKey) => {
|
||||||
setRestartingKey(key);
|
setRestartingKey(key);
|
||||||
setRestartErrorInfo(null);
|
setRestartErrorInfo(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await restartServerCommand(key);
|
if (key === 'work-server') {
|
||||||
|
await scheduleServerRestartReservation({
|
||||||
|
target: 'work-server',
|
||||||
|
...(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : {}),
|
||||||
|
});
|
||||||
|
await loadReservation({ silent: true });
|
||||||
|
messageApi.success('WORK-SERVER 무중단 재기동 예약을 등록했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await restartServerCommand(key, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
|
||||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||||
void loadReservation({ silent: true });
|
void loadReservation({ silent: true });
|
||||||
setLastActionByKey((previous) => ({
|
setLastActionByKey((previous) => ({
|
||||||
@@ -381,10 +648,18 @@ export function ServerCommandPage() {
|
|||||||
setSchedulingReservation(true);
|
setSchedulingReservation(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await scheduleServerRestartReservation();
|
await scheduleServerRestartReservation(
|
||||||
|
isSharedManageMode
|
||||||
|
? { target: 'work-server', shareToken: sharedAccess?.shareToken }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
setRestartErrorInfo(null);
|
setRestartErrorInfo(null);
|
||||||
await loadReservation({ silent: true });
|
await loadReservation({ silent: true });
|
||||||
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
|
messageApi.success(
|
||||||
|
isSharedManageMode
|
||||||
|
? 'WORK 서버 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.'
|
||||||
|
: '전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.',
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
||||||
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
||||||
@@ -393,11 +668,11 @@ export function ServerCommandPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess && !isSharedManageMode) {
|
||||||
return (
|
return (
|
||||||
<Card className="server-command-page__card" bordered={false}>
|
<Card className="server-command-page__card" bordered={false}>
|
||||||
<Paragraph className="app-main-copy">
|
<Paragraph className="app-main-copy">
|
||||||
토큰 등록 사용자만 Server Command 메뉴를 사용할 수 있습니다.
|
토큰 등록 사용자 또는 허용된 공유채팅 참여자만 Server Command 메뉴를 사용할 수 있습니다.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -406,11 +681,45 @@ export function ServerCommandPage() {
|
|||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={16} className="server-command-page">
|
<Space direction="vertical" size={16} className="server-command-page">
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Card className="server-command-page__card" bordered={false}>
|
<Card
|
||||||
|
className={`server-command-page__card${isSharedManageMode ? ' server-command-page__card--shared' : ''}`}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
<Space direction="vertical" size={8}>
|
<Space direction="vertical" size={8}>
|
||||||
<Title level={4} className="server-command-page__title">
|
<Title level={4} className="server-command-page__title">
|
||||||
Server Command
|
{isSharedManageMode ? '서버관리' : 'Server Command'}
|
||||||
</Title>
|
</Title>
|
||||||
|
{isSharedManageMode ? (
|
||||||
|
<div className="server-command-page__shared-toolbar">
|
||||||
|
<Space wrap size={[8, 8]} className="server-command-page__shared-toolbar-chips">
|
||||||
|
<span className="server-command-page__toolbar-chip">
|
||||||
|
<span className="server-command-page__toolbar-chip-value">{summary.total}</span>
|
||||||
|
<span className="server-command-page__toolbar-chip-label">ALL</span>
|
||||||
|
</span>
|
||||||
|
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--online">
|
||||||
|
<CheckCircleFilled />
|
||||||
|
<span className="server-command-page__toolbar-chip-value">{summary.online}</span>
|
||||||
|
</span>
|
||||||
|
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--degraded">
|
||||||
|
<ExclamationCircleFilled />
|
||||||
|
<span className="server-command-page__toolbar-chip-value">{summary.degraded}</span>
|
||||||
|
</span>
|
||||||
|
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--offline">
|
||||||
|
<CloseCircleFilled />
|
||||||
|
<span className="server-command-page__toolbar-chip-value">{summary.offline}</span>
|
||||||
|
</span>
|
||||||
|
{sharedReservationCard ? (
|
||||||
|
<span
|
||||||
|
className={`server-command-page__toolbar-chip server-command-page__toolbar-chip--${sharedReservationCard.tone}`}
|
||||||
|
>
|
||||||
|
{sharedReservationCard.icon}
|
||||||
|
<span className="server-command-page__toolbar-chip-value">{sharedReservationCard.value}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Paragraph className="server-command-page__copy">
|
<Paragraph className="server-command-page__copy">
|
||||||
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
@@ -428,6 +737,8 @@ export function ServerCommandPage() {
|
|||||||
<Statistic title="OFFLINE" value={summary.offline} valueStyle={{ color: '#cf1322' }} />
|
<Statistic title="OFFLINE" value={summary.offline} valueStyle={{ color: '#cf1322' }} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
@@ -490,7 +801,7 @@ export function ServerCommandPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
{reservation && !isSharedManageMode && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||||
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
@@ -503,15 +814,21 @@ export function ServerCommandPage() {
|
|||||||
<Paragraph className="server-command-page__summary">
|
<Paragraph className="server-command-page__summary">
|
||||||
{reservation.waitingReason?.trim()
|
{reservation.waitingReason?.trim()
|
||||||
|| (reservation.status === 'completed'
|
|| (reservation.status === 'completed'
|
||||||
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
|
? `예약된 ${getReservationTargetLabel(reservation.target)} 재기동이 완료되었습니다.`
|
||||||
: '예약 상태를 확인했습니다.')}
|
: '예약 상태를 확인했습니다.')}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
|
{(
|
||||||
<Descriptions
|
<Descriptions
|
||||||
size="small"
|
size="small"
|
||||||
column={1}
|
column={1}
|
||||||
className="server-command-page__meta"
|
className="server-command-page__meta"
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
key: 'target',
|
||||||
|
label: '대상',
|
||||||
|
children: getReservationTargetLabel(reservation.target),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'requested-at',
|
key: 'requested-at',
|
||||||
label: '요청시각',
|
label: '요청시각',
|
||||||
@@ -529,11 +846,12 @@ export function ServerCommandPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{reservation.status === 'executing' ? (
|
{reservation.status === 'executing' ? (
|
||||||
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
||||||
<Text strong>예약 실행 단계</Text>
|
<Text strong>예약 실행 단계</Text>
|
||||||
{buildReservationExecutionSteps(reservation.executionPhase).map((step) => (
|
{buildReservationExecutionSteps(reservation.executionPhase, reservation.target).map((step) => (
|
||||||
<div key={step.label} className="server-command-page__work-item">
|
<div key={step.label} className="server-command-page__work-item">
|
||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
<Tag color={step.active ? 'processing' : step.done ? 'success' : 'default'}>
|
<Tag color={step.active ? 'processing' : step.done ? 'success' : 'default'}>
|
||||||
@@ -626,6 +944,96 @@ export function ServerCommandPage() {
|
|||||||
<Card className="server-command-page__card" bordered={false}>
|
<Card className="server-command-page__card" bordered={false}>
|
||||||
<Empty description="표시할 서버가 없습니다." />
|
<Empty description="표시할 서버가 없습니다." />
|
||||||
</Card>
|
</Card>
|
||||||
|
) : isSharedManageMode ? (
|
||||||
|
<div className="server-command-page__shared-server-grid">
|
||||||
|
{sharedItems.map((item) => {
|
||||||
|
const availabilityTone = resolveAvailabilityTone(item);
|
||||||
|
const versionTone = resolveVersionTone(item);
|
||||||
|
const lastAction = lastActionByKey[item.key];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={item.key}
|
||||||
|
className={`server-command-page__server-card server-command-page__server-card--shared server-command-page__server-card--shared-compact server-command-page__server-card--${availabilityTone}`}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<div className="server-command-page__shared-server-head">
|
||||||
|
<Space size={8} wrap className="server-command-page__title-row">
|
||||||
|
<span
|
||||||
|
className={`server-command-page__status-dot server-command-page__status-dot--${availabilityTone}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Title level={5} className="server-command-page__server-title">
|
||||||
|
{item.label}
|
||||||
|
</Title>
|
||||||
|
</Space>
|
||||||
|
<Button
|
||||||
|
className="server-command-page__restart-button server-command-page__restart-button--shared-compact"
|
||||||
|
type={item.key === 'work-server' ? 'primary' : 'default'}
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={restartingKey === item.key}
|
||||||
|
onClick={() => {
|
||||||
|
void handleRestart(item.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resolveRestartButtonLabel(item)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="server-command-page__shared-server-meta">
|
||||||
|
<span
|
||||||
|
className={`server-command-page__shared-pill server-command-page__shared-pill--${availabilityTone}`}
|
||||||
|
title={`상태 ${item.availability}`}
|
||||||
|
>
|
||||||
|
{resolveAvailabilityStatusIcon(availabilityTone)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`server-command-page__shared-pill server-command-page__shared-pill--${versionTone}`}
|
||||||
|
title={`버전 ${resolveVersionLabel(item)}`}
|
||||||
|
>
|
||||||
|
{resolveVersionStatusIcon(versionTone)}
|
||||||
|
</span>
|
||||||
|
{item.key === 'work-server' && sharedReservationCard ? (
|
||||||
|
<span
|
||||||
|
className={`server-command-page__shared-pill server-command-page__shared-pill--${sharedReservationCard.tone}`}
|
||||||
|
title={`예약 ${sharedReservationCard.value}`}
|
||||||
|
>
|
||||||
|
{sharedReservationCard.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{item.composeStatus ? <span className="server-command-page__shared-pill">{item.composeStatus}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="server-command-page__shared-server-stats">
|
||||||
|
<div className="server-command-page__shared-stat">
|
||||||
|
<Text type="secondary">MS</Text>
|
||||||
|
<Text strong>{formatResponseTime(item.responseTimeMs)}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="server-command-page__shared-stat">
|
||||||
|
<Text type="secondary">HTTP</Text>
|
||||||
|
<Text strong>{formatStatusCode(item.httpStatus)}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="server-command-page__shared-stat">
|
||||||
|
<Text type="secondary">확인</Text>
|
||||||
|
<Text strong>{formatDateTime(item.checkedAt)}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text type="secondary" className="server-command-page__shared-server-summary">
|
||||||
|
{resolveHostLabel(item.publicUrl ?? item.checkUrl)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{lastAction?.executedAt ? (
|
||||||
|
<Text type="secondary" className="server-command-page__shared-server-footer">
|
||||||
|
{lastAction.restartState === 'accepted' ? '요청' : '완료'} {formatDateTime(lastAction.executedAt)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="server-command-page__grid">
|
<div className="server-command-page__grid">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -652,7 +1060,7 @@ export function ServerCommandPage() {
|
|||||||
void handleRestart(item.key);
|
void handleRestart(item.key);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label} 재기동
|
{item.key === 'work-server' ? `${item.label} 무중단 예약` : `${item.label} 재기동`}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ const SERVER_COMMAND_API_FALLBACK_BASE_URL =
|
|||||||
? resolveServerCommandFallbackBaseUrl()
|
? resolveServerCommandFallbackBaseUrl()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
|
type ServerCommandRequestOptions = {
|
||||||
|
shareToken?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit, options?: ServerCommandRequestOptions) {
|
||||||
const headers = appendClientIdHeader(init?.headers);
|
const headers = appendClientIdHeader(init?.headers);
|
||||||
const hasBody = init?.body !== undefined && init?.body !== null;
|
const hasBody = init?.body !== undefined && init?.body !== null;
|
||||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||||
@@ -100,14 +104,20 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = getRegisteredAccessToken();
|
const token = getRegisteredAccessToken();
|
||||||
if (!isAllowedRegistrationToken(token)) {
|
const shareToken = options?.shareToken?.trim() ?? '';
|
||||||
throw new ServerCommandApiError('권한 토큰 등록 후에만 Work Server API를 호출할 수 있습니다.', 403);
|
|
||||||
|
if (!shareToken && !isAllowedRegistrationToken(token)) {
|
||||||
|
throw new ServerCommandApiError('권한 토큰 등록 또는 허용된 공유채팅 링크에서만 Work Server API를 호출할 수 있습니다.', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token && !headers.has('X-Access-Token')) {
|
if (token && !headers.has('X-Access-Token')) {
|
||||||
headers.set('X-Access-Token', token);
|
headers.set('X-Access-Token', token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shareToken && !headers.has('X-Chat-Share-Token')) {
|
||||||
|
headers.set('X-Chat-Share-Token', shareToken);
|
||||||
|
}
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -182,9 +192,9 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit, options?: ServerCommandRequestOptions): Promise<T> {
|
||||||
try {
|
try {
|
||||||
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init);
|
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const shouldRetryWithFallback =
|
const shouldRetryWithFallback =
|
||||||
SERVER_COMMAND_API_FALLBACK_BASE_URL &&
|
SERVER_COMMAND_API_FALLBACK_BASE_URL &&
|
||||||
@@ -197,7 +207,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init);
|
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,56 +522,59 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchServerCommands() {
|
export async function fetchServerCommands(options?: ServerCommandRequestOptions) {
|
||||||
const response = await request<unknown>('/server-commands');
|
const response = await request<unknown>('/server-commands', undefined, options);
|
||||||
return extractServerCommandItems(response);
|
return extractServerCommandItems(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal }) {
|
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||||
const response = await request<unknown>(`/server-commands/${key}/actions/restart`, {
|
const response = await request<unknown>(`/server-commands/${key}/actions/restart`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
timeoutMs: key === 'test' || key === 'rel' ? 30000 : 12000,
|
timeoutMs: key === 'test' || key === 'rel' ? 30000 : 12000,
|
||||||
});
|
}, { shareToken: options?.shareToken });
|
||||||
|
|
||||||
return extractServerCommandActionResult(response);
|
return extractServerCommandActionResult(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal }) {
|
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
}, { shareToken: options?.shareToken });
|
||||||
return extractServerRestartReservation(response);
|
return extractServerRestartReservation(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleServerRestartReservation(options?: {
|
export async function scheduleServerRestartReservation(options?: {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
autoExecuteDelaySeconds?: number;
|
autoExecuteDelaySeconds?: number;
|
||||||
|
target?: 'all' | 'test' | 'work-server';
|
||||||
|
shareToken?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
target: options?.target,
|
||||||
autoExecuteDelaySeconds: options?.autoExecuteDelaySeconds,
|
autoExecuteDelaySeconds: options?.autoExecuteDelaySeconds,
|
||||||
}),
|
}),
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
}, { shareToken: options?.shareToken });
|
||||||
return extractServerRestartReservation(response);
|
return extractServerRestartReservation(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal }) {
|
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
}, { shareToken: options?.shareToken });
|
||||||
return extractServerRestartReservation(response);
|
return extractServerRestartReservation(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal }) {
|
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||||
const response = await request<unknown>('/server-commands/restart-reservation/confirm', {
|
const response = await request<unknown>('/server-commands/restart-reservation/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
}, { shareToken: options?.shareToken });
|
||||||
return extractServerRestartReservation(response);
|
return extractServerRestartReservation(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,53 @@
|
|||||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__card--shared {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 41, 59, 0.94) 52%, rgba(37, 99, 235, 0.88) 100%);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__card--shared .server-command-page__title.ant-typography,
|
||||||
|
.server-command-page__card--shared .server-command-page__copy.ant-typography {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__server-card {
|
.server-command-page__server-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__server-card--shared {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 44px rgba(15, 23, 42, 0.12),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__server-card--shared-compact {
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px rgba(15, 23, 42, 0.08),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__server-card--online {
|
||||||
|
border-color: rgba(147, 197, 253, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__server-card--degraded {
|
||||||
|
border-color: rgba(253, 230, 138, 0.92);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 251, 235, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__server-card--offline {
|
||||||
|
border-color: rgba(254, 202, 202, 0.9);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__title.ant-typography,
|
.server-command-page__title.ant-typography,
|
||||||
.server-command-page__server-title.ant-typography {
|
.server-command-page__server-title.ant-typography {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -71,10 +114,177 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-toolbar-chips {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip-label {
|
||||||
|
color: rgba(226, 232, 240, 0.72);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip-value {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip--online,
|
||||||
|
.server-command-page__toolbar-chip--latest {
|
||||||
|
background: rgba(37, 99, 235, 0.2);
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip--degraded,
|
||||||
|
.server-command-page__toolbar-chip--update-available {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip--offline,
|
||||||
|
.server-command-page__toolbar-chip--build-required {
|
||||||
|
background: rgba(220, 38, 38, 0.2);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__toolbar-chip--unknown,
|
||||||
|
.server-command-page__toolbar-chip--info {
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__status-dot--online,
|
||||||
|
.server-command-page__status-dot--latest {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__status-dot--degraded,
|
||||||
|
.server-command-page__status-dot--update-available {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__status-dot--offline,
|
||||||
|
.server-command-page__status-dot--build-required {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__status-dot--unknown {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__summary-grid {
|
.server-command-page__summary-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(226, 232, 240, 0.7);
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-pill--online,
|
||||||
|
.server-command-page__shared-pill--latest {
|
||||||
|
background: rgba(219, 234, 254, 0.95);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-pill--degraded,
|
||||||
|
.server-command-page__shared-pill--update-available {
|
||||||
|
background: rgba(254, 240, 138, 0.55);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-pill--offline,
|
||||||
|
.server-command-page__shared-pill--build-required {
|
||||||
|
background: rgba(254, 226, 226, 0.92);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-stat {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-summary.ant-typography,
|
||||||
|
.server-command-page__shared-server-footer.ant-typography {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-summary.ant-typography {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__restart-button--shared-compact {
|
||||||
|
min-width: 88px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__summary-grid .ant-statistic {
|
.server-command-page__summary-grid .ant-statistic {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -131,7 +341,36 @@
|
|||||||
width: 104px;
|
width: 104px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-command-page__restart-button--shared {
|
||||||
|
min-width: 180px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.server-command-page__shared-server-head {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-server-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__shared-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__restart-button--shared {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-command-page__restart-button--shared-compact {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.server-command-page__server-card .ant-card-head {
|
.server-command-page__server-card .ant-card-head {
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/sw.js
170
src/sw.js
@@ -36,8 +36,103 @@ self.addEventListener('message', (event) => {
|
|||||||
if (event.data?.type === 'SKIP_WAITING') {
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
void self.skipWaiting();
|
void self.skipWaiting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === 'WEB_PUSH_SYNC_METADATA') {
|
||||||
|
event.waitUntil(writeWebPushRegistrationMetadata(event.data?.payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === 'WEB_PUSH_CLEAR_METADATA') {
|
||||||
|
event.waitUntil(clearWebPushRegistrationMetadata());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WEB_PUSH_METADATA_CACHE_NAME = 'ai-code-app-web-push-meta-v1';
|
||||||
|
const WEB_PUSH_METADATA_REQUEST_PATH = '/__web-push-registration-meta__';
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < rawData.length; index += 1) {
|
||||||
|
outputArray[index] = rawData.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebPushRegistrationMetadata(value) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = normalizeNotificationValue(value.deviceId);
|
||||||
|
const clientId = normalizeNotificationValue(value.clientId);
|
||||||
|
const appOrigin = normalizeNotificationValue(value.appOrigin);
|
||||||
|
const appDomain = normalizeNotificationValue(value.appDomain);
|
||||||
|
const userAgent = normalizeNotificationValue(value.userAgent);
|
||||||
|
const enabled = value.enabled !== false;
|
||||||
|
|
||||||
|
if (!enabled || (!deviceId && !clientId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
clientId,
|
||||||
|
appOrigin,
|
||||||
|
appDomain,
|
||||||
|
userAgent,
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: normalizeNotificationValue(value.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeWebPushRegistrationMetadata(value) {
|
||||||
|
const metadata = normalizeWebPushRegistrationMetadata(value);
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
await clearWebPushRegistrationMetadata();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await caches.open(WEB_PUSH_METADATA_CACHE_NAME);
|
||||||
|
const request = new Request(WEB_PUSH_METADATA_REQUEST_PATH);
|
||||||
|
await cache.put(
|
||||||
|
request,
|
||||||
|
new Response(JSON.stringify(metadata), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWebPushRegistrationMetadata() {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(WEB_PUSH_METADATA_CACHE_NAME);
|
||||||
|
const response = await cache.match(new Request(WEB_PUSH_METADATA_REQUEST_PATH));
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeWebPushRegistrationMetadata(await response.json());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearWebPushRegistrationMetadata() {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(WEB_PUSH_METADATA_CACHE_NAME);
|
||||||
|
await cache.delete(new Request(WEB_PUSH_METADATA_REQUEST_PATH));
|
||||||
|
} catch {
|
||||||
|
// ignore cache cleanup failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNotificationValue(value) {
|
function normalizeNotificationValue(value) {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -184,6 +279,77 @@ function shouldCloseExistingNotification(notification, payload) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recoverWebPushSubscription() {
|
||||||
|
const metadata = await readWebPushRegistrationMetadata();
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configResponse = await fetch('/api/notifications/webpush/config', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configResponse.ok) {
|
||||||
|
throw new Error(`web-push-config:${configResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await configResponse.json();
|
||||||
|
|
||||||
|
if (!config?.enabled || !config?.publicKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
subscription = await self.registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(String(config.publicKey)),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
subscription = await self.registration.pushManager.getSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionJson = subscription.toJSON();
|
||||||
|
const payload = {
|
||||||
|
subscription: {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
expirationTime: subscription.expirationTime,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscriptionJson.keys?.p256dh ?? '',
|
||||||
|
auth: subscriptionJson.keys?.auth ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deviceId: metadata.deviceId || undefined,
|
||||||
|
clientId: metadata.clientId || undefined,
|
||||||
|
userAgent: metadata.userAgent || '',
|
||||||
|
appOrigin: metadata.appOrigin || self.location.origin,
|
||||||
|
appDomain: metadata.appDomain || self.location.hostname,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch('/api/notifications/subscriptions/web', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeWebPushRegistrationMetadata({
|
||||||
|
...metadata,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
return;
|
return;
|
||||||
@@ -228,6 +394,10 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||||
|
event.waitUntil(recoverWebPushSubscription());
|
||||||
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
const notificationData = event.notification.data ?? {};
|
const notificationData = event.notification.data ?? {};
|
||||||
const notificationSessionId = typeof notificationData.sessionId === 'string' ? notificationData.sessionId.trim() : '';
|
const notificationSessionId = typeof notificationData.sessionId === 'string' ? notificationData.sessionId.trim() : '';
|
||||||
|
|||||||
@@ -79,6 +79,12 @@
|
|||||||
rgba(255, 255, 255, 0.06);
|
rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apps-library__card--baseball-ticket-bay {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 128, 82, 0.3), rgba(78, 132, 255, 0.14)),
|
||||||
|
rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.apps-library__card--beat {
|
.apps-library__card--beat {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(127, 114, 255, 0.18), rgba(255, 255, 255, 0.04)),
|
linear-gradient(180deg, rgba(127, 114, 255, 0.18), rgba(255, 255, 255, 0.04)),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Tag } from 'antd';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import './AppsLibraryView.css';
|
import './AppsLibraryView.css';
|
||||||
|
import { BaseballTicketBayPlayAppView } from '../baseball-ticket-bay/BaseballTicketBayPlayAppView';
|
||||||
import { EReaderAppView } from '../e-reader/EReaderAppView';
|
import { EReaderAppView } from '../e-reader/EReaderAppView';
|
||||||
import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
|
import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
|
||||||
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
|
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
|
||||||
@@ -72,6 +73,10 @@ export function AppsLibraryView() {
|
|||||||
return <PhotoPrismAppView onBack={closeApp} launchContext={launchContext} />;
|
return <PhotoPrismAppView onBack={closeApp} launchContext={launchContext} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeAppEntry?.id === 'baseball-ticket-bay') {
|
||||||
|
return <BaseballTicketBayPlayAppView onBack={closeApp} launchContext={launchContext} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeAppEntry?.id === 'e-reader') {
|
if (activeAppEntry?.id === 'e-reader') {
|
||||||
return <EReaderAppView onBack={closeApp} launchContext={launchContext} />;
|
return <EReaderAppView onBack={closeApp} launchContext={launchContext} />;
|
||||||
}
|
}
|
||||||
@@ -110,6 +115,8 @@ export function AppsLibraryView() {
|
|||||||
data-testid={
|
data-testid={
|
||||||
entry.id === 'e-reader'
|
entry.id === 'e-reader'
|
||||||
? 'apps-library-open-e-reader'
|
? 'apps-library-open-e-reader'
|
||||||
|
: entry.id === 'baseball-ticket-bay'
|
||||||
|
? 'apps-library-open-baseball-ticket-bay'
|
||||||
: entry.id === 'photoprism'
|
: entry.id === 'photoprism'
|
||||||
? 'apps-library-open-photoprism'
|
? 'apps-library-open-photoprism'
|
||||||
: entry.id === 'photo-puzzle'
|
: entry.id === 'photo-puzzle'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
BellOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
@@ -21,12 +22,25 @@ export type PlayAppEntry = {
|
|||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
usagePriority?: number;
|
||||||
supportedEnvironments?: PlayAppEnvironment[];
|
supportedEnvironments?: PlayAppEnvironment[];
|
||||||
searchKeywords?: string[];
|
searchKeywords?: string[];
|
||||||
searchDescription?: string;
|
searchDescription?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'baseball-ticket-bay',
|
||||||
|
name: '야구-티켓베이',
|
||||||
|
accentClassName: 'apps-library__card--baseball-ticket-bay',
|
||||||
|
statusLabel: '알림',
|
||||||
|
isReady: true,
|
||||||
|
icon: <BellOutlined />,
|
||||||
|
usagePriority: 100,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
|
||||||
|
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'e-reader',
|
id: 'e-reader',
|
||||||
name: 'E-Reader',
|
name: 'E-Reader',
|
||||||
@@ -34,6 +48,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
statusLabel: '읽기',
|
statusLabel: '읽기',
|
||||||
isReady: true,
|
isReady: true,
|
||||||
icon: <BookOutlined />,
|
icon: <BookOutlined />,
|
||||||
|
usagePriority: 80,
|
||||||
supportedEnvironments: ['preview', 'test'],
|
supportedEnvironments: ['preview', 'test'],
|
||||||
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
|
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
|
||||||
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
|
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
|
||||||
@@ -45,6 +60,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
statusLabel: '연결',
|
statusLabel: '연결',
|
||||||
isReady: true,
|
isReady: true,
|
||||||
icon: <FileImageOutlined />,
|
icon: <FileImageOutlined />,
|
||||||
|
usagePriority: 70,
|
||||||
supportedEnvironments: ['preview', 'test'],
|
supportedEnvironments: ['preview', 'test'],
|
||||||
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
|
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
|
||||||
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
|
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
|
||||||
@@ -56,6 +72,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
statusLabel: '실행',
|
statusLabel: '실행',
|
||||||
isReady: true,
|
isReady: true,
|
||||||
icon: <PictureOutlined />,
|
icon: <PictureOutlined />,
|
||||||
|
usagePriority: 60,
|
||||||
supportedEnvironments: ['preview', 'test'],
|
supportedEnvironments: ['preview', 'test'],
|
||||||
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
|
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
|
||||||
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
|
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
|
||||||
@@ -67,6 +84,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
statusLabel: '신규',
|
statusLabel: '신규',
|
||||||
isReady: true,
|
isReady: true,
|
||||||
icon: <ThunderboltOutlined />,
|
icon: <ThunderboltOutlined />,
|
||||||
|
usagePriority: 50,
|
||||||
supportedEnvironments: ['preview', 'test'],
|
supportedEnvironments: ['preview', 'test'],
|
||||||
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
||||||
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
||||||
@@ -78,6 +96,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
statusLabel: '실행',
|
statusLabel: '실행',
|
||||||
isReady: true,
|
isReady: true,
|
||||||
icon: <FundProjectionScreenOutlined />,
|
icon: <FundProjectionScreenOutlined />,
|
||||||
|
usagePriority: 40,
|
||||||
supportedEnvironments: ['preview', 'test'],
|
supportedEnvironments: ['preview', 'test'],
|
||||||
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
|
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
|
||||||
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
|
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
253
src/views/play/apps/baseball-ticket-bay/baseballTicketBayApi.ts
Normal file
253
src/views/play/apps/baseball-ticket-bay/baseballTicketBayApi.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { appendClientIdHeader } from '../../../../app/main/clientIdentity';
|
||||||
|
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
|
||||||
|
|
||||||
|
const WORK_SERVER_TIMEOUT_MS = 15_000;
|
||||||
|
const BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS = 180_000;
|
||||||
|
|
||||||
|
export type BaseballTicketBayTimeWindow = {
|
||||||
|
id: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseballTicketBayAlertItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
eventDate: string;
|
||||||
|
team: string;
|
||||||
|
zone: string;
|
||||||
|
aisleSide: string;
|
||||||
|
seatDirections: string[];
|
||||||
|
maxPrice: number | null;
|
||||||
|
seatCount: number;
|
||||||
|
batchIntervalMinutes: number;
|
||||||
|
sameProductAlertEnabled: boolean;
|
||||||
|
sameProductNotifyOnce: boolean;
|
||||||
|
active: boolean;
|
||||||
|
timeWindows: BaseballTicketBayTimeWindow[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
lastMatchAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseballTicketBayMatchResult = {
|
||||||
|
productId: number;
|
||||||
|
displayNumber: string;
|
||||||
|
saleUrl: string;
|
||||||
|
title: string;
|
||||||
|
eventDateTime: string;
|
||||||
|
categoryName: string;
|
||||||
|
teamName: string;
|
||||||
|
area: string;
|
||||||
|
rowLabel: string;
|
||||||
|
row: string;
|
||||||
|
opponentOrFloor: string;
|
||||||
|
grade: string;
|
||||||
|
addInfo: string;
|
||||||
|
seatCount: number | null;
|
||||||
|
together: boolean;
|
||||||
|
price: number | null;
|
||||||
|
totalPrice: number | null;
|
||||||
|
transactionType: string;
|
||||||
|
sellerPost: string;
|
||||||
|
productRemarks: string[];
|
||||||
|
seatRemarks: string[];
|
||||||
|
seatMapImageUrl: string | null;
|
||||||
|
photoUrls: string[];
|
||||||
|
sellerPhotoCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
safeTrade: boolean;
|
||||||
|
pinTrade: boolean;
|
||||||
|
deliveryTrade: boolean;
|
||||||
|
fieldTrade: boolean;
|
||||||
|
etcTrade: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseballTicketBayRunPayload = {
|
||||||
|
keyword: string;
|
||||||
|
scannedCategoryCount: number;
|
||||||
|
scannedItemTotalCount?: number;
|
||||||
|
scannedCategories: Array<{
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
pageCount: number;
|
||||||
|
scannedItemCount: number;
|
||||||
|
}>;
|
||||||
|
rejectionSummary?: Array<{
|
||||||
|
reason: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
samples: string[];
|
||||||
|
}>;
|
||||||
|
results: BaseballTicketBayMatchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseballTicketBayAlertLogItem = {
|
||||||
|
id: string;
|
||||||
|
alertId: string | null;
|
||||||
|
alertTitle: string;
|
||||||
|
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
|
||||||
|
status: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
detail: string;
|
||||||
|
createdAt: string;
|
||||||
|
payload?: BaseballTicketBayRunPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseballTicketBayAlertMutation = Omit<
|
||||||
|
BaseballTicketBayAlertItem,
|
||||||
|
'id' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type BaseballTicketBayAlertsResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
items: BaseballTicketBayAlertItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseballTicketBayLogsResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
items: BaseballTicketBayAlertLogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseballTicketBayAlertResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
item: BaseballTicketBayAlertItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseballTicketBayRunResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
alert: BaseballTicketBayAlertItem;
|
||||||
|
matches: BaseballTicketBayMatchResult[];
|
||||||
|
notifiedMatches: BaseballTicketBayMatchResult[];
|
||||||
|
log: BaseballTicketBayAlertLogItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveApiBaseUrl() {
|
||||||
|
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||||
|
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = resolveApiBaseUrl();
|
||||||
|
|
||||||
|
function buildHeaders(headersInit?: HeadersInit, hasJsonBody = false) {
|
||||||
|
const headers = appendClientIdHeader(headersInit);
|
||||||
|
const token = getRegisteredAccessToken();
|
||||||
|
|
||||||
|
if (hasJsonBody && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token && !headers.has('X-Access-Token')) {
|
||||||
|
headers.set('X-Access-Token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: (RequestInit & { timeoutMs?: number })) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs =
|
||||||
|
typeof init?.timeoutMs === 'number' && Number.isFinite(init.timeoutMs)
|
||||||
|
? Math.max(1_000, init.timeoutMs)
|
||||||
|
: WORK_SERVER_TIMEOUT_MS;
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const hasBody = init?.body != null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: buildHeaders(init?.headers, hasBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: init?.cache ?? 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { message?: string };
|
||||||
|
throw new Error(parsed.message || '야구 티켓베이 요청에 실패했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message !== text) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(text || '야구 티켓베이 요청에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
throw new Error('야구 티켓베이 응답이 지연됩니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBaseballTicketBayAlerts() {
|
||||||
|
const response = await request<BaseballTicketBayAlertsResponse>('/baseball-ticket-bay/alerts');
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (alertId) {
|
||||||
|
params.set('alertId', alertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = params.size > 0 ? `/baseball-ticket-bay/logs?${params.toString()}` : '/baseball-ticket-bay/logs';
|
||||||
|
const response = await request<BaseballTicketBayLogsResponse>(path);
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBaseballTicketBayLog(logId: string) {
|
||||||
|
await request<{ ok: boolean; item: BaseballTicketBayAlertLogItem | null }>(
|
||||||
|
`/baseball-ticket-bay/logs/${encodeURIComponent(logId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBaseballTicketBayAlert(payload: BaseballTicketBayAlertMutation) {
|
||||||
|
const response = await request<BaseballTicketBayAlertResponse>('/baseball-ticket-bay/alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBaseballTicketBayAlert(alertId: string, payload: Partial<BaseballTicketBayAlertMutation>) {
|
||||||
|
const response = await request<BaseballTicketBayAlertResponse>(`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBaseballTicketBayAlert(alertId: string) {
|
||||||
|
await request<{ ok: boolean; item: BaseballTicketBayAlertItem | null }>(
|
||||||
|
`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBaseballTicketBayAlert(alertId: string) {
|
||||||
|
return request<BaseballTicketBayRunResponse>(`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
listReaderLibraryArticles,
|
listReaderLibraryArticles,
|
||||||
searchReaderNews,
|
searchReaderNews,
|
||||||
saveReaderLibraryArticle,
|
saveReaderLibraryArticle,
|
||||||
|
setEReaderShareTokenOverride,
|
||||||
type EReaderLibraryArticle,
|
type EReaderLibraryArticle,
|
||||||
type EReaderNewsArticle,
|
type EReaderNewsArticle,
|
||||||
type EReaderNewsSearchParams,
|
type EReaderNewsSearchParams,
|
||||||
@@ -33,6 +34,7 @@ import './EReaderAppView.css';
|
|||||||
type EReaderAppViewProps = {
|
type EReaderAppViewProps = {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
launchContext?: 'direct' | 'embedded';
|
launchContext?: 'direct' | 'embedded';
|
||||||
|
shareToken?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReaderTheme = 'mist' | 'ocean' | 'night' | 'sepia' | 'forest' | 'graphite' | 'rose' | 'dawn';
|
type ReaderTheme = 'mist' | 'ocean' | 'night' | 'sepia' | 'forest' | 'graphite' | 'rose' | 'dawn';
|
||||||
@@ -994,6 +996,18 @@ function getReaderLaunchUrl() {
|
|||||||
return new URL(getReaderLaunchPath(), window.location.origin).toString();
|
return new URL(getReaderLaunchPath(), window.location.origin).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeShareToken(value: string | null | undefined) {
|
||||||
|
return value?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readShareTokenFromUrl() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
|
||||||
|
}
|
||||||
|
|
||||||
function getInstallGuideMessage() {
|
function getInstallGuideMessage() {
|
||||||
if (typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
if (typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||||
return 'Safari 공유 메뉴에서 홈 화면에 추가를 선택하면 E-Reader 전용 아이콘으로 저장됩니다.';
|
return 'Safari 공유 메뉴에서 홈 화면에 추가를 선택하면 E-Reader 전용 아이콘으로 저장됩니다.';
|
||||||
@@ -1002,7 +1016,10 @@ function getInstallGuideMessage() {
|
|||||||
return '브라우저 메뉴의 홈 화면에 추가 또는 앱 설치를 사용하면 E-Reader를 바로 열 수 있습니다.';
|
return '브라우저 메뉴의 홈 화면에 추가 또는 앱 설치를 사용하면 E-Reader를 바로 열 수 있습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createEReaderManifestObjectUrl(registeredToken: string) {
|
async function createEReaderManifestObjectUrl(options?: {
|
||||||
|
registeredToken?: string | null;
|
||||||
|
shareToken?: string | null;
|
||||||
|
}) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -1020,8 +1037,17 @@ async function createEReaderManifestObjectUrl(registeredToken: string) {
|
|||||||
typeof manifest.start_url === 'string' && manifest.start_url.trim() ? manifest.start_url : getReaderLaunchPath(),
|
typeof manifest.start_url === 'string' && manifest.start_url.trim() ? manifest.start_url : getReaderLaunchPath(),
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
|
const registeredToken = options?.registeredToken?.trim() ?? '';
|
||||||
|
const shareToken = normalizeShareToken(options?.shareToken);
|
||||||
|
|
||||||
|
if (registeredToken) {
|
||||||
startUrl.searchParams.set('registeredAccessToken', registeredToken);
|
startUrl.searchParams.set('registeredAccessToken', registeredToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareToken) {
|
||||||
|
startUrl.searchParams.set('shareToken', shareToken);
|
||||||
|
}
|
||||||
|
|
||||||
manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
|
manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
|
||||||
|
|
||||||
return window.URL.createObjectURL(
|
return window.URL.createObjectURL(
|
||||||
@@ -1297,7 +1323,7 @@ function sleep(ms: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppViewProps) {
|
export function EReaderAppView({ onBack, launchContext = 'direct', shareToken }: EReaderAppViewProps) {
|
||||||
const storedSettings = readStoredSettings();
|
const storedSettings = readStoredSettings();
|
||||||
const initialStoredArticles = readStoredArticles();
|
const initialStoredArticles = readStoredArticles();
|
||||||
const initialReadHistory = readStoredReadHistory();
|
const initialReadHistory = readStoredReadHistory();
|
||||||
@@ -1442,6 +1468,7 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
|
|||||||
const safeDisplayPageIndex = clampIndex(displayPageIndex, pageCount);
|
const safeDisplayPageIndex = clampIndex(displayPageIndex, pageCount);
|
||||||
const hiddenPageSlot = visiblePageSlot === 'primary' ? 'secondary' : 'primary';
|
const hiddenPageSlot = visiblePageSlot === 'primary' ? 'secondary' : 'primary';
|
||||||
const isEmbeddedLaunch = launchContext === 'embedded';
|
const isEmbeddedLaunch = launchContext === 'embedded';
|
||||||
|
const effectiveShareToken = normalizeShareToken(shareToken) || readShareTokenFromUrl();
|
||||||
const hasViewHistory = viewHistory.length > 0;
|
const hasViewHistory = viewHistory.length > 0;
|
||||||
const canExitToParentApp = isEmbeddedLaunch && installState !== 'standalone';
|
const canExitToParentApp = isEmbeddedLaunch && installState !== 'standalone';
|
||||||
const isStandaloneHome = installState === 'standalone' && currentView === 'home';
|
const isStandaloneHome = installState === 'standalone' && currentView === 'home';
|
||||||
@@ -1610,6 +1637,14 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
|
|||||||
};
|
};
|
||||||
}, [librarySyncRequestId]);
|
}, [librarySyncRequestId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEReaderShareTokenOverride(effectiveShareToken);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setEReaderShareTokenOverride('');
|
||||||
|
};
|
||||||
|
}, [effectiveShareToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1621,10 +1656,13 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
|
|||||||
const previewRuntimeToken = isPreviewRuntime() ? getRegisteredAccessToken() : '';
|
const previewRuntimeToken = isPreviewRuntime() ? getRegisteredAccessToken() : '';
|
||||||
const restoreManifest = swapManifestForEReader();
|
const restoreManifest = swapManifestForEReader();
|
||||||
|
|
||||||
if (previewRuntimeToken) {
|
if (previewRuntimeToken || effectiveShareToken) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const manifestObjectUrl = await createEReaderManifestObjectUrl(previewRuntimeToken);
|
const manifestObjectUrl = await createEReaderManifestObjectUrl({
|
||||||
|
registeredToken: previewRuntimeToken,
|
||||||
|
shareToken: effectiveShareToken,
|
||||||
|
});
|
||||||
|
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
window.URL.revokeObjectURL(manifestObjectUrl);
|
window.URL.revokeObjectURL(manifestObjectUrl);
|
||||||
@@ -1648,7 +1686,7 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
|
|||||||
restoreManifest();
|
restoreManifest();
|
||||||
document.body.classList.remove(E_READER_IMMERSIVE_BODY_CLASS);
|
document.body.classList.remove(E_READER_IMMERSIVE_BODY_CLASS);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [effectiveShareToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const WORK_SERVER_FALLBACK_BASE_URL =
|
|||||||
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
|
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
|
||||||
? resolveWorkServerFallbackBaseUrl()
|
? resolveWorkServerFallbackBaseUrl()
|
||||||
: null;
|
: null;
|
||||||
|
let eReaderShareTokenOverride = '';
|
||||||
|
|
||||||
class EReaderApiError extends Error {
|
class EReaderApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -103,6 +104,26 @@ class EReaderApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeShareToken(value: string | null | undefined) {
|
||||||
|
return value?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readShareTokenFromUrl() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReaderShareToken() {
|
||||||
|
return normalizeShareToken(eReaderShareTokenOverride) || readShareTokenFromUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEReaderShareTokenOverride(shareToken: string | null | undefined) {
|
||||||
|
eReaderShareTokenOverride = normalizeShareToken(shareToken);
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonPayload<T>(text: string, fallbackMessage: string) {
|
function parseJsonPayload<T>(text: string, fallbackMessage: string) {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
throw new EReaderApiError(fallbackMessage, 502);
|
throw new EReaderApiError(fallbackMessage, 502);
|
||||||
@@ -206,11 +227,16 @@ export async function extractReaderArticle(url: string) {
|
|||||||
function buildReaderHeaders() {
|
function buildReaderHeaders() {
|
||||||
const headers = appendClientIdHeader();
|
const headers = appendClientIdHeader();
|
||||||
const token = getRegisteredAccessToken();
|
const token = getRegisteredAccessToken();
|
||||||
|
const shareToken = resolveReaderShareToken();
|
||||||
|
|
||||||
if (token && !headers.has('X-Access-Token')) {
|
if (token && !headers.has('X-Access-Token')) {
|
||||||
headers.set('X-Access-Token', token);
|
headers.set('X-Access-Token', token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shareToken && !headers.has('X-Chat-Share-Token')) {
|
||||||
|
headers.set('X-Chat-Share-Token', shareToken);
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user