feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s
Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.
Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).
Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).
Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.
Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).
API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.
DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.
Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
This commit is contained in:
@@ -464,6 +464,8 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
|
||||
_translation_jobs: dict[str, dict] = {}
|
||||
_JOB_TTL_SECONDS = 3600
|
||||
# Completed jobs are kept longer so the history endpoint has depth.
|
||||
_JOB_HISTORY_TTL_SECONDS = 24 * 3600
|
||||
_last_cleanup_ts: float = 0.0
|
||||
|
||||
# Google Cloud API key validity cache — avoids probing the API on every request.
|
||||
@@ -516,10 +518,14 @@ def _cleanup_old_jobs() -> None:
|
||||
expired_job_ids = [
|
||||
job_id
|
||||
for job_id, job in list(_translation_jobs.items())
|
||||
if job.get("status") in ("completed", "failed")
|
||||
if job.get("status") in ("completed", "failed", "cancelled")
|
||||
and (
|
||||
(ts := job.get("completed_at") or job.get("failed_at"))
|
||||
and _job_age_seconds(ts) > _JOB_TTL_SECONDS
|
||||
(ts := job.get("completed_at") or job.get("failed_at") or job.get("cancelled_at"))
|
||||
and _job_age_seconds(ts) > (
|
||||
_JOB_HISTORY_TTL_SECONDS
|
||||
if job.get("status") == "completed"
|
||||
else _JOB_TTL_SECONDS
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
@@ -659,7 +665,7 @@ async def translate_document_v1(
|
||||
- `source_lang`: Source language code (default: auto-detect)
|
||||
- `target_lang`: Target language code (required)
|
||||
- `mode`: Translation mode - "classic" or "llm" (default: classic)
|
||||
- `provider`: Provider override (google, deepl, ollama, openai, openrouter)
|
||||
- `provider`: Provider override (google, ollama, openai, openrouter)
|
||||
- `webhook_url`: URL to receive POST notification when complete
|
||||
- `glossary_id`: Glossary ID for LLM translation (Pro only)
|
||||
- `custom_prompt`: Custom system prompt (Pro only)
|
||||
@@ -1233,6 +1239,10 @@ async def _run_translation_job(
|
||||
await set_job_status_async(job_id, dict(job))
|
||||
tracker.update(10, "Validating file")
|
||||
|
||||
if job.get("status") == "cancelled":
|
||||
logger.info(f"Job {job_id}: cancelled by user before dispatch — aborting")
|
||||
return
|
||||
|
||||
async def _sync_job_to_redis():
|
||||
"""Sync job status to Redis every 0.5s until completed/failed or job removed."""
|
||||
while True:
|
||||
@@ -1311,7 +1321,6 @@ async def _run_translation_job(
|
||||
|
||||
from services.providers.google_provider import GoogleTranslationProvider
|
||||
from services.providers.google_cloud_provider import GoogleCloudTranslationProvider
|
||||
from services.providers.deepl_provider import DeepLTranslationProvider
|
||||
from services.providers.openai_provider import OpenAITranslationProvider
|
||||
from services.providers.deepseek_provider import DeepSeekTranslationProvider
|
||||
from services.providers.minimax_provider import MinimaxTranslationProvider
|
||||
@@ -1398,13 +1407,6 @@ async def _run_translation_job(
|
||||
model=mm_model,
|
||||
timeout=int(os.getenv("MINIMAX_TIMEOUT", "60")),
|
||||
)
|
||||
elif _p == "deepl":
|
||||
deepl_key = _cfg(_admin_cfg.deepl.api_key, "DEEPL_API_KEY")
|
||||
if deepl_key:
|
||||
translation_provider = DeepLTranslationProvider(
|
||||
api_key=deepl_key,
|
||||
timeout=int(os.getenv("DEEPL_TIMEOUT", "30")),
|
||||
)
|
||||
elif _p == "zai":
|
||||
zai_key = _cfg(_admin_cfg.zai.api_key, "ZAI_API_KEY")
|
||||
zai_model = _cfg(_admin_cfg.zai.model, "ZAI_MODEL", "grok-2-1212")
|
||||
@@ -1887,6 +1889,10 @@ async def _run_translation_job(
|
||||
except Exception as wm_err:
|
||||
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
|
||||
|
||||
if job.get("status") == "cancelled":
|
||||
logger.info(f"Job {job_id}: cancelled by user mid-flight — discarding result")
|
||||
return
|
||||
|
||||
tracker.set_completed(str(output_path))
|
||||
# Record translation metric
|
||||
duration = _compute_duration_seconds(job.get("created_at", ""))
|
||||
@@ -2123,6 +2129,134 @@ async def get_translation_status(
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router_v1.get(
|
||||
"/translations",
|
||||
responses={
|
||||
200: {"description": "Translation job history for the current user"},
|
||||
401: {"description": "Authentication required"},
|
||||
},
|
||||
)
|
||||
async def list_translation_history(
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""
|
||||
List the current user's translation jobs, newest first.
|
||||
|
||||
Jobs are kept in memory: completed jobs for 24 hours, other states for 1 hour.
|
||||
Pagination via ``page`` / ``per_page`` (max 50).
|
||||
"""
|
||||
if current_user is None:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "AUTH_REQUIRED", "message": "Authentication required."},
|
||||
)
|
||||
|
||||
user_id = str(getattr(current_user, "id", ""))
|
||||
per_page = max(1, min(per_page, 50))
|
||||
page = max(1, page)
|
||||
|
||||
jobs = [
|
||||
{
|
||||
"id": job.get("id"),
|
||||
"status": job.get("status"),
|
||||
"progress_percent": job.get("progress_percent", 0),
|
||||
"file_name": job.get("file_name"),
|
||||
"source_lang": job.get("source_lang"),
|
||||
"target_lang": job.get("target_lang"),
|
||||
"provider": job.get("provider"),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"failed_at": job.get("failed_at"),
|
||||
"cancelled_at": job.get("cancelled_at"),
|
||||
}
|
||||
for job in _translation_jobs.values()
|
||||
if str(job.get("user_id", "")) == user_id
|
||||
]
|
||||
jobs.sort(key=lambda j: j.get("created_at") or "", reverse=True)
|
||||
|
||||
total = len(jobs)
|
||||
start = (page - 1) * per_page
|
||||
return {
|
||||
"data": jobs[start : start + per_page],
|
||||
"meta": {"total": total, "page": page, "per_page": per_page},
|
||||
}
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/translations/{job_id}/cancel",
|
||||
responses={
|
||||
200: {"description": "Job cancelled"},
|
||||
401: {"description": "Authentication required"},
|
||||
404: {"description": "Job not found"},
|
||||
409: {"description": "Job already finished"},
|
||||
},
|
||||
)
|
||||
async def cancel_translation(
|
||||
job_id: str,
|
||||
token: Optional[str] = None,
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""
|
||||
Cancel a queued or processing translation job.
|
||||
|
||||
Cancellation is cooperative: the worker aborts at its next checkpoint
|
||||
(before dispatch, or before finalisation for in-flight jobs) and the
|
||||
reserved quota is released immediately.
|
||||
"""
|
||||
job = await get_job_status_async(job_id)
|
||||
if not job:
|
||||
job = _translation_jobs.get(job_id)
|
||||
|
||||
if not job:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "NOT_FOUND",
|
||||
"message": "Translation job not found.",
|
||||
"details": {"job_id": job_id},
|
||||
},
|
||||
)
|
||||
|
||||
denied = _check_job_access(job, current_user, token)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if job.get("status") in ("completed", "failed", "cancelled"):
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "ALREADY_FINISHED",
|
||||
"message": f"Job already {job.get('status')}.",
|
||||
"details": {"job_id": job_id, "status": job.get("status")},
|
||||
},
|
||||
)
|
||||
|
||||
mem_job = _translation_jobs.get(job_id)
|
||||
if mem_job is None:
|
||||
# Redis-only copy (e.g. after a worker restart): cancel that view.
|
||||
mem_job = job
|
||||
_translation_jobs[job_id] = dict(job)
|
||||
|
||||
mem_job["status"] = "cancelled"
|
||||
mem_job["cancelled_at"] = datetime.now(timezone.utc).isoformat()
|
||||
mem_job["current_step"] = "Cancelled by user"
|
||||
await set_job_status_async(job_id, dict(mem_job))
|
||||
|
||||
# Release the reserved document slot right away unless usage was recorded.
|
||||
job_user_id = mem_job.get("user_id")
|
||||
if job_user_id and not mem_job.get("usage_recorded"):
|
||||
try:
|
||||
await asyncio.to_thread(release_translation_quota, str(job_user_id))
|
||||
logger.info(f"Job {job_id}: released reserved quota after user cancellation")
|
||||
except Exception as release_err:
|
||||
logger.exception(f"Job {job_id}: failed to release quota on cancel: {release_err}")
|
||||
|
||||
return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}}
|
||||
|
||||
|
||||
@router_v1.get("/translate/health")
|
||||
async def translate_health():
|
||||
"""Health check for translation endpoint."""
|
||||
|
||||
Reference in New Issue
Block a user