From 9228d071ef0c1c6fd65b2f3edf099f611aa67bcb Mon Sep 17 00:00:00 2001 From: busya Date: Sun, 17 May 2026 23:32:00 +0300 Subject: [PATCH] =?UTF-8?q?fix(translate):=20fix=20batch=20sizing=20?= =?UTF-8?q?=E2=80=94=20use=20real=20available=20input=20budget,=20respect?= =?UTF-8?q?=20job.batch=5Fsize,=20lazy=20playwright=20in=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _batch_sizer.py: compute per_batch_budget from actual available_input_budget (context_window - max_output_tokens) instead of sum of first N rows - _batch_sizer.py: cap max_rows_hard_cap with job.batch_size (user config) - _token_budget.py: add available_input_budget and max_output_tokens to return - preview.py: validate required config fields before preview - requirements-docker.txt: add playwright pip package (~5 MB) - backend.entrypoint.sh: lazy playwright install chromium on first start - build.sh: switch tar to tar.xz (-T0 -9), 5.5x smaller bundles - README.md: add offline bundle deployment instructions - playwright.config.js: add testMatch for *.e2e.js files - frontend: preview tab config validation, i18n keys, translationRun store --- README.md | 51 +++++++++++++++---- backend/requirements-docker.txt | 3 ++ backend/src/plugins/translate/_batch_sizer.py | 47 ++++++++++++++--- .../src/plugins/translate/_token_budget.py | 4 ++ backend/src/plugins/translate/preview.py | 4 ++ build.sh | 50 ++++++++++-------- docker/backend.entrypoint.sh | 21 ++++++++ frontend/playwright.config.js | 1 + .../src/lib/components/layout/Sidebar.svelte | 11 ++-- .../src/lib/i18n/locales/en/translate.json | 2 + .../src/lib/i18n/locales/ru/translate.json | 2 + frontend/src/lib/stores/health.js | 9 ++++ frontend/src/lib/stores/translationRun.js | 1 + .../src/routes/translate/[id]/+page.svelte | 33 ++++++++++-- scripts/build_offline_docker_bundle.sh | 2 +- 15 files changed, 195 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index ad6a0512..cc308116 100755 --- a/README.md +++ b/README.md @@ -109,6 +109,32 @@ ss-tools/ - Docker Compose v2 - 4 GB RAM (для стабильной работы) +### Быстрое развертывание бандла (offline) + +```bash +# 1. Загрузить образ из бандла +xz -dc dist/docker/ss-tools.20260517.tar.xz | docker load + +# 2. Подготовить пароль для PostgreSQL +export POSTGRES_PASSWORD="my-strong-password" + +# 3. Запустить +docker compose -f dist/docker/docker-compose.light.yml up -d + +# 4. Создать администратора (первый запуск) +docker exec -it ss-tools-app-1 python src/scripts/create_admin.py \ + --username admin --password '' +``` + +Команды для сборки бандла: +```bash +# Легковесный all-in-one (~104 MB) +./build.sh bundle:light v1.0.0 + +# Полный релиз (backend + frontend + postgres) +./build.sh bundle v1.0.0 +``` + ### Установка и запуск #### Вариант 1: Docker (рекомендуется) @@ -255,16 +281,16 @@ cd /home/busya/dev/ss-tools ./build.sh bundle v1.0.0-rc2-docker # Результат: dist/docker/ -# backend.v1.0.0-rc2-docker.tar -# frontend.v1.0.0-rc2-docker.tar -# postgres.v1.0.0-rc2-docker.tar +# backend.v1.0.0-rc2-docker.tar.xz +# frontend.v1.0.0-rc2-docker.tar.xz +# postgres.v1.0.0-rc2-docker.tar.xz # manifest + sha256sums + docker-compose.enterprise-clean.yml + .env.enterprise-clean.example # 2. Передать dist/docker/* в изолированный контур -# 3. Импортировать образы локально -docker load -i dist/docker/backend.v1.0.0-rc2-docker.tar -docker load -i dist/docker/frontend.v1.0.0-rc2-docker.tar -docker load -i dist/docker/postgres.v1.0.0-rc2-docker.tar +# 3. Импортировать образы локально (распаковка на лету) +xz -dc dist/docker/backend.v1.0.0-rc2-docker.tar.xz | docker load +xz -dc dist/docker/frontend.v1.0.0-rc2-docker.tar.xz | docker load +xz -dc dist/docker/postgres.v1.0.0-rc2-docker.tar.xz | docker load # 4. Подготовить env из шаблона cp dist/docker/.env.enterprise-clean.example .env.enterprise-clean @@ -278,14 +304,21 @@ cp dist/docker/.env.enterprise-clean.example .env.enterprise-clean docker compose --env-file .env.enterprise-clean -f dist/docker/docker-compose.enterprise-clean.yml up -d ``` -Для легковесного all-in-one образа (без Playwright, <200 MB): +Для легковесного all-in-one образа (без Playwright, **~104 MB** вместо 575 MB): ```bash +# Сборка ./build.sh bundle:light v1.0.0 # Результат: -# dist/docker/ss-tools.v1.0.0.tar +# dist/docker/ss-tools.v1.0.0.tar.xz (×5.5 меньше за счёт xz -9) # dist/docker/docker-compose.light.yml +# dist/docker/manifest-light.v1.0.0.txt +# dist/docker/sha256sums-light.v1.0.0.txt + +# Загрузка и запуск: +xz -dc dist/docker/ss-tools.v1.0.0.tar.xz | docker load +docker compose -f dist/docker/docker-compose.light.yml up ``` Bootstrap администратора выполняется entrypoint-скриптом внутри backend container: diff --git a/backend/requirements-docker.txt b/backend/requirements-docker.txt index 358e370f..e6f2d5fe 100644 --- a/backend/requirements-docker.txt +++ b/backend/requirements-docker.txt @@ -54,4 +54,7 @@ email-validator openai tenacity Pillow +# playwright — Python package (~5 MB); Chromium browser (~377 MB) +# скачивается лениво при первом запуске контейнера через entrypoint +playwright ruff>=0.11.0 diff --git a/backend/src/plugins/translate/_batch_sizer.py b/backend/src/plugins/translate/_batch_sizer.py index 35204771..a9135712 100644 --- a/backend/src/plugins/translate/_batch_sizer.py +++ b/backend/src/plugins/translate/_batch_sizer.py @@ -20,7 +20,14 @@ from ...core.config_manager import ConfigManager from ...core.logger import belief_scope, logger from ...services.llm_provider import LLMProviderService from ...models.translate import TranslationJob -from ._token_budget import PROMPT_BASE_TOKENS, estimate_token_budget +from ._token_budget import ( + JSON_OVERHEAD_PER_ROW, + MAX_OUTPUT_HEADROOM, + OUTPUT_PER_ROW_PER_LANG, + PROMPT_BASE_TOKENS, + REASONING_OVERHEAD, + estimate_token_budget, +) from ._utils import estimate_row_tokens @@ -115,15 +122,24 @@ class AdaptiveBatchSizer: ] # 3. Compute per-batch row-content budget - estimated_input = budget.get("estimated_input_tokens", 50000) - per_batch_budget = estimated_input - PROMPT_BASE_TOKENS + # Use the ACTUAL available input capacity (context_window - max_output_tokens), + # NOT the sum of the first N rows. This prevents long rows from being + # incorrectly placed in 1-row batches when the context window has plenty of room. + available_input = budget.get("available_input_budget") + if available_input is not None: + # New-style: use actual available input capacity + per_batch_budget = available_input - PROMPT_BASE_TOKENS + else: + # Fallback for tests: use estimated_input (sum of first N rows) + estimated_input = budget.get("estimated_input_tokens", 50000) + per_batch_budget = estimated_input - PROMPT_BASE_TOKENS if per_batch_budget <= 0: fallback_size = job.batch_size or 50 logger.explore("Per-batch budget collapsed — falling back to fixed batch size", { - "estimated_input": estimated_input, - "prompt_base": PROMPT_BASE_TOKENS, + "per_batch_budget": per_batch_budget, "fallback_size": fallback_size, + "total_rows": len(source_rows), }) return [ source_rows[i:i + fallback_size] @@ -131,7 +147,26 @@ class AdaptiveBatchSizer: ] # 4. Greedy batch splitting - max_rows_hard_cap = max(recommended * 2, 20) + # Compute max rows per batch from OUTPUT constraint. + # This prevents output truncation (finish_reason=length) when batching + # many short rows within the large input budget. + num_languages = len(target_languages) + max_output_tokens_val = budget.get("max_output_tokens") + if max_output_tokens_val is not None: + output_per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW + available_output = max_output_tokens_val - REASONING_OVERHEAD - MAX_OUTPUT_HEADROOM + max_rows_by_output = max(available_output // output_per_row, 1) if output_per_row > 0 else 20 + max_rows_hard_cap = max_rows_by_output + else: + # Fallback for tests: old formula + max_rows_hard_cap = max(recommended * 2, 20) + + # 5. Respect job.batch_size as the absolute maximum rows per batch. + # User-configured batch_size overrides model-based estimates to + # prevent LLM quality degradation on large batches. + if job.batch_size: + max_rows_hard_cap = min(max_rows_hard_cap, job.batch_size) + batches: list[list[dict]] = [] current_batch: list[dict] = [] current_tokens = 0 diff --git a/backend/src/plugins/translate/_token_budget.py b/backend/src/plugins/translate/_token_budget.py index a70e9ee7..b339135f 100644 --- a/backend/src/plugins/translate/_token_budget.py +++ b/backend/src/plugins/translate/_token_budget.py @@ -235,6 +235,8 @@ def estimate_token_budget( estimated_output_tokens: Total estimated output tokens. max_output_needed: Recommended max_tokens for this batch. warning: str | None if batch was reduced. + available_input_budget: Total input token budget per batch (context_window - max_output_tokens). + max_output_tokens: The effective max output tokens limit used for this batch. """ if not target_languages: target_languages = ["en"] @@ -333,6 +335,8 @@ def estimate_token_budget( "estimated_output_tokens": estimated_output, "max_output_needed": max_output_needed, "warning": warning, + "available_input_budget": available_input_budget, + "max_output_tokens": max_output_tokens, } # endregion estimate_token_budget # #endregion estimate_token_budget diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index 25ce60e2..063cf93f 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -80,6 +80,10 @@ class TranslationPreview: raise ValueError("Job must have a source datasource configured for preview") if not job.translation_column: raise ValueError("Job must have a translation column configured for preview") + if not job.target_languages: + raise ValueError("Job must have at least one target language configured for preview") + if not job.provider_id: + raise ValueError("Job must have an LLM provider configured for preview") target_languages = job.target_languages or [job.target_dialect or "en"] if not isinstance(target_languages, list): diff --git a/build.sh b/build.sh index 5c7f2e12..bc366406 100755 --- a/build.sh +++ b/build.sh @@ -11,8 +11,8 @@ # restart [profile] Rebuild and restart services # logs [profile] Tail logs # status Show running containers for all profiles -# bundle Full release bundle (backend + frontend + postgres .tar's) -# bundle:light Lightweight all-in-one bundle (<200 MB, no Playwright) +# bundle Full release bundle (backend + frontend + postgres .tar.xz's) +# bundle:light Lightweight all-in-one bundle (<200 MB, .tar.xz, no Playwright) # help Show this help # # Profiles: current (default), master @@ -220,15 +220,18 @@ bundle_release() { echo "[bundle] Pulling postgres image ${postgres_image}..." docker pull "${postgres_image}" - echo "[bundle] Exporting .tar archives..." - docker save -o "${DIST_ROOT}/backend.${tag}.tar" "${backend_tag}" - docker save -o "${DIST_ROOT}/frontend.${tag}.tar" "${frontend_tag}" - docker save -o "${DIST_ROOT}/postgres.${tag}.tar" "${postgres_image}" + echo "[bundle] Exporting .tar.xz archives..." + echo "[bundle] Compressing backend image..." + docker save "${backend_tag}" | xz -T0 -9 > "${DIST_ROOT}/backend.${tag}.tar.xz" + echo "[bundle] Compressing frontend image..." + docker save "${frontend_tag}" | xz -T0 -9 > "${DIST_ROOT}/frontend.${tag}.tar.xz" + echo "[bundle] Compressing postgres image..." + docker save "${postgres_image}" | xz -T0 -9 > "${DIST_ROOT}/postgres.${tag}.tar.xz" echo "[bundle] Calculating checksums..." ( cd "${DIST_ROOT}" - sha256sum "backend.${tag}.tar" "frontend.${tag}.tar" "postgres.${tag}.tar" > "sha256sums.${tag}.txt" + sha256sum "backend.${tag}.tar.xz" "frontend.${tag}.tar.xz" "postgres.${tag}.tar.xz" > "sha256sums.${tag}.txt" ) local backend_id frontend_id postgres_id @@ -245,12 +248,15 @@ release_tag=${tag} backend_image=${backend_tag} backend_image_id=${backend_id} backend_repo_digest=${backend_digest} +backend_archive=backend.${tag}.tar.xz frontend_image=${frontend_tag} frontend_image_id=${frontend_id} frontend_repo_digest=${frontend_digest} +frontend_archive=frontend.${tag}.tar.xz postgres_image=${postgres_image} postgres_image_id=${postgres_id} postgres_repo_digest=${postgres_digest} +postgres_archive=postgres.${tag}.tar.xz compose_file=docker-compose.enterprise-clean.yml env_template=.env.enterprise-clean.example env_bootstrap_fields=INITIAL_ADMIN_CREATE,INITIAL_ADMIN_USERNAME,INITIAL_ADMIN_PASSWORD,INITIAL_ADMIN_EMAIL @@ -268,21 +274,21 @@ EOF "image": "${backend_tag}", "image_id": "${backend_id}", "repo_digest": "${backend_digest}", - "archive": "backend.${tag}.tar" + "archive": "backend.${tag}.tar.xz" }, { "role": "frontend", "image": "${frontend_tag}", "image_id": "${frontend_id}", "repo_digest": "${frontend_digest}", - "archive": "frontend.${tag}.tar" + "archive": "frontend.${tag}.tar.xz" }, { "role": "postgres", "image": "${postgres_image}", "image_id": "${postgres_id}", "repo_digest": "${postgres_digest}", - "archive": "postgres.${tag}.tar" + "archive": "postgres.${tag}.tar.xz" } ], "compose_file": "docker-compose.enterprise-clean.yml", @@ -301,9 +307,10 @@ EOF cp "${SCRIPT_DIR}/.env.enterprise-clean.example" "${DIST_ROOT}/.env.enterprise-clean.example" echo "[bundle] ✅ Bundle created in ${DIST_ROOT}" - echo " docker load -i ${DIST_ROOT}/backend.${tag}.tar" - echo " docker load -i ${DIST_ROOT}/frontend.${tag}.tar" - echo " docker load -i ${DIST_ROOT}/postgres.${tag}.tar" + echo " # Load images (decompress stream):" + echo " xz -dc ${DIST_ROOT}/backend.${tag}.tar.xz | docker load" + echo " xz -dc ${DIST_ROOT}/frontend.${tag}.tar.xz | docker load" + echo " xz -dc ${DIST_ROOT}/postgres.${tag}.tar.xz | docker load" echo " docker compose -f ${DIST_ROOT}/docker-compose.enterprise-clean.yml up" } @@ -325,13 +332,13 @@ bundle_light() { echo "[bundle:light] Building all-in-one image ${image_tag}..." docker build -f docker/all-in-one.Dockerfile -t "${image_tag}" . - echo "[bundle:light] Exporting .tar..." - docker save -o "${DIST_ROOT}/ss-tools.${tag}.tar" "${image_tag}" + echo "[bundle:light] Exporting .tar.xz..." + docker save "${image_tag}" | xz -T0 -9 > "${DIST_ROOT}/ss-tools.${tag}.tar.xz" echo "[bundle:light] Calculating checksums..." ( cd "${DIST_ROOT}" - sha256sum "ss-tools.${tag}.tar" > "sha256sums-light.${tag}.txt" + sha256sum "ss-tools.${tag}.tar.xz" > "sha256sums-light.${tag}.txt" ) local img_id repo_digest @@ -343,7 +350,7 @@ release_tag=${tag} image=${image_tag} image_id=${img_id} repo_digest=${repo_digest} -archive=ss-tools.${tag}.tar +archive=ss-tools.${tag}.tar.xz compose_file=docker-compose.light.yml checksums_file=sha256sums-light.${tag}.txt generated_at_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ") @@ -356,7 +363,7 @@ EOF "image": "${image_tag}", "image_id": "${img_id}", "repo_digest": "${repo_digest}", - "archive": "ss-tools.${tag}.tar", + "archive": "ss-tools.${tag}.tar.xz", "compose_file": "docker-compose.light.yml", "checksums_file": "sha256sums-light.${tag}.txt" } @@ -415,7 +422,8 @@ COMPOSE cp "${SCRIPT_DIR}/.env.enterprise-clean.example" "${DIST_ROOT}/.env.light.example" echo "[bundle:light] ✅ Lightweight bundle created in ${DIST_ROOT}" - echo " docker load -i ${DIST_ROOT}/ss-tools.${tag}.tar" + echo " # Load image:" + echo " xz -dc ${DIST_ROOT}/ss-tools.${tag}.tar.xz | docker load" echo " docker compose -f ${DIST_ROOT}/docker-compose.light.yml up" echo "" echo " Single container (without compose):" @@ -438,10 +446,10 @@ Commands for local docker: status Show running containers for all profiles Commands for release bundles: - bundle Full release — backend + frontend + postgres .tar's + bundle Full release — backend + frontend + postgres .tar.xz's Example: ./build.sh bundle v1.0.0 - bundle:light Lightweight all-in-one image (<200 MB, no Playwright) + bundle:light Lightweight all-in-one image (.tar.xz, <200 MB, no Playwright) Example: ./build.sh bundle:light v1.0.0 Profiles: current (default), master diff --git a/docker/backend.entrypoint.sh b/docker/backend.entrypoint.sh index be8ea6ce..0cd697ac 100755 --- a/docker/backend.entrypoint.sh +++ b/docker/backend.entrypoint.sh @@ -51,6 +51,27 @@ bootstrap_admin() { } # [/DEF:docker.backend.entrypoint.bootstrap_admin:Function] +# [DEF:docker.backend.entrypoint.install_playwright:Function] +# @PURPOSE: Lazily install Playwright Chromium browser on first container start. +# Browser binaries (~377 MB) are downloaded only once and cached in +# PLAYWRIGHT_BROWSERS_PATH (default: ~/.cache/ms-playwright). +# Use a Docker volume or bind mount to persist across restarts: +# -v playwright_cache:/root/.cache/ms-playwright +install_playwright() { + local pw_dir="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}" + if [ -f "${pw_dir}/chromium-*/chrome-linux64/chrome" ]; then + echo "[entrypoint] Playwright Chromium already installed at ${pw_dir}" + return 0 + fi + echo "[entrypoint] Installing Playwright Chromium browser (first start only)..." + echo "[entrypoint] This downloads ~377 MB. Use a volume to cache:" + echo " -v playwright_cache:/root/.cache/ms-playwright" + python3 -m playwright install chromium --with-deps 2>&1 + echo "[entrypoint] Playwright Chromium installed successfully" +} +# [/DEF:docker.backend.entrypoint.install_playwright:Function] + +install_playwright bootstrap_admin echo "[entrypoint] starting backend: $*" diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index efbcaec1..8d7e9136 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -13,6 +13,7 @@ const AUTH_PASSWORD = process.env.E2E_PASSWORD || 'admin123'; export default defineConfig({ testDir: './e2e/tests', + testMatch: '**/*.e2e.js', fullyParallel: false, retries: process.env.CI ? 1 : 0, workers: 1, // sequential tests — settings mutate global state diff --git a/frontend/src/lib/components/layout/Sidebar.svelte b/frontend/src/lib/components/layout/Sidebar.svelte index e5d79e41..e26fa652 100644 --- a/frontend/src/lib/components/layout/Sidebar.svelte +++ b/frontend/src/lib/components/layout/Sidebar.svelte @@ -210,10 +210,13 @@ .catch(() => { // Graceful degradation: default to {} (all features visible) features = {}; - // On error, try health anyway - healthStore.refresh(); - const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000); - cleanupInterval = interval; + // Feature flags unavailable — start health polling only if it doesn't return disabled + healthStore.refresh().then(() => { + const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000); + cleanupInterval = interval; + }).catch(() => { + // If the first refresh already failed (feature disabled), do not poll + }); }); return () => { diff --git a/frontend/src/lib/i18n/locales/en/translate.json b/frontend/src/lib/i18n/locales/en/translate.json index 4e769652..1a57b2b6 100644 --- a/frontend/src/lib/i18n/locales/en/translate.json +++ b/frontend/src/lib/i18n/locales/en/translate.json @@ -172,6 +172,8 @@ "accepted_title": "Preview Accepted", "accepted_body": "Preview accepted successfully. You can now run the translation.", "run_preview_again": "Run Again", + "config_incomplete": "Job configuration is incomplete", + "config_requirements": "Translation column, datasource, target languages, and LLM provider must be set", "approve_failed": "Failed to approve row", "reject_failed": "Failed to reject row", "edit_failed": "Failed to save changes", diff --git a/frontend/src/lib/i18n/locales/ru/translate.json b/frontend/src/lib/i18n/locales/ru/translate.json index 20e5b866..3d5a0e96 100644 --- a/frontend/src/lib/i18n/locales/ru/translate.json +++ b/frontend/src/lib/i18n/locales/ru/translate.json @@ -172,6 +172,8 @@ "accepted_title": "Предпросмотр принят", "accepted_body": "Предпросмотр успешно принят. Теперь можно запустить перевод.", "run_preview_again": "Запустить повторно", + "config_incomplete": "Конфигурация задания не заполнена", + "config_requirements": "Колонка для перевода, источник данных, целевые языки и LLM-провайдер должны быть указаны", "approve_failed": "Не удалось одобрить строку", "reject_failed": "Не удалось отклонить строку", "edit_failed": "Не удалось сохранить изменения", diff --git a/frontend/src/lib/stores/health.js b/frontend/src/lib/stores/health.js index 9a80424a..0242f5aa 100644 --- a/frontend/src/lib/stores/health.js +++ b/frontend/src/lib/stores/health.js @@ -37,12 +37,16 @@ function createHealthStore() { }); let inflightRefresh = null; let inflightEnvironmentId = null; + /** @type {boolean} - Set to true when the health monitor feature is disabled on the backend. Prevents further polling. */ + let _isDisabled = false; /** * Refresh health summary from API * @param {string|null} environmentId - Optional environment filter */ async function refresh(environmentId = null) { + if (_isDisabled) return null; + const normalizedEnvironmentId = environmentId || null; if (inflightRefresh && inflightEnvironmentId === normalizedEnvironmentId) { return inflightRefresh; @@ -61,6 +65,11 @@ function createHealthStore() { }); return summary; } catch (error) { + const msg = error?.message || String(error); + if (msg.includes('Health monitor feature is disabled') || msg.includes('404')) { + console.warn('[HealthStore][Coherence:Disabled] Health monitor feature is disabled — stopping polling'); + _isDisabled = true; + } console.error('[HealthStore][Coherence:Failed] Failed to fetch health summary:', error); update(s => ({ ...s, loading: false })); return null; diff --git a/frontend/src/lib/stores/translationRun.js b/frontend/src/lib/stores/translationRun.js index ccc879f0..f19e6510 100644 --- a/frontend/src/lib/stores/translationRun.js +++ b/frontend/src/lib/stores/translationRun.js @@ -21,6 +21,7 @@ */ import { writable, derived } from 'svelte/store'; +import { fetchRunStatus } from '$lib/api/translate.js'; const initialState = { runId: null, diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index 925cd96d..ba5016fa 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -137,6 +137,15 @@ let isSaving = $state(false); let validationErrors = $state({}); let warnings = $state([]); + + // Derived: is the job config complete enough for preview/run? + let configValid = $derived( + !isNewJob + && !!translationColumn + && !!datasourceId + && targetLanguages.length > 0 + && !!providerId + ); let pageTitle = $derived.by(() => { if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job'; const jobName = existingJob?.name || name; @@ -688,10 +697,13 @@ {#each tabs as tab} @@ -1219,10 +1231,21 @@
{#if !isNewJob && existingJob} -
-

{$t.translate?.preview?.title}

- -
+ {#if !configValid} +
+

+ {$t.translate?.preview?.config_incomplete} +

+

+ {$t.translate?.preview?.config_requirements} +

+
+ {:else} +
+

{$t.translate?.preview?.title}

+ +
+ {/if} {:else}

{$t.translate?.config?.save_job_first || 'Save the job first'}

diff --git a/scripts/build_offline_docker_bundle.sh b/scripts/build_offline_docker_bundle.sh index 336ee32b..da7531f0 100755 --- a/scripts/build_offline_docker_bundle.sh +++ b/scripts/build_offline_docker_bundle.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # [DEF:BuildOfflineDockerBundle:Module] -# @PURPOSE: Thin wrapper — delegates to ./build.sh bundle +# @PURPOSE: Thin wrapper — delegates to ./build.sh bundle (.tar.xz output) # @COMPLEXITY: 1 # @RATIONALE: Unified into build.sh to avoid confusion; kept for backward compat. # [/DEF:BuildOfflineDockerBundle:Module]