Compare commits
9 commits
main
...
studio-pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7300a3e1 | ||
|
|
8644b4c208 | ||
|
|
485684db5f | ||
|
|
f9970b8744 | ||
|
|
1338f09b97 | ||
|
|
9ae3090b98 | ||
|
|
6ff20ecdeb | ||
|
|
f9b4ca4ff0 | ||
|
|
b8364e3445 |
26 changed files with 3476 additions and 216 deletions
|
|
@ -330,6 +330,7 @@ from hub.utils.download_registry import (
|
|||
)
|
||||
from routes.settings import router as settings_router
|
||||
from routes.prompts import router as prompts_router
|
||||
from routes.profile_stats import router as profile_stats_router
|
||||
from auth import storage
|
||||
from auth.authentication import get_current_subject
|
||||
from utils.hardware import (
|
||||
|
|
@ -1048,6 +1049,7 @@ app.include_router(providers_router, prefix = "/api/providers", tags = ["provide
|
|||
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
|
||||
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
|
||||
app.include_router(prompts_router, prefix = "/api/prompts", tags = ["prompts"])
|
||||
app.include_router(profile_stats_router, prefix = "/api/profile", tags = ["profile"])
|
||||
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
|
||||
app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
|
||||
app.include_router(llama_router, prefix = "/api/llama", tags = ["llama"])
|
||||
|
|
|
|||
57
studio/backend/routes/profile_stats.py
Normal file
57
studio/backend/routes/profile_stats.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Usage numbers for the Profile settings tab.
|
||||
|
||||
Read-only aggregation over the local studio.db (see
|
||||
``storage.profile_stats_db``). Nothing is uploaded.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from loggers import get_logger
|
||||
from storage.profile_stats_db import (
|
||||
MAX_DAILY_DAYS,
|
||||
MAX_TZ_OFFSET_MINUTES,
|
||||
compute_profile_stats,
|
||||
)
|
||||
from utils.utils import log_and_http_error
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_profile_stats(
|
||||
days: int = Query(MAX_DAILY_DAYS, ge = 1, le = MAX_DAILY_DAYS),
|
||||
tz_offset_minutes: int = Query(0, ge = -MAX_TZ_OFFSET_MINUTES, le = MAX_TZ_OFFSET_MINUTES),
|
||||
tz: str = Query("", max_length = 64),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> dict[str, Any]:
|
||||
"""Usage stats for the signed-in user's local history.
|
||||
|
||||
Days and hours are bucketed in the caller's timezone so a remote browser
|
||||
does not read the server's calendar. ``tz`` is an IANA name, which carries
|
||||
each date's own daylight-saving offset; ``tz_offset_minutes`` is the
|
||||
``Date.getTimezoneOffset()`` fallback for hosts with no tzdata.
|
||||
"""
|
||||
try:
|
||||
# A cold pass parses every message's metadata JSON: ~90 ms at 10k
|
||||
# messages, ~1.2 s at 260k. Off the event loop so it cannot stall token
|
||||
# streaming when Settings is opened mid-generation.
|
||||
return await asyncio.to_thread(
|
||||
compute_profile_stats,
|
||||
days = days,
|
||||
tz_offset_minutes = tz_offset_minutes,
|
||||
tz_name = tz,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise log_and_http_error(
|
||||
exc, 500, "Failed to compute profile statistics", log = logger
|
||||
) from exc
|
||||
656
studio/backend/storage/profile_stats_db.py
Normal file
656
studio/backend/storage/profile_stats_db.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Profile usage statistics derived from studio.db.
|
||||
|
||||
Read-only aggregation over rows the app already writes: chat threads/messages
|
||||
(with their per-message ``metadata_json``) and training runs/metrics. Nothing
|
||||
is recorded specifically for stats, so the numbers are only as complete as the
|
||||
local history.
|
||||
|
||||
Token counts live inside each message's metadata blob, so they cannot be summed
|
||||
in SQL portably (JSON1 is not guaranteed on every bundled SQLite). Rows are
|
||||
streamed once in (thread, time) order and every metric is folded in that single
|
||||
pass, then memoised against a (count, max created_at) fingerprint so reopening
|
||||
the Profile tab is free until history changes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
from typing import Any, Optional
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
from storage.studio_db import get_connection
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Gaps longer than this end a "sitting at the keyboard" stretch: without the cap
|
||||
# a thread reopened a week later would report a week-long chat.
|
||||
SESSION_GAP_SECONDS = 30 * 60
|
||||
# Cap on the daily activity series handed to the UI (the heatmap draws a year).
|
||||
MAX_DAILY_DAYS = 366
|
||||
# Widest real UTC offset is 14h; anything beyond that is a bad client value.
|
||||
MAX_TZ_OFFSET_MINUTES = 14 * 60
|
||||
# Top-N lists returned to the client.
|
||||
TOP_MODELS = 8
|
||||
RECENT_RUNS = 5
|
||||
# Serve a memoised payload for this long even if the fingerprint is unchanged,
|
||||
# so a chat that is mid-stream still refreshes reasonably promptly.
|
||||
CACHE_TTL_SECONDS = 20.0
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache: dict[str, Any] = {"fingerprint": None, "expires_at": 0.0, "payload": None}
|
||||
|
||||
|
||||
def _as_float(value: Any) -> Optional[float]:
|
||||
"""Coerce JSON numbers defensively; metadata is written by the client.
|
||||
|
||||
json accepts integers of any width, and float() raises OverflowError past
|
||||
~1e308, so one oversized counter would 500 the whole panel.
|
||||
"""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
number = float(value)
|
||||
except (OverflowError, ValueError):
|
||||
return None
|
||||
return number if number == number and number not in (float("inf"), float("-inf")) else None
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int:
|
||||
number = _as_float(value)
|
||||
if number is None or number < 0:
|
||||
return 0
|
||||
return int(number)
|
||||
|
||||
|
||||
def _iso(day: date) -> str:
|
||||
return day.isoformat()
|
||||
|
||||
|
||||
def _clean_str(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _resolve_zone(tz_name: str, tz_offset_minutes: int):
|
||||
"""The caller's zone, preferring an IANA name over a single offset.
|
||||
|
||||
A fixed offset is only correct for the half of the year the caller happens
|
||||
to be in, so a winter message read during summer lands an hour out and can
|
||||
cross midnight. An IANA name carries each date's own offset. The offset
|
||||
stays as the fallback for callers that send no name, or hosts with no tzdata.
|
||||
"""
|
||||
if tz_name:
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except (ValueError, ZoneInfoNotFoundError, OSError):
|
||||
logger.debug("unknown timezone %r, falling back to fixed offset", tz_name)
|
||||
return timezone(timedelta(minutes = -tz_offset_minutes))
|
||||
|
||||
|
||||
def _local_stamp(created_at_ms: int, zone) -> Optional[datetime]:
|
||||
"""Wall-clock time in the caller's timezone, not the server's.
|
||||
|
||||
created_at is a client-supplied integer that SQLite stores unchecked, so a
|
||||
value outside datetime's range is possible. Drop that row rather than let
|
||||
one bad import take down the whole panel.
|
||||
"""
|
||||
if created_at_ms <= 0:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None)
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _streaks(days: set[date], today: date) -> dict[str, Any]:
|
||||
"""Current and longest run of consecutive active days.
|
||||
|
||||
The current streak survives a day that has not been used yet: a streak that
|
||||
ended yesterday is still "live" until today is over.
|
||||
|
||||
Imported history or a skewed client clock can date rows in the future.
|
||||
Those are dropped up front so they cannot pad the longest streak or be
|
||||
reported as the last active day either.
|
||||
"""
|
||||
days = {day for day in days if day <= today}
|
||||
if not days:
|
||||
return {"current": 0, "longest": 0, "lastActiveDay": None}
|
||||
|
||||
ordered = sorted(days)
|
||||
longest = 1
|
||||
running = 1
|
||||
for previous, current in zip(ordered, ordered[1:]):
|
||||
running = running + 1 if current - previous == timedelta(days = 1) else 1
|
||||
longest = max(longest, running)
|
||||
|
||||
last = ordered[-1]
|
||||
current_streak = 0
|
||||
if today - last <= timedelta(days = 1):
|
||||
current_streak = 1
|
||||
cursor = last
|
||||
while cursor - timedelta(days = 1) in days:
|
||||
cursor -= timedelta(days = 1)
|
||||
current_streak += 1
|
||||
|
||||
return {"current": current_streak, "longest": longest, "lastActiveDay": _iso(last)}
|
||||
|
||||
|
||||
def _model_label(model_id: str) -> str:
|
||||
"""Last path segment of a repo id, e.g. ``unsloth/gpt-oss-20b`` -> ``gpt-oss-20b``."""
|
||||
cleaned = model_id.strip().replace("\\", "/")
|
||||
tail = cleaned.rstrip("/").split("/")[-1]
|
||||
return tail or cleaned
|
||||
|
||||
|
||||
class _MessageFold:
|
||||
"""Accumulators for the single streaming pass over chat messages."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.threads: set[str] = set()
|
||||
self.messages = 0
|
||||
self.user_messages = 0
|
||||
self.assistant_messages = 0
|
||||
self.prompt_tokens = 0
|
||||
self.completion_tokens = 0
|
||||
self.total_tokens = 0
|
||||
self.cached_tokens = 0
|
||||
self.tool_calls = 0
|
||||
self.attachments = 0
|
||||
self.session_seconds = 0.0
|
||||
self.longest_chat: dict[str, Any] = {
|
||||
"threadId": None,
|
||||
"title": None,
|
||||
"seconds": 0.0,
|
||||
"messages": 0,
|
||||
}
|
||||
self.by_day: dict[date, dict[str, Any]] = {}
|
||||
self.models: dict[str, dict[str, Any]] = {}
|
||||
self.speed_samples: list[float] = []
|
||||
self.best_speed = 0.0
|
||||
self.best_speed_model: Optional[str] = None
|
||||
self.response_ms: list[float] = []
|
||||
self.first_token_ms: list[float] = []
|
||||
|
||||
def note_model(self, model_id: str, tokens: int) -> None:
|
||||
entry = self.models.setdefault(
|
||||
model_id, {"id": model_id, "label": _model_label(model_id), "messages": 0, "tokens": 0}
|
||||
)
|
||||
entry["messages"] += 1
|
||||
entry["tokens"] += tokens
|
||||
|
||||
def note_day(self, day: date, tokens: int, thread_id: str) -> None:
|
||||
bucket = self.by_day.setdefault(day, {"tokens": 0, "messages": 0, "threads": set()})
|
||||
bucket["tokens"] += tokens
|
||||
bucket["messages"] += 1
|
||||
bucket["threads"].add(thread_id)
|
||||
|
||||
|
||||
def _fork_keepers(conn) -> dict[tuple[str, int, str], str]:
|
||||
"""For each original message, the one clone elected to stand in for it.
|
||||
|
||||
A clone is normally ignored because the original is counted instead. Once
|
||||
the original is gone, whether its thread was deleted or just that row was
|
||||
pruned, the clones become the only record. Letting every sibling count them
|
||||
would multiply the usage, so exactly one may.
|
||||
|
||||
Electing per message rather than per fork matters because fork_chat_thread
|
||||
copies one parent_id branch, not the whole thread: sibling forks taken from
|
||||
a retry and a regeneration hold different rows, and a per-fork winner would
|
||||
silently drop whatever only the loser carries.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT m.thread_id, m.created_at, m.role,
|
||||
t.forked_from_thread_id AS source_id
|
||||
FROM chat_messages m
|
||||
JOIN chat_threads t ON t.id = m.thread_id
|
||||
WHERE t.forked_from_thread_id IS NOT NULL
|
||||
AND m.created_at < t.created_at
|
||||
"""
|
||||
)
|
||||
|
||||
best: dict[tuple[str, int, str], str] = {}
|
||||
for row in rows:
|
||||
key = (row["source_id"], _as_int(row["created_at"]), row["role"])
|
||||
thread_id = row["thread_id"]
|
||||
current = best.get(key)
|
||||
# Any stable winner works; lowest id keeps the choice reproducible.
|
||||
if current is None or thread_id < current:
|
||||
best[key] = thread_id
|
||||
return best
|
||||
|
||||
|
||||
def _surviving_original_keys(conn) -> set[tuple[str, int, str]]:
|
||||
"""Identity of every message still living in a thread that has been forked.
|
||||
|
||||
Clones get fresh ids, so there is nothing to join on. Within one thread the
|
||||
timestamp and role are enough to recognise the row a clone was taken from.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT m.thread_id, m.created_at, m.role
|
||||
FROM chat_messages m
|
||||
WHERE m.thread_id IN (
|
||||
SELECT DISTINCT forked_from_thread_id FROM chat_threads
|
||||
WHERE forked_from_thread_id IS NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return {(row["thread_id"], _as_int(row["created_at"]), row["role"]) for row in rows}
|
||||
|
||||
|
||||
def _fold_messages(conn, zone) -> _MessageFold:
|
||||
fold = _MessageFold()
|
||||
keepers = _fork_keepers(conn)
|
||||
surviving = _surviving_original_keys(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at,
|
||||
t.title, t.model_id, t.model_type, t.pair_id,
|
||||
t.created_at AS thread_created_at, t.forked_from_thread_id
|
||||
FROM chat_messages m
|
||||
LEFT JOIN chat_threads t ON t.id = m.thread_id
|
||||
ORDER BY m.thread_id, m.created_at
|
||||
"""
|
||||
)
|
||||
|
||||
current_thread: Optional[str] = None
|
||||
thread_title: Optional[str] = None
|
||||
thread_seconds = 0.0
|
||||
thread_messages = 0
|
||||
previous_created: Optional[int] = None
|
||||
|
||||
def close_thread() -> None:
|
||||
if current_thread is None:
|
||||
return
|
||||
fold.session_seconds += thread_seconds
|
||||
if thread_seconds > fold.longest_chat["seconds"]:
|
||||
fold.longest_chat = {
|
||||
"threadId": current_thread,
|
||||
"title": thread_title,
|
||||
"seconds": thread_seconds,
|
||||
"messages": thread_messages,
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
thread_id = row["thread_id"]
|
||||
created_at = _as_int(row["created_at"])
|
||||
if thread_id != current_thread:
|
||||
close_thread()
|
||||
current_thread = thread_id
|
||||
thread_title = row["title"]
|
||||
thread_seconds = 0.0
|
||||
thread_messages = 0
|
||||
previous_created = None
|
||||
|
||||
# Compare mode stores one thread per pane under a shared pair_id, and
|
||||
# the sidebar shows them as a single conversation. Count them that way
|
||||
# too, or one comparison inflates the chat total and drags the average
|
||||
# tokens per chat down.
|
||||
conversation_id = row["pair_id"] or thread_id
|
||||
|
||||
# A fork is its own visible conversation, so it counts towards the chat
|
||||
# total from the moment it exists, before any new turn is added.
|
||||
fold.threads.add(conversation_id)
|
||||
|
||||
# Forking clones the whole ancestry into the new thread, keeping each
|
||||
# copy's original timestamp. Skip a clone while the row it was taken
|
||||
# from is still there to be counted; once that original is gone, only
|
||||
# the elected fork stands in for it, so nothing is lost or doubled.
|
||||
source_id = row["forked_from_thread_id"]
|
||||
if source_id and created_at < _as_int(row["thread_created_at"]):
|
||||
original = (source_id, created_at, row["role"])
|
||||
if original in surviving or keepers.get(original) != thread_id:
|
||||
continue
|
||||
|
||||
fold.messages += 1
|
||||
thread_messages += 1
|
||||
|
||||
if previous_created is not None:
|
||||
gap = (created_at - previous_created) / 1000
|
||||
if 0 < gap <= SESSION_GAP_SECONDS:
|
||||
thread_seconds += gap
|
||||
previous_created = created_at
|
||||
|
||||
stamp = _local_stamp(created_at, zone)
|
||||
role = row["role"]
|
||||
if role == "user":
|
||||
fold.user_messages += 1
|
||||
|
||||
attachments_json = row["attachments_json"]
|
||||
if attachments_json:
|
||||
try:
|
||||
parsed = json.loads(attachments_json)
|
||||
if isinstance(parsed, list):
|
||||
fold.attachments += len(parsed)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
message_tokens = 0
|
||||
metadata: Any = None
|
||||
if role == "assistant":
|
||||
fold.assistant_messages += 1
|
||||
raw_metadata = row["metadata_json"]
|
||||
if raw_metadata:
|
||||
try:
|
||||
metadata = json.loads(raw_metadata)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata = None
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
usage = metadata.get("contextUsage")
|
||||
timing = metadata.get("timing")
|
||||
usage = usage if isinstance(usage, dict) else {}
|
||||
timing = timing if isinstance(timing, dict) else {}
|
||||
|
||||
prompt_tokens = _as_int(usage.get("promptTokens"))
|
||||
completion_tokens = _as_int(usage.get("completionTokens"))
|
||||
total_tokens = _as_int(usage.get("totalTokens"))
|
||||
if completion_tokens == 0:
|
||||
# Local engines occasionally omit the usage chunk; the adapter's
|
||||
# own token count is the next best estimate.
|
||||
completion_tokens = _as_int(timing.get("tokenCount"))
|
||||
if total_tokens == 0:
|
||||
total_tokens = prompt_tokens + completion_tokens
|
||||
|
||||
fold.prompt_tokens += prompt_tokens
|
||||
fold.completion_tokens += completion_tokens
|
||||
fold.total_tokens += total_tokens
|
||||
fold.cached_tokens += _as_int(usage.get("cachedTokens"))
|
||||
fold.tool_calls += _as_int(timing.get("toolCallCount"))
|
||||
message_tokens = total_tokens
|
||||
|
||||
# responseDetails carries the model that actually answered, which
|
||||
# differs from the requested checkpoint whenever a provider routes
|
||||
# or resolves an alias. contextUsage.modelId is the request, so it
|
||||
# is only the fallback. The thread's model_id is never used: it
|
||||
# tracks the current selection, not the one that ran.
|
||||
details = metadata.get("responseDetails")
|
||||
details = details if isinstance(details, dict) else {}
|
||||
model_id = _clean_str(details.get("responseModelId")) or _clean_str(
|
||||
usage.get("modelId")
|
||||
)
|
||||
if model_id:
|
||||
fold.note_model(model_id, message_tokens)
|
||||
|
||||
speed = _as_float(timing.get("tokensPerSecond"))
|
||||
# llama.cpp reports absurd rates on no-op turns; ignore those.
|
||||
if speed is not None and 0 < speed < 100_000:
|
||||
fold.speed_samples.append(speed)
|
||||
if speed > fold.best_speed:
|
||||
fold.best_speed = speed
|
||||
fold.best_speed_model = _model_label(model_id) if model_id else None
|
||||
|
||||
stream_ms = _as_float(timing.get("totalStreamTime"))
|
||||
if stream_ms is not None and stream_ms > 0:
|
||||
fold.response_ms.append(stream_ms)
|
||||
# firstTokenTime is already an elapsed duration, not a timestamp.
|
||||
first_token = _as_float(timing.get("firstTokenTime"))
|
||||
if first_token is not None and first_token > 0:
|
||||
fold.first_token_ms.append(first_token)
|
||||
|
||||
if stamp is not None:
|
||||
fold.note_day(stamp.date(), message_tokens, conversation_id)
|
||||
|
||||
close_thread()
|
||||
return fold
|
||||
|
||||
|
||||
def _daily_series(fold: _MessageFold, today: date, days: int) -> list[dict[str, Any]]:
|
||||
"""Dense day-by-day series so the heatmap can index straight into it."""
|
||||
start = today - timedelta(days = days - 1)
|
||||
series: list[dict[str, Any]] = []
|
||||
for offset in range(days):
|
||||
day = start + timedelta(days = offset)
|
||||
bucket = fold.by_day.get(day)
|
||||
series.append(
|
||||
{
|
||||
"date": _iso(day),
|
||||
"tokens": int(bucket["tokens"]) if bucket else 0,
|
||||
"messages": int(bucket["messages"]) if bucket else 0,
|
||||
"chats": len(bucket["threads"]) if bucket else 0,
|
||||
}
|
||||
)
|
||||
return series
|
||||
|
||||
|
||||
def _superseded(prefix: str = "r.") -> str:
|
||||
"""SQL for "a later run resumed from this one, so its counters live there".
|
||||
|
||||
``prefix`` must qualify the outer row: the EXISTS subquery selects from the
|
||||
same table, so a bare column name would bind to the subquery instead.
|
||||
|
||||
``create_run``'s resume claim sets ``resume_blocked`` and leaves
|
||||
``output_dir`` alone. Cancelling clears ``output_dir`` while setting the
|
||||
same flag, so the flag alone cannot tell the two apart.
|
||||
|
||||
``delete_run`` never clears the flag, so the continuation has to still be
|
||||
there. Otherwise deleting it would strand the source at zero while its row
|
||||
and metrics stay visible in history.
|
||||
|
||||
The continuation also has to have reached the source's step. ``create_run``
|
||||
claims the source the moment a resume starts, but ``final_step`` is only
|
||||
written on the first metric flush, so a continuation that fails before then
|
||||
would take the source's completed work down with it.
|
||||
"""
|
||||
return f"""
|
||||
{prefix}resume_blocked = 1
|
||||
AND {prefix}output_dir IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM training_runs continuation
|
||||
WHERE continuation.output_dir = {prefix}output_dir
|
||||
AND continuation.started_at > {prefix}started_at
|
||||
AND COALESCE(continuation.final_step, 0)
|
||||
>= COALESCE({prefix}final_step, 0)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _training_stats(conn) -> dict[str, Any]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS runs,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
||||
SUM(COALESCE(duration_seconds, 0)) AS seconds,
|
||||
COUNT(DISTINCT model_name) AS models,
|
||||
COUNT(DISTINCT dataset_name) AS datasets,
|
||||
MIN(final_loss) AS best_loss
|
||||
FROM training_runs
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
# A resumed run continues its source's step and token counters from the
|
||||
# checkpoint, so both absolute totals already include the source's work.
|
||||
# Only a run superseded by a resume is dropped: create_run's claim sets
|
||||
# resume_blocked while leaving output_dir intact, whereas cancelling clears
|
||||
# output_dir, so a cancelled run keeps contributing the work it did do.
|
||||
steps = conn.execute(
|
||||
f"SELECT COALESCE(SUM(r.final_step), 0) FROM training_runs r WHERE NOT ({_superseded()})"
|
||||
).fetchone()[0]
|
||||
|
||||
# num_tokens is state.num_input_tokens_seen, a running total logged at each
|
||||
# step, so summing the samples multiplies the real figure. Take each run's
|
||||
# final counter, the same value get_run_metrics reports.
|
||||
tokens = conn.execute(
|
||||
f"""
|
||||
SELECT COALESCE(SUM(run_tokens), 0) FROM (
|
||||
SELECT MAX(m.num_tokens) AS run_tokens
|
||||
FROM training_metrics m
|
||||
JOIN training_runs r ON r.id = m.run_id
|
||||
WHERE NOT ({_superseded("r.")})
|
||||
GROUP BY m.run_id
|
||||
)
|
||||
"""
|
||||
).fetchone()[0]
|
||||
|
||||
recent = conn.execute(
|
||||
"""
|
||||
SELECT id, display_name, model_name, dataset_name,
|
||||
status, final_loss, final_step, duration_seconds, started_at
|
||||
FROM training_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(RECENT_RUNS,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"runs": _as_int(row["runs"]),
|
||||
"completed": _as_int(row["completed"]),
|
||||
"steps": _as_int(steps),
|
||||
"tokens": _as_int(tokens),
|
||||
"seconds": _as_float(row["seconds"]) or 0.0,
|
||||
"models": _as_int(row["models"]),
|
||||
"datasets": _as_int(row["datasets"]),
|
||||
"bestLoss": _as_float(row["best_loss"]),
|
||||
"recent": [
|
||||
{
|
||||
"id": item["id"],
|
||||
# A renamed run keeps the name the user gave it; otherwise fall
|
||||
# back to the short model label rather than the full repo id.
|
||||
"name": _clean_str(item["display_name"]) or _model_label(item["model_name"] or ""),
|
||||
"modelLabel": _model_label(item["model_name"] or ""),
|
||||
"datasetLabel": _model_label(item["dataset_name"] or ""),
|
||||
"status": item["status"],
|
||||
"finalLoss": _as_float(item["final_loss"]),
|
||||
"steps": _as_int(item["final_step"]),
|
||||
"seconds": _as_float(item["duration_seconds"]) or 0.0,
|
||||
"startedAt": item["started_at"],
|
||||
}
|
||||
for item in recent
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(conn) -> tuple:
|
||||
message_row = conn.execute(
|
||||
"SELECT COUNT(*), COALESCE(MAX(created_at), 0) FROM chat_messages"
|
||||
).fetchone()
|
||||
run_row = conn.execute(
|
||||
"SELECT COUNT(*), COALESCE(MAX(started_at), '') FROM training_runs"
|
||||
).fetchone()
|
||||
return (message_row[0], message_row[1], run_row[0], run_row[1])
|
||||
|
||||
|
||||
def compute_profile_stats(
|
||||
days: int = MAX_DAILY_DAYS,
|
||||
tz_offset_minutes: int = 0,
|
||||
tz_name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate every profile statistic in one pass, memoised per history state."""
|
||||
days = max(1, min(int(days), MAX_DAILY_DAYS))
|
||||
tz_offset_minutes = max(
|
||||
-MAX_TZ_OFFSET_MINUTES, min(int(tz_offset_minutes), MAX_TZ_OFFSET_MINUTES)
|
||||
)
|
||||
zone = _resolve_zone(tz_name, tz_offset_minutes)
|
||||
conn = get_connection()
|
||||
try:
|
||||
fingerprint = (_fingerprint(conn), days, tz_offset_minutes, tz_name)
|
||||
now = time.monotonic()
|
||||
with _cache_lock:
|
||||
if (
|
||||
_cache["payload"] is not None
|
||||
and _cache["fingerprint"] == fingerprint
|
||||
and _cache["expires_at"] > now
|
||||
):
|
||||
return _cache["payload"]
|
||||
|
||||
started = time.perf_counter()
|
||||
fold = _fold_messages(conn, zone)
|
||||
training = _training_stats(conn)
|
||||
|
||||
# "Today" has to match the buckets above, or the newest column and the
|
||||
# current streak drift by a day whenever the caller is elsewhere.
|
||||
today = (_local_stamp(int(time.time() * 1000), zone) or datetime.now()).date()
|
||||
streak = _streaks(set(fold.by_day.keys()), today)
|
||||
daily = _daily_series(fold, today, days)
|
||||
|
||||
peak_day = max(fold.by_day.items(), key = lambda item: item[1]["tokens"], default = None)
|
||||
models = sorted(
|
||||
fold.models.values(), key = lambda item: (item["tokens"], item["messages"]), reverse = True
|
||||
)[:TOP_MODELS]
|
||||
|
||||
speed_samples = fold.speed_samples
|
||||
payload = {
|
||||
"generatedAt": int(time.time() * 1000),
|
||||
"days": days,
|
||||
"totals": {
|
||||
"threads": len(fold.threads),
|
||||
"messages": fold.messages,
|
||||
"userMessages": fold.user_messages,
|
||||
"assistantMessages": fold.assistant_messages,
|
||||
"promptTokens": fold.prompt_tokens,
|
||||
"completionTokens": fold.completion_tokens,
|
||||
"totalTokens": fold.total_tokens,
|
||||
"cachedTokens": fold.cached_tokens,
|
||||
"toolCalls": fold.tool_calls,
|
||||
"attachments": fold.attachments,
|
||||
"activeDays": len(fold.by_day),
|
||||
"chatSeconds": round(fold.session_seconds),
|
||||
},
|
||||
"streak": streak,
|
||||
"peakDay": (
|
||||
{"date": _iso(peak_day[0]), "tokens": int(peak_day[1]["tokens"])}
|
||||
if peak_day and peak_day[1]["tokens"] > 0
|
||||
else None
|
||||
),
|
||||
"longestChat": (
|
||||
{
|
||||
"threadId": fold.longest_chat["threadId"],
|
||||
"title": fold.longest_chat["title"],
|
||||
"seconds": round(fold.longest_chat["seconds"]),
|
||||
"messages": fold.longest_chat["messages"],
|
||||
}
|
||||
if fold.longest_chat["seconds"] > 0
|
||||
else None
|
||||
),
|
||||
"daily": daily,
|
||||
"models": models,
|
||||
"speed": {
|
||||
"averageTokensPerSecond": (
|
||||
sum(speed_samples) / len(speed_samples) if speed_samples else None
|
||||
),
|
||||
"bestTokensPerSecond": fold.best_speed or None,
|
||||
"bestTokensPerSecondModel": fold.best_speed_model,
|
||||
"averageResponseMs": (
|
||||
sum(fold.response_ms) / len(fold.response_ms) if fold.response_ms else None
|
||||
),
|
||||
"averageFirstTokenMs": (
|
||||
sum(fold.first_token_ms) / len(fold.first_token_ms)
|
||||
if fold.first_token_ms
|
||||
else None
|
||||
),
|
||||
"samples": len(speed_samples),
|
||||
},
|
||||
"training": training,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"profile stats computed in %.1f ms (%d messages)",
|
||||
(time.perf_counter() - started) * 1000,
|
||||
fold.messages,
|
||||
)
|
||||
|
||||
with _cache_lock:
|
||||
_cache["fingerprint"] = fingerprint
|
||||
_cache["expires_at"] = time.monotonic() + CACHE_TTL_SECONDS
|
||||
_cache["payload"] = payload
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def invalidate_profile_stats_cache() -> None:
|
||||
"""Drop the memoised payload (used by tests and after history wipes)."""
|
||||
with _cache_lock:
|
||||
_cache["fingerprint"] = None
|
||||
_cache["expires_at"] = 0.0
|
||||
_cache["payload"] = None
|
||||
1006
studio/backend/tests/test_profile_stats.py
Normal file
1006
studio/backend/tests/test_profile_stats.py
Normal file
File diff suppressed because it is too large
Load diff
102
studio/frontend/src/features/profile/api/profile-stats.ts
Normal file
102
studio/frontend/src/features/profile/api/profile-stats.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
/** One day of the activity series. Dense: every day in range is present. */
|
||||
export type ProfileStatsDay = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
messages: number;
|
||||
chats: number;
|
||||
};
|
||||
|
||||
export type ProfileStatsModel = {
|
||||
id: string;
|
||||
label: string;
|
||||
messages: number;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
export type ProfileStatsRun = {
|
||||
id: string;
|
||||
name: string;
|
||||
modelLabel: string;
|
||||
datasetLabel: string;
|
||||
status: string;
|
||||
finalLoss: number | null;
|
||||
steps: number;
|
||||
seconds: number;
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
export type ProfileStats = {
|
||||
generatedAt: number;
|
||||
days: number;
|
||||
totals: {
|
||||
threads: number;
|
||||
messages: number;
|
||||
userMessages: number;
|
||||
assistantMessages: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cachedTokens: number;
|
||||
toolCalls: number;
|
||||
attachments: number;
|
||||
activeDays: number;
|
||||
chatSeconds: number;
|
||||
};
|
||||
streak: {
|
||||
current: number;
|
||||
longest: number;
|
||||
lastActiveDay: string | null;
|
||||
};
|
||||
peakDay: { date: string; tokens: number } | null;
|
||||
longestChat: {
|
||||
threadId: string | null;
|
||||
title: string | null;
|
||||
seconds: number;
|
||||
messages: number;
|
||||
} | null;
|
||||
daily: ProfileStatsDay[];
|
||||
models: ProfileStatsModel[];
|
||||
speed: {
|
||||
averageTokensPerSecond: number | null;
|
||||
bestTokensPerSecond: number | null;
|
||||
bestTokensPerSecondModel: string | null;
|
||||
averageResponseMs: number | null;
|
||||
averageFirstTokenMs: number | null;
|
||||
samples: number;
|
||||
};
|
||||
training: {
|
||||
runs: number;
|
||||
completed: number;
|
||||
steps: number;
|
||||
tokens: number;
|
||||
seconds: number;
|
||||
models: number;
|
||||
datasets: number;
|
||||
bestLoss: number | null;
|
||||
recent: ProfileStatsRun[];
|
||||
};
|
||||
};
|
||||
|
||||
export async function loadProfileStats(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProfileStats> {
|
||||
// Bucket days and hours in this browser's timezone, which is not the
|
||||
// server's when Studio is reached over the network. The IANA name is what
|
||||
// gives each historical date its own daylight-saving offset; the current
|
||||
// offset only covers callers whose host cannot resolve the name.
|
||||
const query = new URLSearchParams({
|
||||
tz_offset_minutes: String(new Date().getTimezoneOffset()),
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "",
|
||||
});
|
||||
const res = await authFetch(`/api/profile/stats?${query}`, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(await readFastApiError(res, "Failed to load your stats"));
|
||||
}
|
||||
return (await res.json()) as ProfileStats;
|
||||
}
|
||||
|
|
@ -5,14 +5,23 @@ import { publicAssetUrl } from "@/components/mascot-img";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useT } from "@/i18n";
|
||||
import { toastError, toastSuccess } from "@/shared/toast";
|
||||
import { Edit03Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Delete02Icon,
|
||||
Edit03Icon,
|
||||
Image01Icon,
|
||||
Upload01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SLOTH_AVATARS } from "../sloth-avatars";
|
||||
import { decodeJwtSubject } from "../utils/jwt-subject";
|
||||
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
|
||||
|
|
@ -23,6 +32,8 @@ import {
|
|||
import { UserAvatar } from "./user-avatar";
|
||||
|
||||
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
|
||||
const SLOTH_NAME = /^large\s+/i;
|
||||
const PNG_SUFFIX = /\.png$/i;
|
||||
|
||||
function readPersistedProfile(): {
|
||||
displayName: string;
|
||||
|
|
@ -36,7 +47,8 @@ function readPersistedProfile(): {
|
|||
if (!parsed || typeof parsed !== "object") return null;
|
||||
|
||||
// Zustand persist shape: { state: {...}, version }
|
||||
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
|
||||
const maybeState =
|
||||
"state" in parsed ? (parsed as { state?: unknown }).state : parsed;
|
||||
if (!maybeState || typeof maybeState !== "object") return null;
|
||||
const state = maybeState as {
|
||||
displayName?: unknown;
|
||||
|
|
@ -45,9 +57,11 @@ function readPersistedProfile(): {
|
|||
};
|
||||
|
||||
return {
|
||||
displayName: typeof state.displayName === "string" ? state.displayName : "",
|
||||
displayName:
|
||||
typeof state.displayName === "string" ? state.displayName : "",
|
||||
nickname: typeof state.nickname === "string" ? state.nickname : "",
|
||||
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
|
||||
avatarDataUrl:
|
||||
typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -64,28 +78,17 @@ export function ProfilePersonalizationPanel() {
|
|||
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
|
||||
const avatarShape = useUserProfileStore((s) => s.avatarShape);
|
||||
const setAvatarShape = useUserProfileStore((s) => s.setAvatarShape);
|
||||
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
|
||||
const setShowGreetingSloth = useUserProfileStore(
|
||||
(s) => s.setShowGreetingSloth,
|
||||
);
|
||||
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [draftName, setDraftName] = useState(displayName);
|
||||
const [draftNickname, setDraftNickname] = useState(nickname);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastDisplayNameRef = useRef(displayName);
|
||||
const lastNicknameRef = useRef(nickname);
|
||||
|
||||
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
|
||||
const previewName = draftName.trim() || sessionSub || "Unsloth";
|
||||
const hasNameChanges = useMemo(
|
||||
() => draftName.trim() !== displayName.trim(),
|
||||
[draftName, displayName],
|
||||
);
|
||||
const hasNicknameChanges = useMemo(
|
||||
() => draftNickname.trim() !== nickname.trim(),
|
||||
[draftNickname, nickname],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = lastDisplayNameRef.current;
|
||||
|
|
@ -99,6 +102,8 @@ export function ProfilePersonalizationPanel() {
|
|||
setDraftNickname((draft) => (draft === previous ? nickname : draft));
|
||||
}, [nickname]);
|
||||
|
||||
// Committed on blur and on Enter rather than behind a Save button, so each
|
||||
// field is a single row like the rest of Settings.
|
||||
const saveName = () => {
|
||||
const trimmed = draftName.trim();
|
||||
if (trimmed !== draftName) setDraftName(trimmed);
|
||||
|
|
@ -133,6 +138,18 @@ export function ProfilePersonalizationPanel() {
|
|||
}
|
||||
};
|
||||
|
||||
// Escape, or any programmatic close, unmounts the tab without dispatching a
|
||||
// blur, which would drop whatever was typed. Commit the drafts on the way
|
||||
// out; both saves no-op on an unchanged value, so a double commit is safe.
|
||||
const flushDrafts = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
flushDrafts.current = () => {
|
||||
saveName();
|
||||
saveNickname();
|
||||
};
|
||||
});
|
||||
useEffect(() => () => flushDrafts.current(), []);
|
||||
|
||||
const applyAvatar = (value: string | null) => {
|
||||
setAvatarDataUrl(value);
|
||||
const persisted = readPersistedProfile();
|
||||
|
|
@ -180,201 +197,232 @@ export function ProfilePersonalizationPanel() {
|
|||
requestAnimationFrame(() => applyAvatar(value));
|
||||
};
|
||||
|
||||
const pickSloth = (path: string) => {
|
||||
pickAvatarValue(publicAssetUrl(path));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
name={previewName}
|
||||
imageUrl={shownAvatar}
|
||||
size="lg"
|
||||
className="size-[124px] text-[calc(3.15rem*var(--ui-font-scale,1))]"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void onPickFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t("settings.profile.changePicture")}
|
||||
>
|
||||
<HugeiconsIcon icon={Edit03Icon} className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void onPickFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-settings-label={t("settings.profile.displayName")}
|
||||
className="flex w-full max-w-[560px] flex-col gap-2"
|
||||
>
|
||||
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
|
||||
{t("settings.profile.displayName")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="profile-display-name"
|
||||
type="text"
|
||||
value={draftName}
|
||||
maxLength={PROFILE_TEXT_MAX_LENGTH}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder={sessionSub || "Unsloth"}
|
||||
className="h-10 min-w-0 flex-1 rounded-full text-sm"
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-label={t("settings.profile.nickname")}
|
||||
className="flex w-full max-w-[560px] flex-col gap-2"
|
||||
>
|
||||
<Label htmlFor="profile-nickname" className="text-xs font-medium text-muted-foreground">
|
||||
{t("settings.profile.nickname")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="profile-nickname"
|
||||
type="text"
|
||||
value={draftNickname}
|
||||
maxLength={PROFILE_TEXT_MAX_LENGTH}
|
||||
onChange={(e) => setDraftNickname(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveNickname();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder={t("settings.profile.nicknamePlaceholder")}
|
||||
className="h-10 min-w-0 flex-1 rounded-full text-sm"
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-10 px-5" onClick={saveNickname} disabled={!hasNicknameChanges}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-label={t("settings.profile.avatarShape")}
|
||||
className="flex w-full max-w-[560px] flex-col gap-2"
|
||||
>
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("settings.profile.avatarShape")}
|
||||
</Label>
|
||||
<div className="hub-tab-toggle inline-flex h-8 w-fit items-center rounded-full">
|
||||
{(["circle", "rounded"] as const).map((shape) => (
|
||||
<button
|
||||
key={shape}
|
||||
type="button"
|
||||
onClick={() => setAvatarShape(shape)}
|
||||
aria-pressed={avatarShape === shape}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center rounded-full px-4 text-ui-13 font-medium transition-colors",
|
||||
avatarShape === shape
|
||||
? "hub-tab-toggle-pill text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{shape === "circle"
|
||||
? t("settings.profile.avatarShapeCircle")
|
||||
: t("settings.profile.avatarShapeRounded")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-label={t("settings.profile.greetingSloth")}
|
||||
className="flex w-full max-w-[560px] items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor="profile-greeting-sloth"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("settings.profile.greetingSloth")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/75">
|
||||
{t("settings.profile.greetingSlothDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-greeting-sloth"
|
||||
checked={showGreetingSloth}
|
||||
onCheckedChange={setShowGreetingSloth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[560px] flex-col gap-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("settings.profile.chooseSloth")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-7 gap-2 sm:grid-cols-9">
|
||||
{SLOTH_AVATARS.map((path) => {
|
||||
const url = publicAssetUrl(path);
|
||||
const selected = shownAvatar === url;
|
||||
const label =
|
||||
path.split("/").pop()?.replace(/\.png$/i, "").replace(/^large\s+/i, "").trim() ??
|
||||
"sloth";
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={() => pickSloth(path)}
|
||||
aria-pressed={selected}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
// No transition here: animating the ring makes the old
|
||||
// icon's selection border linger when switching sloths.
|
||||
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
|
||||
// Selection keeps the 1px weight, only darker.
|
||||
selected && "ring-ring-strong hover:ring-ring-strong",
|
||||
)}
|
||||
>
|
||||
<img src={url} alt="" loading="lazy" className="size-full object-cover" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center gap-10 py-6 pr-2">
|
||||
<div className="relative shrink-0">
|
||||
{/* The picture itself is the shortcut to "upload a photo"; the pencil
|
||||
opens the rest of the options. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickAvatarValue(null)}
|
||||
aria-pressed={shownAvatar === null}
|
||||
aria-label={t("settings.profile.noPicture")}
|
||||
title={t("settings.profile.noPicture")}
|
||||
className={cn(
|
||||
"relative flex aspect-square items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
|
||||
shownAvatar === null && "ring-ring-strong hover:ring-ring-strong",
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label={t("settings.profile.changePicture")}
|
||||
className="group relative block rounded-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="text-ui-11 font-medium">
|
||||
{t("settings.profile.noneLabel")}
|
||||
<UserAvatar
|
||||
name={previewName}
|
||||
imageUrl={shownAvatar}
|
||||
size="lg"
|
||||
className="size-[128px] text-[calc(3.2rem*var(--ui-font-scale,1))]"
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/45 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<HugeiconsIcon
|
||||
icon={Image01Icon}
|
||||
className="size-8 text-white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.profile.pictureOptions")}
|
||||
title={t("settings.profile.pictureOptions")}
|
||||
className="absolute top-[85.36%] left-[85.36%] flex size-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:border-transparent dark:bg-white/[0.14] dark:hover:bg-white/20"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Edit03Icon}
|
||||
className="size-4.5"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[320px] gap-4 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-ui-11 font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("settings.profile.avatarShape")}
|
||||
</span>
|
||||
<div className="hub-tab-toggle flex h-8 shrink-0 items-center rounded-full">
|
||||
{(["circle", "rounded"] as const).map((shape) => (
|
||||
<button
|
||||
key={shape}
|
||||
type="button"
|
||||
onClick={() => setAvatarShape(shape)}
|
||||
aria-pressed={avatarShape === shape}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center justify-center rounded-full px-3.5 text-ui-13 font-medium transition-colors",
|
||||
avatarShape === shape
|
||||
? "hub-tab-toggle-pill text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{shape === "circle"
|
||||
? t("settings.profile.avatarShapeCircle")
|
||||
: t("settings.profile.avatarShapeRounded")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="h-9 w-fit gap-2 rounded-full px-4 text-sm"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Upload01Icon}
|
||||
className="size-4"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{t("settings.profile.uploadPhoto")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => pickAvatarValue(null)}
|
||||
disabled={shownAvatar === null}
|
||||
aria-label={t("settings.profile.removePhoto")}
|
||||
title={t("settings.profile.removePhoto")}
|
||||
className="size-9 shrink-0 rounded-full p-0 text-muted-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Delete02Icon}
|
||||
className="size-4"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-ui-11 font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("settings.profile.chooseSloth")}
|
||||
</span>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{SLOTH_AVATARS.map((path) => {
|
||||
const url = publicAssetUrl(path);
|
||||
const selected = shownAvatar === url;
|
||||
const label =
|
||||
path
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(PNG_SUFFIX, "")
|
||||
.replace(SLOTH_NAME, "")
|
||||
.trim() ?? "sloth";
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={() => pickAvatarValue(url)}
|
||||
aria-pressed={selected}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
// No transition here: animating the ring makes the old
|
||||
// icon's selection border linger when switching sloths.
|
||||
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
|
||||
selected &&
|
||||
"ring-2 ring-ring-strong hover:ring-ring-strong",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Name fields sit beside the picture. These are not SettingsRows, so
|
||||
data-settings-label is set by hand for settings search. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div
|
||||
data-settings-label={t("settings.profile.displayName")}
|
||||
className="flex min-w-0 flex-col gap-1.5"
|
||||
>
|
||||
<Label
|
||||
htmlFor="profile-display-name"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("settings.profile.displayName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-display-name"
|
||||
type="text"
|
||||
value={draftName}
|
||||
maxLength={PROFILE_TEXT_MAX_LENGTH}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder={sessionSub || "Unsloth"}
|
||||
className="h-9 w-full rounded-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-label={t("settings.profile.nickname")}
|
||||
className="flex min-w-0 flex-col gap-1.5"
|
||||
>
|
||||
<Label
|
||||
htmlFor="profile-nickname"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("settings.profile.nickname")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-nickname"
|
||||
type="text"
|
||||
value={draftNickname}
|
||||
maxLength={PROFILE_TEXT_MAX_LENGTH}
|
||||
onChange={(e) => setDraftNickname(e.target.value)}
|
||||
onBlur={saveNickname}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder={t("settings.profile.nicknamePlaceholder")}
|
||||
className="h-9 w-full rounded-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{imageError ? (
|
||||
<p className="w-full text-xs text-destructive" role="alert">
|
||||
<p className="pt-2 text-xs text-destructive" role="alert">
|
||||
{imageError}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useT } from "@/i18n";
|
||||
import type { ProfileStats } from "../../api/profile-stats";
|
||||
import {
|
||||
formatCompactNumber,
|
||||
formatDuration,
|
||||
formatFullNumber,
|
||||
formatMilliseconds,
|
||||
} from "../../utils/stats-format";
|
||||
import { StatMeter, StatRow, StatsCard } from "./stat-primitives";
|
||||
|
||||
/** Left column: the "how you use Unsloth" numbers. */
|
||||
export function ActivityInsightsCard({ stats }: { stats: ProfileStats }) {
|
||||
const t = useT();
|
||||
const { totals, speed } = stats;
|
||||
const averageTokensPerChat =
|
||||
totals.threads > 0 ? totals.totalTokens / totals.threads : 0;
|
||||
const cacheShare =
|
||||
totals.promptTokens > 0 ? totals.cachedTokens / totals.promptTokens : 0;
|
||||
|
||||
return (
|
||||
<StatsCard title={t("settings.profile.stats.insightsTitle")}>
|
||||
<div className="flex flex-col divide-y divide-border/60">
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.totalChats")}
|
||||
value={formatFullNumber(totals.threads)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.totalMessages")}
|
||||
value={formatFullNumber(totals.messages)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.tokensIn")}
|
||||
value={formatCompactNumber(totals.promptTokens)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.tokensOut")}
|
||||
value={formatCompactNumber(totals.completionTokens)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.cachedTokens")}
|
||||
value={
|
||||
cacheShare > 0
|
||||
? t("settings.profile.stats.cachedValue", {
|
||||
tokens: formatCompactNumber(totals.cachedTokens),
|
||||
percent: Math.round(cacheShare * 100),
|
||||
})
|
||||
: formatCompactNumber(totals.cachedTokens)
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.avgTokensPerChat")}
|
||||
value={formatCompactNumber(averageTokensPerChat)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.timeInChat")}
|
||||
value={formatDuration(totals.chatSeconds)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.activeDays")}
|
||||
value={formatFullNumber(totals.activeDays)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.toolCalls")}
|
||||
value={formatFullNumber(totals.toolCalls)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.attachments")}
|
||||
value={formatFullNumber(totals.attachments)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.avgSpeed")}
|
||||
value={
|
||||
speed.averageTokensPerSecond === null
|
||||
? "—"
|
||||
: t("settings.profile.stats.tokensPerSecond", {
|
||||
value: speed.averageTokensPerSecond.toFixed(1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.bestSpeed")}
|
||||
value={
|
||||
speed.bestTokensPerSecond === null
|
||||
? "—"
|
||||
: t("settings.profile.stats.tokensPerSecond", {
|
||||
value: speed.bestTokensPerSecond.toFixed(1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("settings.profile.stats.firstToken")}
|
||||
value={
|
||||
speed.averageFirstTokenMs === null
|
||||
? "—"
|
||||
: formatMilliseconds(speed.averageFirstTokenMs)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</StatsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right column: model leaderboard, ranked by tokens exchanged. */
|
||||
export function TopModelsCard({ stats }: { stats: ProfileStats }) {
|
||||
const t = useT();
|
||||
const models = stats.models;
|
||||
const peak = models.reduce((max, model) => Math.max(max, model.tokens), 0);
|
||||
|
||||
return (
|
||||
<StatsCard
|
||||
title={t("settings.profile.stats.topModelsTitle")}
|
||||
description={t("settings.profile.stats.topModelsDescription")}
|
||||
>
|
||||
{models.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t("settings.profile.stats.noModels")}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{models.map((model, index) => (
|
||||
<li key={model.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="w-4 shrink-0 text-ui-11 tabular-nums text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-sm text-foreground"
|
||||
title={model.id}
|
||||
>
|
||||
{model.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{t("settings.profile.stats.modelSummary", {
|
||||
tokens: formatCompactNumber(model.tokens),
|
||||
messages: formatFullNumber(model.messages),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<StatMeter progress={peak > 0 ? model.tokens / peak : 0} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</StatsCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/i18n";
|
||||
import { useProfileStats } from "../../hooks/use-profile-stats";
|
||||
import { ActivityInsightsCard, TopModelsCard } from "./insights-card";
|
||||
import { StatsCard } from "./stat-primitives";
|
||||
import { StatsHighlights } from "./stats-highlights";
|
||||
import { StatsSkeleton } from "./stats-skeleton";
|
||||
import { TokenActivityCard } from "./token-activity-card";
|
||||
import { TrainingHighlightsCard } from "./training-card";
|
||||
|
||||
/**
|
||||
* Everything below the personalization form on the Profile tab: headline
|
||||
* numbers, activity grid, insights and training.
|
||||
*
|
||||
* All of it comes from `/api/profile/stats`, which reads local history only.
|
||||
*
|
||||
* Loaded lazily by `profile-stats-panel.tsx` so none of it reaches the main
|
||||
* bundle, since the Profile tab is the only place it renders.
|
||||
*/
|
||||
export function ProfileStatsContent() {
|
||||
const t = useT();
|
||||
const { stats, loading, error, reload } = useProfileStats();
|
||||
|
||||
if (loading && stats === null) {
|
||||
return <StatsSkeleton />;
|
||||
}
|
||||
|
||||
if (error !== null && stats === null) {
|
||||
return (
|
||||
<StatsCard title={t("settings.profile.stats.title")}>
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">{error}</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={reload}>
|
||||
{t("settings.profile.stats.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</StatsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats === null) return null;
|
||||
|
||||
const hasChats = stats.totals.messages > 0;
|
||||
const hasTraining = stats.training.runs > 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex flex-col gap-0.5">
|
||||
<h2
|
||||
data-settings-label={t("settings.profile.stats.title")}
|
||||
className="text-base font-semibold font-heading text-foreground"
|
||||
>
|
||||
{t("settings.profile.stats.title")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.profile.stats.subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<StatsHighlights stats={stats} />
|
||||
|
||||
{hasChats ? (
|
||||
<>
|
||||
<TokenActivityCard daily={stats.daily} />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ActivityInsightsCard stats={stats} />
|
||||
<TopModelsCard stats={stats} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<StatsCard>
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t("settings.profile.stats.emptyChats")}
|
||||
</p>
|
||||
</StatsCard>
|
||||
)}
|
||||
|
||||
{hasTraining ? <TrainingHighlightsCard stats={stats} /> : null}
|
||||
|
||||
<p className="px-1 pb-2 text-ui-11 text-muted-foreground">
|
||||
{t("settings.profile.stats.privacyNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Suspense, lazy } from "react";
|
||||
import { StatsSkeleton } from "./stats-skeleton";
|
||||
|
||||
// The stats content pulls in recharts for the rhythm charts. Settings live in
|
||||
// the main bundle, so importing it eagerly would move ~300 KB of charting off
|
||||
// its own lazy chunk and onto every cold app load. Split it here instead: the
|
||||
// chunk is fetched only when someone actually opens Settings -> Profile.
|
||||
const ProfileStatsContent = lazy(() =>
|
||||
import("./profile-stats-content").then((module) => ({
|
||||
default: module.ProfileStatsContent,
|
||||
})),
|
||||
);
|
||||
|
||||
export function ProfileStatsPanel() {
|
||||
return (
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<ProfileStatsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Bordered surface every stats block sits on, matching the profile card. */
|
||||
export function StatsCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
{...(title ? { "data-settings-label": title } : {})}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-4 rounded-2xl border border-border bg-background dark:border-transparent dark:bg-white/[0.06] px-5 py-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h3 className="text-sm font-semibold font-heading text-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Big number + caption, used across the highlight and training rows. */
|
||||
export function StatTile({
|
||||
value,
|
||||
label,
|
||||
hint,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-settings-label={label}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col items-center gap-0.5 px-2 py-1 text-center",
|
||||
className,
|
||||
)}
|
||||
{...(hint ? { title: hint } : {})}
|
||||
>
|
||||
<span className="text-xl font-semibold font-heading tabular-nums text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label left, value right: the "Activity insights" rows. */
|
||||
export function StatRow({
|
||||
label,
|
||||
value,
|
||||
emphasis,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
emphasis?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-settings-label={label}
|
||||
className="flex items-center justify-between gap-4 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-sm tabular-nums",
|
||||
emphasis ? "font-semibold text-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin progress track (level bar, achievement progress, model share). */
|
||||
export function StatMeter({
|
||||
progress,
|
||||
className,
|
||||
tone = "primary",
|
||||
}: {
|
||||
progress: number;
|
||||
className?: string;
|
||||
tone?: "primary" | "muted";
|
||||
}) {
|
||||
const clamped = Math.min(
|
||||
1,
|
||||
Math.max(0, Number.isFinite(progress) ? progress : 0),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-500",
|
||||
tone === "primary" ? "bg-primary" : "bg-muted-foreground/50",
|
||||
)}
|
||||
style={{ width: `${clamped * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useT } from "@/i18n";
|
||||
import type { ProfileStats } from "../../api/profile-stats";
|
||||
import {
|
||||
formatCompactNumber,
|
||||
formatDuration,
|
||||
formatFullNumber,
|
||||
} from "../../utils/stats-format";
|
||||
import { StatTile } from "./stat-primitives";
|
||||
|
||||
/** The five headline numbers, mirroring the app's top-of-profile summary. */
|
||||
export function StatsHighlights({ stats }: { stats: ProfileStats }) {
|
||||
const t = useT();
|
||||
const { totals, streak, peakDay, longestChat } = stats;
|
||||
const days = (count: number) =>
|
||||
count === 1
|
||||
? t("settings.profile.stats.dayCountOne")
|
||||
: t("settings.profile.stats.dayCount", { count });
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-y-5 rounded-2xl border border-border bg-background dark:border-transparent dark:bg-white/[0.06] px-4 py-5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatTile
|
||||
value={formatCompactNumber(totals.totalTokens)}
|
||||
label={t("settings.profile.stats.lifetimeTokens")}
|
||||
hint={formatFullNumber(totals.totalTokens)}
|
||||
/>
|
||||
<StatTile
|
||||
value={peakDay ? formatCompactNumber(peakDay.tokens) : "—"}
|
||||
label={t("settings.profile.stats.peakTokens")}
|
||||
{...(peakDay ? { hint: peakDay.date } : {})}
|
||||
/>
|
||||
<StatTile
|
||||
value={longestChat ? formatDuration(longestChat.seconds) : "—"}
|
||||
label={t("settings.profile.stats.longestChat")}
|
||||
{...(longestChat?.title ? { hint: longestChat.title } : {})}
|
||||
/>
|
||||
<StatTile
|
||||
value={days(streak.current)}
|
||||
label={t("settings.profile.stats.currentStreak")}
|
||||
/>
|
||||
<StatTile
|
||||
value={days(streak.longest)}
|
||||
label={t("settings.profile.stats.longestStreak")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for the stats panel.
|
||||
*
|
||||
* Its own module so the lazy wrapper can render it as a Suspense fallback
|
||||
* without pulling the chart-bearing content chunk into the main bundle.
|
||||
*/
|
||||
export function StatsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
<Skeleton className="h-56 w-full rounded-2xl" />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-72 w-full rounded-2xl" />
|
||||
<Skeleton className="h-72 w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useLocale, useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ProfileStatsDay } from "../../api/profile-stats";
|
||||
import {
|
||||
type ActivityMode,
|
||||
formatCompactNumber,
|
||||
formatFullNumber,
|
||||
heatLevel,
|
||||
parseDayKey,
|
||||
seriesForMode,
|
||||
windowBaseline,
|
||||
} from "../../utils/stats-format";
|
||||
import { StatsCard } from "./stat-primitives";
|
||||
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const CELL_SIZE = 11;
|
||||
const CELL_GAP = 3;
|
||||
const COLUMN_WIDTH = CELL_SIZE + CELL_GAP;
|
||||
const MIN_COLUMNS = 8;
|
||||
const HEAT_OPACITY = [0, 0.4, 0.62, 0.8, 1] as const;
|
||||
// Weekly and cumulative are on/off, so they use one flat shade.
|
||||
const SOLID_LEVEL = 4;
|
||||
const MODES: ActivityMode[] = ["daily", "weekly", "cumulative"];
|
||||
|
||||
type Cell = {
|
||||
key: string;
|
||||
day: ProfileStatsDay | null;
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trim the series to the most recent `columns` weeks, ending on a partial
|
||||
* current week. The grid never scrolls, so older days fall off the left.
|
||||
*/
|
||||
function buildColumns(
|
||||
daily: ProfileStatsDay[],
|
||||
values: number[],
|
||||
columns: number,
|
||||
mode: ActivityMode,
|
||||
): Cell[][] {
|
||||
if (daily.length === 0 || columns <= 0) return [];
|
||||
|
||||
const lastDay = daily.at(-1);
|
||||
if (!lastDay) return [];
|
||||
// Days after today in the final (partial) week.
|
||||
const trailing =
|
||||
DAYS_PER_WEEK -
|
||||
1 -
|
||||
((parseDayKey(lastDay.date).getDay() + 6) % DAYS_PER_WEEK);
|
||||
const capacity = columns * DAYS_PER_WEEK - trailing;
|
||||
const start = Math.max(0, daily.length - capacity);
|
||||
const visible = daily.slice(start);
|
||||
// Cumulative is a running total over what the grid shows, so a narrow card
|
||||
// that drops older weeks has to rebase off the last hidden day.
|
||||
const baseline = windowBaseline(values, start, mode);
|
||||
|
||||
const cells: Cell[] = [];
|
||||
// Pad so every column is a Monday-started week.
|
||||
const firstVisible = visible[0];
|
||||
if (!firstVisible) return [];
|
||||
const leading = (parseDayKey(firstVisible.date).getDay() + 6) % DAYS_PER_WEEK;
|
||||
for (let index = 0; index < leading; index += 1) {
|
||||
cells.push({ key: `pad-${index}`, day: null, value: 0 });
|
||||
}
|
||||
for (const [index, day] of visible.entries()) {
|
||||
cells.push({
|
||||
key: day.date,
|
||||
day,
|
||||
value: (values[start + index] ?? 0) - baseline,
|
||||
});
|
||||
}
|
||||
|
||||
const grid: Cell[][] = [];
|
||||
for (let index = 0; index < cells.length; index += DAYS_PER_WEEK) {
|
||||
grid.push(cells.slice(index, index + DAYS_PER_WEEK));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** Month captions under the grid, one per column where the month turns over. */
|
||||
function buildMonthLabels(grid: Cell[][], locale: string) {
|
||||
const formatter = new Intl.DateTimeFormat(locale, { month: "short" });
|
||||
const labels: Array<{ key: string; column: number; text: string }> = [];
|
||||
let lastMonth = -1;
|
||||
for (const [columnIndex, column] of grid.entries()) {
|
||||
const firstDay = column.find((cell) => cell.day !== null)?.day;
|
||||
if (!firstDay) continue;
|
||||
const date = parseDayKey(firstDay.date);
|
||||
if (date.getMonth() === lastMonth) continue;
|
||||
lastMonth = date.getMonth();
|
||||
// Skip a label that would collide with the previous one, or run off the end.
|
||||
const previous = labels.at(-1);
|
||||
if (previous && columnIndex - previous.column < 3) continue;
|
||||
if (columnIndex > grid.length - 3) continue;
|
||||
labels.push({
|
||||
key: firstDay.date,
|
||||
column: columnIndex,
|
||||
text: formatter.format(date),
|
||||
});
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-column totals for the bar modes. Shading every day of an active week
|
||||
* instead would fill the grid solid and hide the shape.
|
||||
*/
|
||||
function columnSummary(column: Cell[]) {
|
||||
let value = 0;
|
||||
let tokens = 0;
|
||||
let firstDay: string | null = null;
|
||||
for (const cell of column) {
|
||||
if (!cell.day) continue;
|
||||
value = Math.max(value, cell.value);
|
||||
tokens += cell.day.tokens;
|
||||
firstDay ??= cell.day.date;
|
||||
}
|
||||
return { value, tokens, firstDay };
|
||||
}
|
||||
|
||||
/** Bar height in cells, at least one for any activity. */
|
||||
function barHeight(value: number, peakValue: number): number {
|
||||
if (value <= 0 || peakValue <= 0) return 0;
|
||||
return Math.max(1, Math.round((value / peakValue) * DAYS_PER_WEEK));
|
||||
}
|
||||
|
||||
/** How many week columns fit the card's current width. */
|
||||
function useVisibleColumns(maxColumns: number) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [columns, setColumns] = useState(maxColumns);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const measure = () => {
|
||||
const width = element.clientWidth;
|
||||
if (width <= 0) return;
|
||||
// The final column carries no trailing gap.
|
||||
const fits = Math.floor((width + CELL_GAP) / COLUMN_WIDTH);
|
||||
setColumns(Math.max(MIN_COLUMNS, Math.min(maxColumns, fits)));
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [maxColumns]);
|
||||
|
||||
return { ref, columns };
|
||||
}
|
||||
|
||||
const CELL_CLASS = "size-[11px] rounded-[3px]";
|
||||
|
||||
function Block({
|
||||
title,
|
||||
tone,
|
||||
}: { title: string; tone: 0 | 1 | 2 | 3 | 4 | -1 }) {
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
className={cn(
|
||||
CELL_CLASS,
|
||||
tone === -1
|
||||
? "bg-transparent"
|
||||
: tone === 0
|
||||
? "bg-muted-foreground/12"
|
||||
: "bg-primary",
|
||||
)}
|
||||
style={
|
||||
tone > 0 ? { opacity: HEAT_OPACITY[tone as 1 | 2 | 3 | 4] } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Daily: each day shaded by its own volume. */
|
||||
function DayColumn({
|
||||
column,
|
||||
peak,
|
||||
dateFormatter,
|
||||
}: {
|
||||
column: Cell[];
|
||||
peak: number;
|
||||
dateFormatter: Intl.DateTimeFormat;
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{column.map((cell) => {
|
||||
if (!cell.day) {
|
||||
return <Block key={cell.key} title="" tone={-1} />;
|
||||
}
|
||||
return (
|
||||
<Block
|
||||
key={cell.key}
|
||||
tone={heatLevel(cell.value, peak)}
|
||||
title={t("settings.profile.stats.cellTooltip", {
|
||||
tokens: formatFullNumber(cell.day.tokens),
|
||||
messages: formatFullNumber(cell.day.messages),
|
||||
date: dateFormatter.format(parseDayKey(cell.day.date)),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Weekly and cumulative: one flat bar per column, anchored to the bottom. */
|
||||
function BarColumn({
|
||||
column,
|
||||
peak,
|
||||
dateFormatter,
|
||||
}: {
|
||||
column: Cell[];
|
||||
peak: number;
|
||||
dateFormatter: Intl.DateTimeFormat;
|
||||
}) {
|
||||
const t = useT();
|
||||
const summary = columnSummary(column);
|
||||
const height = barHeight(summary.value, peak);
|
||||
// The bar is scaled by summary.value, which in cumulative mode is the
|
||||
// running total. The tooltip says "week of", so it reports that week.
|
||||
const title = summary.firstDay
|
||||
? t("settings.profile.stats.weekTooltip", {
|
||||
date: dateFormatter.format(parseDayKey(summary.firstDay)),
|
||||
tokens: formatFullNumber(summary.tokens),
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{Array.from({ length: DAYS_PER_WEEK }, (_, row) => (
|
||||
<Block
|
||||
key={column[row]?.key ?? `slot-${row}`}
|
||||
title={title}
|
||||
tone={row >= DAYS_PER_WEEK - height ? SOLID_LEVEL : 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) {
|
||||
const t = useT();
|
||||
const [mode, setMode] = useState<ActivityMode>("daily");
|
||||
const maxColumns = Math.ceil(daily.length / DAYS_PER_WEEK) + 1;
|
||||
const { ref, columns } = useVisibleColumns(maxColumns);
|
||||
|
||||
const shaded = mode === "daily";
|
||||
const values = useMemo(() => seriesForMode(daily, mode), [daily, mode]);
|
||||
const grid = useMemo(
|
||||
() => buildColumns(daily, values, columns, mode),
|
||||
[daily, values, columns, mode],
|
||||
);
|
||||
// The app language, not the browser's: those differ whenever the user picks
|
||||
// a language in Settings, and it has to be a dependency so switching while
|
||||
// the panel is open rebuilds the formatters.
|
||||
const locale = useLocale();
|
||||
const monthLabels = useMemo(
|
||||
() => buildMonthLabels(grid, locale),
|
||||
[grid, locale],
|
||||
);
|
||||
// Daily scales against the busiest day, the bar modes against the busiest
|
||||
// column, so a full-height bar always means the peak week.
|
||||
const peak = useMemo(
|
||||
() =>
|
||||
shaded
|
||||
? grid.reduce(
|
||||
(max, column) =>
|
||||
column.reduce(
|
||||
(best, cell) => Math.max(best, cell.day?.tokens ?? 0),
|
||||
max,
|
||||
),
|
||||
0,
|
||||
)
|
||||
: grid.reduce(
|
||||
(max, column) => Math.max(max, columnSummary(column).value),
|
||||
0,
|
||||
),
|
||||
[grid, shaded],
|
||||
);
|
||||
const visibleTotal = useMemo(
|
||||
() =>
|
||||
grid.reduce(
|
||||
(sum, column) =>
|
||||
column.reduce((total, cell) => total + (cell.day?.tokens ?? 0), sum),
|
||||
0,
|
||||
),
|
||||
[grid],
|
||||
);
|
||||
|
||||
const dateFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return (
|
||||
<StatsCard
|
||||
title={t("settings.profile.stats.activityTitle")}
|
||||
description={t("settings.profile.stats.activityDescription", {
|
||||
total: formatCompactNumber(visibleTotal),
|
||||
weeks: grid.length,
|
||||
})}
|
||||
action={
|
||||
<div className="hub-tab-toggle inline-flex h-8 w-fit items-center rounded-full">
|
||||
{MODES.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setMode(option)}
|
||||
aria-pressed={mode === option}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center rounded-full px-3 text-ui-13 font-medium transition-colors",
|
||||
mode === option
|
||||
? "hub-tab-toggle-pill text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`settings.profile.stats.mode.${option}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Measured, never scrolled: the grid is trimmed to fit instead. */}
|
||||
<div ref={ref} className="w-full overflow-hidden">
|
||||
<div className="flex gap-[3px]">
|
||||
{grid.map((column) =>
|
||||
shaded ? (
|
||||
<DayColumn
|
||||
key={column[0]?.key ?? "column"}
|
||||
column={column}
|
||||
peak={peak}
|
||||
dateFormatter={dateFormatter}
|
||||
/>
|
||||
) : (
|
||||
<BarColumn
|
||||
key={column[0]?.key ?? "column"}
|
||||
column={column}
|
||||
peak={peak}
|
||||
dateFormatter={dateFormatter}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2 h-4">
|
||||
{monthLabels.map((label) => (
|
||||
<span
|
||||
key={label.key}
|
||||
className="absolute top-0 text-ui-11 text-muted-foreground"
|
||||
style={{ left: `${label.column * COLUMN_WIDTH}px` }}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</StatsCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProfileStats } from "../../api/profile-stats";
|
||||
import {
|
||||
formatCompactNumber,
|
||||
formatDuration,
|
||||
formatFullNumber,
|
||||
} from "../../utils/stats-format";
|
||||
import { StatTile, StatsCard } from "./stat-primitives";
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
completed: "text-primary",
|
||||
running: "text-foreground",
|
||||
error: "text-destructive",
|
||||
stopped: "text-muted-foreground",
|
||||
};
|
||||
|
||||
/** Training-side counterpart to the chat stats: runs, steps, GPU time, loss. */
|
||||
export function TrainingHighlightsCard({ stats }: { stats: ProfileStats }) {
|
||||
const t = useT();
|
||||
const { training } = stats;
|
||||
|
||||
return (
|
||||
<StatsCard
|
||||
title={t("settings.profile.stats.trainingTitle")}
|
||||
description={t("settings.profile.stats.trainingDescription")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatTile
|
||||
value={formatFullNumber(training.runs)}
|
||||
label={t("settings.profile.stats.trainingRuns")}
|
||||
/>
|
||||
<StatTile
|
||||
value={formatFullNumber(training.completed)}
|
||||
label={t("settings.profile.stats.trainingCompleted")}
|
||||
/>
|
||||
<StatTile
|
||||
value={formatCompactNumber(training.steps)}
|
||||
label={t("settings.profile.stats.trainingSteps")}
|
||||
/>
|
||||
<StatTile
|
||||
value={formatCompactNumber(training.tokens)}
|
||||
label={t("settings.profile.stats.trainingTokens")}
|
||||
/>
|
||||
<StatTile
|
||||
value={formatDuration(training.seconds)}
|
||||
label={t("settings.profile.stats.trainingTime")}
|
||||
/>
|
||||
<StatTile
|
||||
value={
|
||||
training.bestLoss === null ? "—" : training.bestLoss.toFixed(3)
|
||||
}
|
||||
label={t("settings.profile.stats.bestLoss")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{training.recent.length > 0 ? (
|
||||
<ul className="flex flex-col divide-y divide-border/60 border-t border-border/60 pt-1">
|
||||
{training.recent.map((run) => (
|
||||
<li
|
||||
key={run.id}
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{/* A renamed run leads with the name the user chose, so the
|
||||
model moves down beside the dataset to stay visible. */}
|
||||
<span
|
||||
className="min-w-0 truncate text-sm text-foreground"
|
||||
title={run.name}
|
||||
>
|
||||
{run.name}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-ui-11 text-muted-foreground">
|
||||
{run.name === run.modelLabel
|
||||
? run.datasetLabel
|
||||
: `${run.modelLabel} · ${run.datasetLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-4 text-xs tabular-nums">
|
||||
<span className="text-muted-foreground">
|
||||
{t("settings.profile.stats.runSteps", {
|
||||
steps: formatFullNumber(run.steps),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{run.finalLoss === null
|
||||
? "—"
|
||||
: t("settings.profile.stats.runLoss", {
|
||||
loss: run.finalLoss.toFixed(3),
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-16 text-right",
|
||||
STATUS_TONE[run.status] ?? "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</StatsCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,14 +30,27 @@ const SHAPE: Record<AvatarShape, string> = {
|
|||
rounded: "rounded-[22%]",
|
||||
};
|
||||
|
||||
export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) {
|
||||
export function UserAvatar({
|
||||
name,
|
||||
imageUrl,
|
||||
size,
|
||||
className,
|
||||
shape,
|
||||
}: UserAvatarProps) {
|
||||
const label = initialsFromName(name);
|
||||
const storedShape = useUserProfileStore((s) => s.avatarShape);
|
||||
const shapeClass = SHAPE[shape ?? storedShape];
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<span className={cn("relative inline-flex shrink-0 overflow-hidden bg-transparent", shapeClass, SIZE[size], className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 overflow-hidden bg-transparent",
|
||||
shapeClass,
|
||||
SIZE[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="size-full object-cover" />
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@ export function usePersonalizationSync(enabled: boolean): void {
|
|||
// default so the push detects the diff) rather than treating the
|
||||
// default as an explicit remote choice. A record that actually stored
|
||||
// the field reports <field>Saved=true and still wins.
|
||||
const localGreeting = useUserProfileStore.getState().showGreetingSloth;
|
||||
const localGreeting =
|
||||
useUserProfileStore.getState().showGreetingSloth;
|
||||
const remoteGreeting = remote.profile.showGreetingSloth !== false;
|
||||
const keepLocalGreeting =
|
||||
remote.greetingSlothSaved === false && localGreeting === false;
|
||||
|
|
@ -248,7 +249,9 @@ export function usePersonalizationSync(enabled: boolean): void {
|
|||
avatarDataUrl: remote.profile.avatarDataUrl ?? null,
|
||||
avatarShape:
|
||||
remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
|
||||
showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting,
|
||||
showGreetingSloth: keepLocalGreeting
|
||||
? localGreeting
|
||||
: remoteGreeting,
|
||||
};
|
||||
const nextTheme = remote.appearance.theme;
|
||||
const localPalette = latestPaletteRef.current;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type ProfileStats, loadProfileStats } from "../api/profile-stats";
|
||||
|
||||
type ProfileStatsState = {
|
||||
stats: ProfileStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
/** Load the profile stats on mount, with a manual refresh. */
|
||||
export function useProfileStats(): ProfileStatsState {
|
||||
const [stats, setStats] = useState<ProfileStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A refresh aborts the in-flight request so a slow first load cannot land
|
||||
// after (and overwrite) the newer one.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await loadProfileStats(controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setStats(next);
|
||||
setError(null);
|
||||
} catch (cause: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
return () => abortRef.current?.abort();
|
||||
}, [load]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { stats, loading, error, reload };
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { ProfilePersonalizationPanel } from "./components/profile-personalization-panel";
|
||||
export { ProfileStatsPanel } from "./components/stats/profile-stats-panel";
|
||||
export { UserAvatar } from "./components/user-avatar";
|
||||
export { useEffectiveProfile } from "./hooks/use-effective-profile";
|
||||
export { usePersonalizationSync } from "./hooks/use-personalization-sync";
|
||||
export { useUserProfileStore } from "./stores/user-profile-store";
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ function loadImage(file: File): Promise<HTMLImageElement> {
|
|||
});
|
||||
}
|
||||
|
||||
function canvasHasTransparency(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
function canvasHasTransparency(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
): boolean {
|
||||
const { data } = ctx.getImageData(0, 0, width, height);
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] < 255) return true;
|
||||
|
|
@ -67,7 +71,12 @@ function drawScaled(
|
|||
w: number,
|
||||
h: number,
|
||||
maxEdge: number,
|
||||
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } {
|
||||
): {
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
cw: number;
|
||||
ch: number;
|
||||
} {
|
||||
const scale = Math.min(1, maxEdge / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
|
|
@ -96,16 +105,28 @@ export async function resizeImageFileToDataUrl(file: File): Promise<string> {
|
|||
// would paint a background behind a transparent image.
|
||||
for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) {
|
||||
const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge);
|
||||
const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START);
|
||||
const webpDataUrl = encodeCanvasWithinLimit(
|
||||
canvas,
|
||||
"image/webp",
|
||||
WEBP_QUALITY_START,
|
||||
);
|
||||
if (webpDataUrl) return webpDataUrl;
|
||||
const pngDataUrl = encodePngWithinLimit(canvas);
|
||||
if (pngDataUrl) return pngDataUrl;
|
||||
}
|
||||
throw new Error("Image is still too large after compression. Try a smaller file.");
|
||||
throw new Error(
|
||||
"Image is still too large after compression. Try a smaller file.",
|
||||
);
|
||||
}
|
||||
|
||||
const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START);
|
||||
const jpegDataUrl = encodeCanvasWithinLimit(
|
||||
base.canvas,
|
||||
"image/jpeg",
|
||||
JPEG_QUALITY_START,
|
||||
);
|
||||
if (jpegDataUrl) return jpegDataUrl;
|
||||
|
||||
throw new Error("Image is still too large after compression. Try a smaller file.");
|
||||
throw new Error(
|
||||
"Image is still too large after compression. Try a smaller file.",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
148
studio/frontend/src/features/profile/utils/stats-format.ts
Normal file
148
studio/frontend/src/features/profile/utils/stats-format.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Formatting helpers for the profile stats panel.
|
||||
*
|
||||
* Kept free of React so the numbers can be unit-tested directly.
|
||||
*/
|
||||
|
||||
const TRAILING_ZERO_DECIMAL = /\.0$/;
|
||||
|
||||
/** Compact form used on every stat tile: 12.3K, 4.5M, 19.8B. */
|
||||
export function formatCompactNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
const abs = Math.abs(value);
|
||||
if (abs < 1000) return String(Math.round(value));
|
||||
|
||||
const units: Array<{ limit: number; suffix: string }> = [
|
||||
{ limit: 1e12, suffix: "T" },
|
||||
{ limit: 1e9, suffix: "B" },
|
||||
{ limit: 1e6, suffix: "M" },
|
||||
{ limit: 1e3, suffix: "K" },
|
||||
];
|
||||
for (const [index, { limit, suffix }] of units.entries()) {
|
||||
if (abs < limit) continue;
|
||||
const scaled = value / limit;
|
||||
// One decimal below 100 keeps "1.9B" readable; above it the decimal is noise.
|
||||
const rounded =
|
||||
Math.abs(scaled) >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1));
|
||||
// Rounding can push a value over the next boundary, and "1000K" is not
|
||||
// compact. Step up a unit rather than print four digits.
|
||||
const next = units[index - 1];
|
||||
if (next && Math.abs(rounded) >= 1000) {
|
||||
return `${(value / next.limit).toFixed(1).replace(TRAILING_ZERO_DECIMAL, "")}${next.suffix}`;
|
||||
}
|
||||
const text =
|
||||
Math.abs(scaled) >= 100 ? rounded.toString() : scaled.toFixed(1);
|
||||
return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`;
|
||||
}
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
export function formatFullNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
/** Compact duration for chat and training time: 4h 8m, 12m 30s, 45s. */
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "0m";
|
||||
const total = Math.round(seconds);
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
export function formatMilliseconds(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket a day's tokens into one of five heatmap intensities (0 = empty).
|
||||
* Thresholds are relative to the busiest day so any usage scale looks alive.
|
||||
*/
|
||||
export function heatLevel(tokens: number, peak: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (tokens <= 0) return 0;
|
||||
if (peak <= 0) return 1;
|
||||
const ratio = tokens / peak;
|
||||
if (ratio > 0.6) return 4;
|
||||
if (ratio > 0.3) return 3;
|
||||
if (ratio > 0.1) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Local YYYY-MM-DD, matching the backend's day keys (which use local time). */
|
||||
export function toLocalDayKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** Parse a backend day key as a local date (not UTC, which would shift a day). */
|
||||
export function parseDayKey(key: string): Date {
|
||||
const [year, month, day] = key.split("-").map(Number);
|
||||
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
}
|
||||
|
||||
export type ActivityMode = "daily" | "weekly" | "cumulative";
|
||||
|
||||
/**
|
||||
* Recast the dense daily series for the selected mode. Weekly sums each
|
||||
* calendar week onto its days so the grid shows week-level intensity;
|
||||
* cumulative is the running total across the displayed window, not lifetime,
|
||||
* since the backend caps the series and seeding it with everything older would
|
||||
* flatten every bar against a baseline the grid cannot show.
|
||||
*/
|
||||
/**
|
||||
* What to subtract from a cumulative series once the grid drops older days.
|
||||
* Without it the first visible bar opens at the hidden total and the whole
|
||||
* window flattens against a baseline the user cannot see.
|
||||
*/
|
||||
export function windowBaseline(
|
||||
values: number[],
|
||||
start: number,
|
||||
mode: ActivityMode,
|
||||
): number {
|
||||
if (mode !== "cumulative" || start <= 0) return 0;
|
||||
return values[start - 1] ?? 0;
|
||||
}
|
||||
|
||||
export function seriesForMode(
|
||||
daily: Array<{ date: string; tokens: number }>,
|
||||
mode: ActivityMode,
|
||||
): number[] {
|
||||
if (mode === "daily") return daily.map((day) => day.tokens);
|
||||
|
||||
if (mode === "cumulative") {
|
||||
let running = 0;
|
||||
return daily.map((day) => {
|
||||
running += day.tokens;
|
||||
return running;
|
||||
});
|
||||
}
|
||||
|
||||
// Weekly: every day carries the total of the Monday-started week it sits in.
|
||||
const weekTotals: number[] = [];
|
||||
const weekOfDay: number[] = [];
|
||||
let week = -1;
|
||||
for (const [index, day] of daily.entries()) {
|
||||
const isMonday = parseDayKey(day.date).getDay() === 1;
|
||||
if (index === 0 || isMonday) {
|
||||
week += 1;
|
||||
weekTotals[week] = 0;
|
||||
}
|
||||
weekTotals[week] = (weekTotals[week] ?? 0) + day.tokens;
|
||||
weekOfDay[index] = week;
|
||||
}
|
||||
return weekOfDay.map((weekIndex) => weekTotals[weekIndex] ?? 0);
|
||||
}
|
||||
|
|
@ -63,7 +63,12 @@ interface TabDef {
|
|||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "general", labelKey: "settings.tabs.general", icon: Settings02Icon },
|
||||
{ id: "profile", labelKey: "settings.tabs.profile", icon: UserIcon },
|
||||
{
|
||||
id: "profile",
|
||||
labelKey: "settings.tabs.profile",
|
||||
icon: UserIcon,
|
||||
badgeKey: "common.new",
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
labelKey: "settings.tabs.appearance",
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"settings.profile.description",
|
||||
"settings.profile.displayName",
|
||||
"settings.profile.nickname",
|
||||
"settings.profile.avatarShape",
|
||||
"settings.profile.greetingSloth",
|
||||
// avatarShape lives inside the avatar edit popover, so it has no
|
||||
// always-rendered label for search to scroll to.
|
||||
],
|
||||
appearance: [
|
||||
"settings.appearance.theme.label",
|
||||
|
|
@ -81,6 +81,7 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
chat: [
|
||||
"settings.general.chatDefaults",
|
||||
"settings.general.autoTitleNewChats",
|
||||
"settings.profile.greetingSloth",
|
||||
"settings.chat.artifacts.title",
|
||||
"settings.chat.artifacts.collapseHtmlBlocks",
|
||||
"settings.chat.artifacts.allowNetworkAccess",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
useChatRuntimeStore,
|
||||
usePlusMenuPrefsStore,
|
||||
} from "@/features/chat";
|
||||
import { useUserProfileStore } from "@/features/profile";
|
||||
import { useT } from "@/i18n";
|
||||
import {
|
||||
Bookmark02Icon,
|
||||
|
|
@ -126,6 +127,10 @@ export function ChatTab() {
|
|||
const togglePlusPin = usePlusMenuPrefsStore((state) => state.togglePin);
|
||||
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
|
||||
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
|
||||
const setShowGreetingSloth = useUserProfileStore(
|
||||
(s) => s.setShowGreetingSloth,
|
||||
);
|
||||
const showCanvasMenuItem = useChatRuntimeStore(
|
||||
(state) => state.showCanvasMenuItem,
|
||||
);
|
||||
|
|
@ -276,6 +281,16 @@ export function ChatTab() {
|
|||
>
|
||||
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("settings.profile.greetingSloth")}
|
||||
description={t("settings.profile.greetingSlothDescription")}
|
||||
>
|
||||
<Switch
|
||||
id="profile-greeting-sloth"
|
||||
checked={showGreetingSloth}
|
||||
onCheckedChange={setShowGreetingSloth}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.chat.artifacts.title")}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { ProfilePersonalizationPanel } from "@/features/profile";
|
||||
import {
|
||||
ProfilePersonalizationPanel,
|
||||
ProfileStatsPanel,
|
||||
} from "@/features/profile";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function ProfileTab() {
|
||||
|
|
@ -25,6 +28,7 @@ export function ProfileTab() {
|
|||
</header>
|
||||
|
||||
<ProfilePersonalizationPanel />
|
||||
<ProfileStatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,16 +383,19 @@ export const en = {
|
|||
title: "Profile",
|
||||
description: "How your profile appears in Unsloth.",
|
||||
changePicture: "Change profile picture",
|
||||
uploadPhoto: "Upload photo",
|
||||
removePhoto: "Remove",
|
||||
pictureOptions: "Profile picture options",
|
||||
displayName: "Display name",
|
||||
nickname: "What should Unsloth call you?",
|
||||
nicknamePlaceholder: "Nickname",
|
||||
nicknameSaved: "Preferred name saved",
|
||||
avatarShape: "Profile picture shape",
|
||||
avatarShape: "Avatar shape",
|
||||
avatarShapeCircle: "Circle",
|
||||
avatarShapeRounded: "Rounded",
|
||||
greetingSloth: "Sloth in greeting",
|
||||
greetingSlothDescription: "Show the sloth in the chat greeting.",
|
||||
chooseSloth: "Or pick a sloth profile picture",
|
||||
chooseSloth: "Or pick a sloth",
|
||||
noPicture: "No profile picture",
|
||||
noneLabel: "None",
|
||||
nameSaved: "Profile name saved",
|
||||
|
|
@ -405,6 +408,64 @@ export const en = {
|
|||
"Photo updated for this session, but may not persist after reload.",
|
||||
photoUpdateErrorTitle: "Could not update profile photo",
|
||||
imageUseError: "Could not use this image.",
|
||||
stats: {
|
||||
title: "Your stats",
|
||||
subtitle:
|
||||
"Everything below is counted from your own history. Nothing is collected or sent to Unsloth.",
|
||||
retry: "Try again",
|
||||
privacyNote:
|
||||
"Stats are computed from the chat and training history held by your Unsloth install. Nothing is collected, and nothing is sent to Unsloth or any third party.",
|
||||
emptyChats:
|
||||
"No chats yet. Start a conversation and your stats will fill in here.",
|
||||
lifetimeTokens: "Lifetime tokens",
|
||||
peakTokens: "Peak day",
|
||||
longestChat: "Longest chat",
|
||||
currentStreak: "Current streak",
|
||||
longestStreak: "Longest streak",
|
||||
dayCount: "{count} days",
|
||||
dayCountOne: "1 day",
|
||||
activityTitle: "Token activity",
|
||||
activityDescription: "{total} tokens over the last {weeks} weeks",
|
||||
mode: {
|
||||
daily: "Daily",
|
||||
weekly: "Weekly",
|
||||
cumulative: "Cumulative",
|
||||
},
|
||||
cellTooltip: "{date} · {tokens} tokens, {messages} messages",
|
||||
weekTooltip: "Week of {date} · {tokens} tokens",
|
||||
less: "Less",
|
||||
more: "More",
|
||||
insightsTitle: "Activity insights",
|
||||
totalChats: "Total chats",
|
||||
totalMessages: "Total messages",
|
||||
tokensIn: "Tokens sent",
|
||||
tokensOut: "Tokens generated",
|
||||
cachedTokens: "Cached tokens",
|
||||
cachedValue: "{tokens} ({percent}% of input)",
|
||||
avgTokensPerChat: "Average tokens per chat",
|
||||
timeInChat: "Time in chat",
|
||||
activeDays: "Active days",
|
||||
toolCalls: "Tool calls",
|
||||
attachments: "Files attached",
|
||||
avgSpeed: "Average speed",
|
||||
bestSpeed: "Fastest response",
|
||||
firstToken: "Average time to first token",
|
||||
tokensPerSecond: "{value} tok/s",
|
||||
topModelsTitle: "Most used models",
|
||||
topModelsDescription: "Ranked by tokens exchanged",
|
||||
modelSummary: "{tokens} · {messages} msgs",
|
||||
noModels: "No model usage recorded yet.",
|
||||
trainingTitle: "Training",
|
||||
trainingDescription: "Fine-tuning runs from this workspace",
|
||||
trainingRuns: "Runs",
|
||||
trainingCompleted: "Completed",
|
||||
trainingSteps: "Steps",
|
||||
trainingTokens: "Tokens trained",
|
||||
trainingTime: "Training time",
|
||||
bestLoss: "Best loss",
|
||||
runSteps: "{steps} steps",
|
||||
runLoss: "loss {loss}",
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
|
|
|
|||
113
studio/frontend/tests/profile-stats-format.test.ts
Normal file
113
studio/frontend/tests/profile-stats-format.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
formatCompactNumber,
|
||||
formatDuration,
|
||||
formatMilliseconds,
|
||||
heatLevel,
|
||||
parseDayKey,
|
||||
seriesForMode,
|
||||
windowBaseline,
|
||||
} from "../src/features/profile/utils/stats-format.ts";
|
||||
|
||||
test("compact numbers match the tile format", () => {
|
||||
assert.equal(formatCompactNumber(0), "0");
|
||||
assert.equal(formatCompactNumber(999), "999");
|
||||
assert.equal(formatCompactNumber(1000), "1K");
|
||||
assert.equal(formatCompactNumber(12_340), "12.3K");
|
||||
assert.equal(formatCompactNumber(1_900_000_000), "1.9B");
|
||||
assert.equal(formatCompactNumber(19_800_000_000), "19.8B");
|
||||
// Past 100 of a unit the decimal is noise.
|
||||
assert.equal(formatCompactNumber(123_400), "123K");
|
||||
assert.equal(formatCompactNumber(Number.NaN), "0");
|
||||
});
|
||||
|
||||
test("rounding up a unit steps to the next suffix", () => {
|
||||
// Rounding 999.5K to "1000K" is four digits, which is not compact.
|
||||
assert.equal(formatCompactNumber(999_999), "1M");
|
||||
assert.equal(formatCompactNumber(999_500), "1M");
|
||||
assert.equal(formatCompactNumber(999_999_999), "1B");
|
||||
assert.equal(formatCompactNumber(999_999_999_999), "1T");
|
||||
assert.equal(formatCompactNumber(-999_999), "-1M");
|
||||
// Just below the rounding boundary the unit is unchanged.
|
||||
assert.equal(formatCompactNumber(999_499), "999K");
|
||||
});
|
||||
|
||||
test("durations read the way the header does", () => {
|
||||
assert.equal(formatDuration(0), "0m");
|
||||
assert.equal(formatDuration(45), "45s");
|
||||
assert.equal(formatDuration(90), "1m 30s");
|
||||
assert.equal(formatDuration(14_880), "4h 8m");
|
||||
assert.equal(formatDuration(180_000), "2d 2h");
|
||||
assert.equal(formatMilliseconds(420), "420ms");
|
||||
assert.equal(formatMilliseconds(2500), "2.5s");
|
||||
assert.equal(formatMilliseconds(0), "—");
|
||||
});
|
||||
|
||||
test("heat levels are relative to the busiest day", () => {
|
||||
assert.equal(heatLevel(0, 1000), 0);
|
||||
assert.equal(heatLevel(50, 1000), 1);
|
||||
assert.equal(heatLevel(200, 1000), 2);
|
||||
assert.equal(heatLevel(400, 1000), 3);
|
||||
assert.equal(heatLevel(1000, 1000), 4);
|
||||
// A single active day with no other history still shows up.
|
||||
assert.equal(heatLevel(5, 0), 1);
|
||||
});
|
||||
|
||||
test("day keys parse as local dates, not UTC", () => {
|
||||
const parsed = parseDayKey("2026-03-09");
|
||||
assert.equal(parsed.getFullYear(), 2026);
|
||||
assert.equal(parsed.getMonth(), 2);
|
||||
assert.equal(parsed.getDate(), 9);
|
||||
});
|
||||
|
||||
test("series modes reshape the same daily data", () => {
|
||||
// 2026-03-02 is a Monday, so this spans exactly two calendar weeks.
|
||||
const daily = [
|
||||
{ date: "2026-03-02", tokens: 10 },
|
||||
{ date: "2026-03-03", tokens: 20 },
|
||||
{ date: "2026-03-08", tokens: 5 },
|
||||
{ date: "2026-03-09", tokens: 100 },
|
||||
];
|
||||
|
||||
assert.deepEqual(seriesForMode(daily, "daily"), [10, 20, 5, 100]);
|
||||
assert.deepEqual(seriesForMode(daily, "cumulative"), [10, 30, 35, 135]);
|
||||
// First three days are in the week of Mar 2 (35), Mar 9 starts a new week.
|
||||
assert.deepEqual(seriesForMode(daily, "weekly"), [35, 35, 35, 100]);
|
||||
assert.deepEqual(seriesForMode([], "weekly"), []);
|
||||
});
|
||||
|
||||
test("a trimmed cumulative window rebases off the last hidden day", () => {
|
||||
const daily = [
|
||||
{ date: "2026-01-01", tokens: 1000 },
|
||||
{ date: "2026-01-02", tokens: 2000 },
|
||||
{ date: "2026-01-03", tokens: 5 },
|
||||
{ date: "2026-01-04", tokens: 10 },
|
||||
];
|
||||
const values = seriesForMode(daily, "cumulative");
|
||||
assert.deepEqual(values, [1000, 3000, 3005, 3015]);
|
||||
|
||||
// Showing only the last two days: without rebasing, both bars sit at ~3000
|
||||
// and the 5 vs 10 difference is invisible.
|
||||
const baseline = windowBaseline(values, 2, "cumulative");
|
||||
assert.equal(baseline, 3000);
|
||||
assert.deepEqual(
|
||||
values.slice(2).map((value) => value - baseline),
|
||||
[5, 15],
|
||||
);
|
||||
});
|
||||
|
||||
test("nothing is rebased when the window shows everything", () => {
|
||||
const values = [1, 3, 6];
|
||||
assert.equal(windowBaseline(values, 0, "cumulative"), 0);
|
||||
});
|
||||
|
||||
test("only cumulative rebases: daily and weekly are already per-window", () => {
|
||||
const values = [10, 20, 5];
|
||||
assert.equal(windowBaseline(values, 2, "daily"), 0);
|
||||
assert.equal(windowBaseline(values, 2, "weekly"), 0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue