From eeee7885306dee1029d9b0f4b42ef8f313da989b Mon Sep 17 00:00:00 2001 From: rayd1o Date: Mon, 27 Apr 2026 23:22:04 +0800 Subject: [PATCH] release: bump version to 0.41.2 --- VERSION | 2 +- aiprovider/Dockerfile | 8 +- backend/Dockerfile | 8 +- docker-compose.local-model.yml | 3 + docker-compose.simple.yml | 3 + docker-compose.yml | 3 + docs/CHANGELOG.md | 16 +++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/js/cables.js | 46 +++++-- frontend/public/earth/js/main.js | 6 + planet.sh | 186 +++++++++++++++++++++++++++-- pyproject.toml | 2 +- uv.lock | 2 +- 14 files changed, 265 insertions(+), 25 deletions(-) diff --git a/VERSION b/VERSION index 9ed317fb..6599454d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.1 +0.41.2 diff --git a/aiprovider/Dockerfile b/aiprovider/Dockerfile index c598f6be..4c99f3e7 100644 --- a/aiprovider/Dockerfile +++ b/aiprovider/Dockerfile @@ -1,6 +1,10 @@ -FROM python:3.14-slim +ARG PYTHON_IMAGE=python:3.14-slim +ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +FROM ${UV_IMAGE} AS uv +FROM ${PYTHON_IMAGE} + +COPY --from=uv /uv /uvx /bin/ WORKDIR /app diff --git a/backend/Dockerfile b/backend/Dockerfile index 88bd01a6..3a190666 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,10 @@ -FROM python:3.14-slim +ARG PYTHON_IMAGE=python:3.14-slim +ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +FROM ${UV_IMAGE} AS uv +FROM ${PYTHON_IMAGE} + +COPY --from=uv /uv /uvx /bin/ WORKDIR /app diff --git a/docker-compose.local-model.yml b/docker-compose.local-model.yml index f7720f0b..0927245a 100644 --- a/docker-compose.local-model.yml +++ b/docker-compose.local-model.yml @@ -18,6 +18,9 @@ services: build: context: . dockerfile: aiprovider/Dockerfile + args: + PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest} container_name: planet_aiprovider ports: - "8010:8010" diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 04d9272a..55c91b76 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -5,6 +5,9 @@ services: build: context: . dockerfile: aiprovider/Dockerfile + args: + PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest} container_name: planet_aiprovider ports: - "8010:8010" diff --git a/docker-compose.yml b/docker-compose.yml index 329ecff7..ec0b4d1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: build: context: . dockerfile: aiprovider/Dockerfile + args: + PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest} env_file: - ./aiprovider/.env container_name: planet_aiprovider diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 314c0129..cefdea67 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.41.2] — 2026-04-27 + +### 🔧 Improvements +- `planet.sh` 启动链路新增 verbose 滚动输出窗口,并在后端端口占用时打印目标地址和监听进程诊断 +- Docker 构建支持通过 build args 覆盖 Python 与 uv 镜像,方便 Docker Hub 不稳定时切换镜像源 + +### 🐛 Fixes +- Earth 海缆登陆点改为基于相机射线与地球遮挡判断可见性,修复旋转后 pin 可见性滞后一帧的问题 + +### 🔧 Improvements +- `docker-compose*.yml` 为 AI Provider 构建传入 `PYTHON_IMAGE` / `UV_IMAGE` 参数,默认仍使用官方镜像 +- 后端启动失败遇到 `Address already in use` 时输出 `lsof`、`ss` 与 PID 命令行信息 +- verbose 模式下 AI Provider build、后端与前端启动日志会在 spinner 下方保留最新 5 行滚动展示 + +--- + ## [0.41.1] — 2026-04-27 ### 🐛 Fixes diff --git a/docs/version-history.md b/docs/version-history.md index 1d4a91eb..a94656f6 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.41.1` +- `dev` 当前开发分支历史推导到:`0.41.2` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.41.2` | improvement | `dev` | `pending` | 启动脚本新增 verbose 滚动日志与端口占用诊断,Docker 构建支持镜像源覆盖,并修复 Earth 登陆点遮挡判断 | | `0.41.1` | improvement | `dev` | `pending` | 修复新闻直播持久化失效、pin 边缘遮挡;图标抽取为 SVG 并建立规范 | | `0.41.0` | feature | `dev` | `pending` | Earth 图层顺序拆分、基座海陆色块、国界交互、高清材质/云图/地形层级与样式文档落地 | | `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 | diff --git a/frontend/package.json b/frontend/package.json index 75196480..468f80ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.41.1", + "version": "0.41.2", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index 56b71209..7b0d1836 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -21,7 +21,11 @@ let cableIdMap = new Map(); let cableStates = new Map(); let cablesVisible = true; let landingPointTexture = null; +const _lpEarthWorldPos = new THREE.Vector3(); const _lpWorldPos = new THREE.Vector3(); +const _lpCameraRel = new THREE.Vector3(); +const _lpPointRel = new THREE.Vector3(); +const _lpCameraToPoint = new THREE.Vector3(); function createLandingPointTexture() { const size = CABLE_CONFIG.landingPoint.textureSize; @@ -460,7 +464,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) { color: CABLE_CONFIG.landingPoint.color, transparent: true, opacity: CABLE_CONFIG.landingPoint.opacity, - depthTest: true, + depthTest: false, depthWrite: false, }), ); @@ -597,16 +601,40 @@ export function getAllLandingPoints() { return landingPoints; } -// Hide pins within ~3° of the limb (normalised dot < 0.05) to prevent the -// "half-clipped-into-globe" look caused by depthTest clipping the billboard -// against closer earth-surface geometry near the horizon. -const _FACING_DOT_MIN_SQ = 0.05 * 0.05; - function isFacingCamera(lp, camera) { lp.getWorldPosition(_lpWorldPos); - const d = _lpWorldPos.dot(camera.position); - if (d <= 0) return false; - return d * d > _FACING_DOT_MIN_SQ * _lpWorldPos.lengthSq() * camera.position.lengthSq(); + if (lp.parent) { + lp.parent.getWorldPosition(_lpEarthWorldPos); + } else { + _lpEarthWorldPos.set(0, 0, 0); + } + + _lpCameraRel.copy(camera.position).sub(_lpEarthWorldPos); + _lpPointRel.copy(_lpWorldPos).sub(_lpEarthWorldPos); + _lpCameraToPoint.subVectors(_lpPointRel, _lpCameraRel); + + const distanceSq = _lpCameraToPoint.lengthSq(); + if (distanceSq <= 0) return true; + + const distance = Math.sqrt(distanceSq); + _lpCameraToPoint.multiplyScalar(1 / distance); + + // The pin sprite is rendered without depth testing so its full shape does + // not get sliced by the globe. Instead, hide it when the camera-to-anchor + // segment is occluded by a slightly inflated globe, matching the behavior of + // the BGP and compute-center markers near the limb. + const occlusionRadius = + CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45; + const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint); + const cameraRadiusSq = _lpCameraRel.lengthSq(); + const discriminant = + cameraProjection * cameraProjection - + (cameraRadiusSq - occlusionRadius * occlusionRadius); + + if (discriminant < 0) return true; + + const nearestIntersection = -cameraProjection - Math.sqrt(discriminant); + return nearestIntersection <= 0 || nearestIntersection >= distance; } export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) { diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index d578e6d4..ae5c6584 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -3399,6 +3399,12 @@ function animate() { } } + // Force matrixWorld to be current before any isFacingCamera checks below. + // Without this, getWorldPosition() on LP sprites reads the previous frame's + // transform (earth rotation is updated above but scene.updateMatrixWorld() + // only runs inside renderer.render(), which hasn't been called yet). + earth?.updateMatrixWorld(true); + applyCableVisualState(); const activeCruiseMarker = isCruiseModeActive() && isCruisePresentationPinned() diff --git a/planet.sh b/planet.sh index c0d39533..2e9f5d10 100755 --- a/planet.sh +++ b/planet.sh @@ -35,6 +35,10 @@ WAIT_SPINNER_TICKS_PER_SECOND=8 WAIT_SPINNER_DETAIL="" WAIT_SPINNER_MESSAGE="" WAIT_SESSION_ACTIVE=0 +VERBOSE=0 +WAIT_VERBOSE_LOG_FILE="" +WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}" +WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}" HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" @@ -186,7 +190,36 @@ render_wait_spinner() { local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" local detail="${WAIT_SPINNER_DETAIL:- }" - printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K\033[1A\r" "$frame" "$message" "$detail" >&2 + local verbose_line="" + local verbose_lines=() + local line_count=0 + local cursor_up=1 + + if [ "$VERBOSE" -eq 1 ] && [ -n "$WAIT_VERBOSE_LOG_FILE" ] && [ -f "$WAIT_VERBOSE_LOG_FILE" ]; then + while IFS= read -r verbose_line; do + verbose_line="$(sanitize_wait_detail "$verbose_line")" + [ -n "$verbose_line" ] || continue + verbose_lines+=("$verbose_line") + done < <(tail -n "$WAIT_VERBOSE_TAIL_LINE_COUNT" "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null) + + while [ "${#verbose_lines[@]}" -gt "$WAIT_VERBOSE_LINE_COUNT" ]; do + verbose_lines=("${verbose_lines[@]:1}") + done + fi + + printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2 + + if [ "$VERBOSE" -eq 1 ]; then + line_count=1 + while [ "$line_count" -le "$WAIT_VERBOSE_LINE_COUNT" ]; do + verbose_line="${verbose_lines[$line_count]:- }" + printf "\n${DIM} %s${NC}\033[K" "$verbose_line" >&2 + line_count=$((line_count + 1)) + done + cursor_up=$((WAIT_VERBOSE_LINE_COUNT + 1)) + fi + + printf "\033[%sA\r" "$cursor_up" >&2 } animate_wait_spinner() { @@ -208,7 +241,29 @@ animate_wait_spinner() { } clear_wait_spinner() { - printf "\r\033[K\n\033[K\033[1A\r" >&2 + local line_count=0 + local clear_verbose_lines=0 + + if [ "$VERBOSE" -eq 1 ] && { [ "$WAIT_SESSION_ACTIVE" -eq 1 ] || [ -n "$WAIT_VERBOSE_LOG_FILE" ]; }; then + clear_verbose_lines=1 + fi + + printf "\r\033[K" >&2 + line_count=0 + while [ "$line_count" -lt 1 ]; do + printf "\n\033[K" >&2 + line_count=$((line_count + 1)) + done + + if [ "$clear_verbose_lines" -eq 1 ]; then + line_count=0 + while [ "$line_count" -lt "$WAIT_VERBOSE_LINE_COUNT" ]; do + printf "\n\033[K" >&2 + line_count=$((line_count + 1)) + done + fi + + printf "\033[%sA\r" "$((clear_verbose_lines == 1 ? WAIT_VERBOSE_LINE_COUNT + 1 : 1))" >&2 } start_wait_session() { @@ -247,6 +302,7 @@ close_wait_session_context() { reset_wait_spinner_state() { WAIT_SPINNER_DETAIL="" WAIT_SPINNER_MESSAGE="" + WAIT_VERBOSE_LOG_FILE="" } sanitize_wait_detail() { @@ -274,12 +330,19 @@ set_wait_detail() { run_command_with_spinner() { local message="$1" shift + local verbose_log_file="" if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then set_wait_detail "$message" fi - "$@" & + if [ "$VERBOSE" -eq 1 ]; then + verbose_log_file="$(mktemp /tmp/planet_verbose.XXXXXX.log)" + WAIT_VERBOSE_LOG_FILE="$verbose_log_file" + "$@" > "$verbose_log_file" 2>&1 & + else + "$@" & + fi local command_pid=$! local exit_code=0 @@ -294,6 +357,9 @@ run_command_with_spinner() { done wait "$command_pid" || exit_code=$? + if [ "$VERBOSE" -eq 1 ] && [ -n "$verbose_log_file" ]; then + WAIT_VERBOSE_LOG_FILE="$verbose_log_file" + fi return "$exit_code" } @@ -619,7 +685,7 @@ ensure_ai_provider_image_current() { build_ai_provider_image_with_fallback() { if compose_available; then set_wait_detail "使用 docker compose 构建 AI Provider 镜像" - if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then + if run_ai_provider_build_command "docker compose"; then return 0 fi if compose_v1_available; then @@ -632,7 +698,7 @@ build_ai_provider_image_with_fallback() { if compose_v1_available; then set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像" - if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker-compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then + if run_ai_provider_build_command "docker-compose"; then return 0 fi return 1 @@ -641,6 +707,17 @@ build_ai_provider_image_with_fallback() { report_missing_compose } +run_ai_provider_build_command() { + local compose_command="$1" + + if [ "$VERBOSE" -eq 1 ]; then + run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider 2>&1 | tee \"$AI_PROVIDER_BUILD_LOG_FILE\"" + return $? + fi + + run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1" +} + install_uv_if_needed() { if command -v uv >/dev/null 2>&1; then return 0 @@ -909,6 +986,9 @@ wait_for_frontend_ready() { else set_wait_detail "启动 Vite 开发服务器" fi + if [ "$VERBOSE" -eq 1 ]; then + WAIT_VERBOSE_LOG_FILE="$log_file" + fi while [ "$tick" -lt "$max_ticks" ]; do WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) @@ -1045,17 +1125,25 @@ start_backend_with_retry() { : > /tmp/planet_backend.log PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! + if [ "$VERBOSE" -eq 1 ]; then + WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log" + fi if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then return 0 fi kill "$BACKEND_PID" 2>/dev/null || true + if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then + report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" + return 1 + fi animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done clear_wait_spinner + report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" || true return 1 } @@ -1387,6 +1475,66 @@ frontend_log_indicates_port_conflict() { grep -q "Port .* is already in use" "$log_file" 2>/dev/null } +backend_log_indicates_port_conflict() { + local log_file="$1" + + [ -f "$log_file" ] || return 1 + grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null +} + +print_port_listener_details() { + local port="$1" + local found=0 + local pid="" + local command_line="" + local lsof_output="" + local ss_output="" + local line="" + + if command -v lsof >/dev/null 2>&1; then + lsof_output="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [ -n "$lsof_output" ]; then + printf "%s\n" "$lsof_output" | awk '{print " " $0}' + found=1 + fi + fi + + if command -v ss >/dev/null 2>&1; then + ss_output="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null || true)" + while IFS= read -r line; do + [ -n "$line" ] || continue + printf "${DIM} ss: %s${NC}\n" "$line" + found=1 + done </dev/null | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" + [ -n "$command_line" ] || command_line="未知命令" + printf "${DIM} pid %s: %s${NC}\n" "$pid" "$command_line" + found=1 + done + + if [ "$found" -eq 0 ]; then + log_note "未能在当前环境内定位端口 ${port} 的监听进程,可能被宿主机或外部网络命名空间占用。" + fi +} + +report_backend_port_conflict_if_needed() { + local backend_port="$1" + local log_file="$2" + + if backend_log_indicates_port_conflict "$log_file" || ! can_bind_port "$backend_port"; then + log_error "后端地址已被占用: 0.0.0.0:${backend_port} / 127.0.0.1:${backend_port} / [::1]:${backend_port}" + print_port_listener_details "$backend_port" + return 0 + fi + + return 1 +} + # Frontend lifecycle helpers cleanup_frontend_processes() { local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}" @@ -1527,6 +1675,10 @@ parse_service_args() { FRONTEND_LAN_ENABLED=1 shift 1 ;; + -v|--verbose) + VERBOSE=1 + shift 1 + ;; *) log_error "未知参数: $1" exit 1 @@ -1831,6 +1983,25 @@ log() { esac } +parse_global_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + -v|--verbose) + VERBOSE=1 + shift 1 + ;; + *) + break + ;; + esac + done + + GLOBAL_ARG_REMAINDER=("$@") +} + +parse_global_args "$@" +set -- "${GLOBAL_ARG_REMAINDER[@]}" + case "$1" in start) shift @@ -1854,9 +2025,10 @@ case "$1" in ;; *) log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}" - log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a --allow-lan" + log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出" + log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a --allow-lan --verbose" log_note "stop 停止服务" - log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan" + log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan --verbose" log_note "createuser 交互创建用户" log_note "health 检查健康状态" log_note "log 查看日志" diff --git a/pyproject.toml b/pyproject.toml index 7b31e0f9..0e0f6bae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.41.1" +version = "0.41.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index dd44dbdf..88aa8a8c 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.41.1" +version = "0.41.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" },