* Dark theme refactor, right sidebar redesign, and chat UI polish
- Dark theme refactor
- Redesign right sidebar
- Further left sidebar adjustments
- Wider chat and content area; layout tweaks for chat content
- Rounded corners across elements for consistency
- Show chat message menu icons on menu-area hover, not only on message hover
- Assistant message menu icons now always visible; user messages keep on-hover
- Redesigned copy icon used consistently across chat blocks and messages
- Redesigned trash icon, applied consistently
- Unified icon sizing and style with the sidebar
- Adjusted icon colors across chat
- Fix on-hover background design for chat icons
- Fix tooltip from 'more' button staying visible after clicking elsewhere
- Adjust position and design of generation speed info text below messages
- Adjust design of token speed info popup
- Adjust sidebar scrollbar to cover recent chats only
* Recents sidebar rename, UI/theme refactor, layout and chat polish
UI & Theme:
- Dark theme refactor
- Consistent rounded corners across elements
- CSS polish and cleanup
- Remove unused logo image assets
Recents sidebar:
- Add 'more' button for options menu
- Support renaming conversations and training runs
- Confirmation dialog before deleting chats
- Add optional display_name column to training_runs (idempotent ALTER TABLE) so renaming doesn't lose model_name/dataset_name from the run config
- New PATCH /api/train/runs/{run_id} endpoint accepts { display_name: string | null }; empty/whitespace clears the override
- Sidebar shows display_name ?? model_name and exposes Rename in the row's More menu, mirroring the chat rename flow
- Cache last list response in localStorage and hydrate from it on mount, so recents paint instantly on F5 / route revisit; cached items are shape-validated and dropped if malformed
- Optimistic updates on rename and delete (apply locally + cache before background refresh)
- Visible toast on rename/delete failure instead of swallowed errors
Layout:
- Redesigned right sidebar
- Further left sidebar adjustments
- Updated chat content layout; chat and content area slightly widened
- Sidebar scrollbar covers recent chats only
Icons:
- Redesigned copy icon, unified across chat blocks and messages
- Redesigned trash icon to match
- Consistent icon sizing and style across chat and sidebar
- Adjusted icon colors across chat
- Fix icon on-hover background design
Chat messages:
- Menu icons now appear on hover over the menu area, not just the message
- Assistant message menu icons always visible; user messages keep on-hover (next/previous response stays visible for edited prompts)
- Repositioned and restyled generation speed info text below messages
- Restyled token generation speed popup
Tooltips:
- Removed tooltip on hover for previous/next assistant response icons
- Unified tooltip design across sidebars and chat
- Removed tooltip animations (also fixes related lag)
Model & Chat Template config:
- Merged Chat Template config into Model Configuration section
- Added revert-to-original for chat template
- Fix Chat Template config disappearing on page refresh until model reload
Performance & scroll:
- Removed chatbox movement animations across pages/navigation (fixes related UI lag)
- Fix scroll flicker at end of streaming when a code block is the final element
- Additional chat scroll improvements
Bug fixes:
- Fix 'more' button tooltip remaining visible after clicking elsewhere
* Remove sidebar localStorage cache and optimistic updates
Drops the localStorage hydration and optimistic rename/delete logic from the recents sidebar; reverts to fetching fresh on mount.
* Fix missing cn import in shared-composer (regression from merge)
* chore(sidebar): import sidebar deps from feature indexes
Re-export deleteChatItem / renameChatItem / useChatSidebarItems / SidebarItem / useChatSearchStore / ChatSearchDialog from @/features/chat, and removeTrainingUnloadGuard from @/features/training. Switch app-sidebar.tsx to consume them via the public feature indexes instead of deep paths, clearing the no-restricted-imports eslint errors. No behavior or UX change.
* fix(studio/frontend): reload training Recents sidebar after F5 refresh
The Recents sidebar showed empty after a hard refresh. The hook's inFlightRef dedup guard collided with React StrictMode's double-mount in dev: the second mount's fetch returned silently with no error, no retry, and no toast — leaving the sidebar empty until navigation.
Replace skip-if-busy dedup with abort-previous via a hook-level AbortController. This also fixes a latent race where a slow poll could resurrect a just-deleted row by clobbering the optimistic update.
Changes (all in use-training-history-sidebar.ts):
- fetchRuns aborts any in-flight request before starting a new one; post-await signal.aborted check drops stale responses.
- Optimistic helpers (applyRunUpdate, removeRun) abort in-flight fetches so they don't depend on caller discipline to invalidate stale data.
- Initial load gets bounded retry-with-backoff (500ms / 1.5s / 3.5s) and surfaces a sonner toast with a Retry action on final failure.
- Failure toast auto-dismisses on any successful load (initial retry, Retry click, or polling recovery).
- Polling pauses while the tab is hidden and catches up on visible, avoiding wasted requests during long training runs.
- Both effects own their teardown explicitly (abort + clear timer).
* Apply unified tooltip design and behavior across remaining pages for consistency
* UI polish: spacing, tooltip on source icons, letter spacing, smaller icons, consistent edit icon
- Adjust tiny spacing between elements around the UI for subtle polish
- Redesign tooltip on source icons for web search / tool use, consistent with the new design
- Adjust chat text letter spacing
- Smaller icon sizes
- Replace 'edit message' icon in chat with the new Rename icon used in Recents for consistency
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Adjust CSS for right sidebar
* Fix scrollbar UI compatibility across browsers
* fix: preserve chat preset settings on model load
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): remove duplicate chat template status field
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* chore: remove creative preset assumption
* fix(studio): align speculative decoding default
* fix(studio/chat): snap numeric param inputs to step grid
- Type a value in any param input (Temperature, Top K, Max Tokens, etc.)
now clamps to [min, max] and snaps to the slider's step grid, killing
off-grid values like 1.051234 and FP residue from slider drags.
- Branch picker chevrons share the action bar's 32px height + 10px radius
via a new .aui-branch-chevron-btn utility; hover area aligns visually
while staying narrower than the sibling icon buttons.
* fix(studio/chat): keep training-run polls converging and drop dead preset code
- Keep training-run polls converging when responses outrun the 5s interval
(don't unconditionally abort prior in-flight; skip if one is still pending,
mutation race still guarded).
- Drop dead Creative/Precise preset code paths (remove 'builtin-fixed' source
variant + unreachable branches).
* fix(studio): training-run cards show custom name + model + dataset
- Training-run cards now display custom display_name + model + dataset,
with cross-view sync on rename/delete.
- Enhance clarity of borders and colors in dark theme on export etc.
* fix(studio): match active state green to unsloth brand color
* fix(studio): preserve can_resume on training rename
* fix(studio): keep GGUF chat template override distinct
* fix(studio): treat audio input models as multimodal
* fix(studio): cancel numeric draft on Escape
* fix(studio): use default speculative mode on toggle
* fix(studio): detect GGUF audio VLM input models
* fix(studio): address final PR review findings
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): refresh sidebar/history when a new training run starts so it appears without a manual reload
* fix: API and svg
* fix(studio/sidebar): align run rename dirty check with displayed baseline
* fix(studio/sidebar): use leading-tight on account block to prevent descender clipping with truncate
---------
Co-authored-by: sneakr <hauzin@hotmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
129 lines
3.9 KiB
Python
129 lines
3.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""
|
|
Training history API routes — browse, view, and delete past training runs.
|
|
"""
|
|
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from loggers import get_logger
|
|
|
|
from auth.authentication import get_current_subject
|
|
from core.training.resume import can_resume_run
|
|
from models import (
|
|
TrainingRunDeleteResponse,
|
|
TrainingRunDetailResponse,
|
|
TrainingRunListResponse,
|
|
TrainingRunMetrics,
|
|
TrainingRunSummary,
|
|
TrainingRunUpdateRequest,
|
|
)
|
|
from storage.studio_db import (
|
|
delete_run,
|
|
get_run,
|
|
get_run_metrics,
|
|
list_runs,
|
|
update_run_display_name,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/runs", response_model = TrainingRunListResponse)
|
|
async def list_training_runs(
|
|
limit: int = Query(50, ge = 1, le = 200),
|
|
offset: int = Query(0, ge = 0),
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""List training runs, newest first."""
|
|
result = list_runs(limit = limit, offset = offset)
|
|
return TrainingRunListResponse(
|
|
runs = [
|
|
TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)})
|
|
for r in result["runs"]
|
|
],
|
|
total = result["total"],
|
|
)
|
|
|
|
|
|
@router.get("/runs/{run_id}", response_model = TrainingRunDetailResponse)
|
|
async def get_training_run_detail(
|
|
run_id: str,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""Get a single training run with full config and metrics."""
|
|
run = get_run(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
|
|
|
try:
|
|
config = json.loads(run.get("config_json", "{}"))
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.debug("Failed to parse config_json for run %s", run_id)
|
|
config = {}
|
|
|
|
metrics_data = get_run_metrics(run_id)
|
|
|
|
return TrainingRunDetailResponse(
|
|
run = TrainingRunSummary(
|
|
**{
|
|
**{k: v for k, v in run.items() if k != "config_json"},
|
|
"can_resume": can_resume_run(run),
|
|
}
|
|
),
|
|
config = config,
|
|
metrics = TrainingRunMetrics(**metrics_data),
|
|
)
|
|
|
|
|
|
@router.patch("/runs/{run_id}", response_model = TrainingRunSummary)
|
|
async def update_training_run(
|
|
run_id: str,
|
|
payload: TrainingRunUpdateRequest,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""Update mutable fields on a training run (currently only display_name)."""
|
|
run = get_run(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
|
|
|
if "display_name" in payload.model_fields_set:
|
|
next_display = payload.display_name
|
|
if next_display is not None:
|
|
next_display = next_display.strip() or None
|
|
update_run_display_name(run_id, next_display)
|
|
|
|
refreshed = get_run(run_id)
|
|
if refreshed is None:
|
|
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
|
return TrainingRunSummary(
|
|
**{
|
|
**{k: v for k, v in refreshed.items() if k != "config_json"},
|
|
"can_resume": can_resume_run(refreshed),
|
|
}
|
|
)
|
|
|
|
|
|
@router.delete("/runs/{run_id}", response_model = TrainingRunDeleteResponse)
|
|
async def delete_training_run(
|
|
run_id: str,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""Delete a training run and its metrics (CASCADE)."""
|
|
run = get_run(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
|
if run["status"] == "running":
|
|
raise HTTPException(
|
|
status_code = 409, detail = "Cannot delete a running training run"
|
|
)
|
|
logger.info("Deleting training run %s", run_id)
|
|
delete_run(run_id)
|
|
return TrainingRunDeleteResponse(
|
|
status = "deleted",
|
|
message = f"Run {run_id} deleted",
|
|
)
|