diff --git a/studio/backend/main.py b/studio/backend/main.py index 02f5a20106..7b53f4dbf2 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -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"]) diff --git a/studio/backend/routes/profile_stats.py b/studio/backend/routes/profile_stats.py new file mode 100644 index 0000000000..ff48646eff --- /dev/null +++ b/studio/backend/routes/profile_stats.py @@ -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 diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py new file mode 100644 index 0000000000..f5c6795ea3 --- /dev/null +++ b/studio/backend/storage/profile_stats_db.py @@ -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 diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py new file mode 100644 index 0000000000..489f53625e --- /dev/null +++ b/studio/backend/tests/test_profile_stats.py @@ -0,0 +1,1006 @@ +# 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 statistics aggregation over local chat/training history.""" + +import json +import time +from datetime import datetime, timedelta, timezone + +import pytest + +from storage import profile_stats_db, studio_db +from storage.profile_stats_db import compute_profile_stats, invalidate_profile_stats_cache + + +@pytest.fixture +def stats_db(tmp_path, monkeypatch): + monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path)) + monkeypatch.setenv("UNSLOTH_STUDIO_PROJECTS_HOME", str(tmp_path / "Projects")) + monkeypatch.setattr(studio_db, "_schema_ready", False) + invalidate_profile_stats_cache() + yield + invalidate_profile_stats_cache() + + +# _seed_thread writes the assistant reply this far after the user turn, and +# fork_chat_thread copies created_at verbatim, so clones must reuse it. +REPLY_DELAY = timedelta(seconds = 10) + + +def _ms(when: datetime) -> int: + return int(when.timestamp() * 1000) + + +def _seed_thread(conn, thread_id: str, model_id: str, turns: list[tuple[datetime, dict]]): + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES (?, ?, 'base', ?, ?, ?)", + (thread_id, f"Thread {thread_id}", model_id, _ms(turns[0][0]), _ms(turns[-1][0])), + ) + for index, (when, metadata) in enumerate(turns): + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + f"{thread_id}-u{index}", + thread_id, + "user", + json.dumps([{"type": "text", "text": "hi"}]), + None, + _ms(when), + ), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + f"{thread_id}-a{index}", + thread_id, + "assistant", + json.dumps([{"type": "text", "text": "hello"}]), + json.dumps(metadata), + _ms(when + REPLY_DELAY), + ), + ) + + +def _metadata( + prompt: int, + completion: int, + *, + speed: float = 40.0, + tools: int = 0, +) -> dict: + return { + "contextUsage": { + "promptTokens": prompt, + "completionTokens": completion, + "totalTokens": prompt + completion, + "cachedTokens": 5, + "modelId": "unsloth/gpt-oss-20b", + }, + "timing": { + # The adapter writes streamStartTime as an epoch stamp and + # firstTokenTime as the elapsed ms before the first chunk. + "streamStartTime": 1_760_000_000_000, + "firstTokenTime": 200, + "totalStreamTime": 2000, + "tokenCount": completion, + "tokensPerSecond": speed, + "toolCallCount": tools, + }, + } + + +def test_empty_history_returns_zeroed_payload(stats_db): + stats = compute_profile_stats(days = 30) + + assert stats["totals"]["messages"] == 0 + assert stats["totals"]["totalTokens"] == 0 + assert stats["streak"] == {"current": 0, "longest": 0, "lastActiveDay": None} + assert stats["peakDay"] is None + assert stats["longestChat"] is None + assert len(stats["daily"]) == 30 + assert all(day["tokens"] == 0 for day in stats["daily"]) + + +def test_tokens_streaks_and_models_are_aggregated(stats_db): + today = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread( + conn, + "t1", + "unsloth/gpt-oss-20b", + [ + (today - timedelta(days = 2), _metadata(100, 50, speed = 30.0, tools = 2)), + (today - timedelta(days = 1), _metadata(200, 80, speed = 55.0)), + (today, _metadata(300, 120, speed = 120.0, tools = 1)), + ], + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 30) + + totals = stats["totals"] + assert totals["threads"] == 1 + assert totals["messages"] == 6 + assert totals["userMessages"] == 3 + assert totals["assistantMessages"] == 3 + assert totals["promptTokens"] == 600 + assert totals["completionTokens"] == 250 + assert totals["totalTokens"] == 850 + assert totals["cachedTokens"] == 15 + assert totals["toolCalls"] == 3 + assert totals["activeDays"] == 3 + + assert stats["streak"] == { + "current": 3, + "longest": 3, + "lastActiveDay": today.date().isoformat(), + } + assert stats["peakDay"] == {"date": today.date().isoformat(), "tokens": 420} + assert stats["models"][0]["id"] == "unsloth/gpt-oss-20b" + assert stats["models"][0]["label"] == "gpt-oss-20b" + assert stats["models"][0]["messages"] == 3 + assert stats["speed"]["bestTokensPerSecond"] == 120.0 + assert stats["speed"]["averageTokensPerSecond"] == pytest.approx(68.333, rel = 1e-3) + + # Each turn is a user message plus an assistant reply 10s later. + assert stats["longestChat"]["seconds"] == 30 + assert stats["longestChat"]["messages"] == 6 + + +def test_completion_tokens_fall_back_to_adapter_count(stats_db): + """Local engines can omit the usage chunk; timing.tokenCount stands in.""" + now = datetime.now().replace(hour = 9, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread( + conn, + "t2", + "local-gguf", + [(now, {"timing": {"tokenCount": 64, "tokensPerSecond": 12.0}})], + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + assert stats["totals"]["completionTokens"] == 64 + assert stats["totals"]["totalTokens"] == 64 + # No modelId on the turn, so it is not credited to any model. The thread's + # model_id follows the current selection and would misattribute after a + # mid-conversation switch. + assert stats["models"] == [] + + +def test_session_time_ignores_long_idle_gaps(stats_db): + """A thread reopened days later must not count the idle time as chatting.""" + start = datetime.now().replace(hour = 10, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread( + conn, + "t3", + "m", + [(start - timedelta(days = 3), _metadata(10, 10)), (start, _metadata(10, 10))], + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 30) + + # Two turns of 10s each; the 3-day gap between them is excluded. + assert stats["longestChat"]["seconds"] == 20 + assert stats["totals"]["chatSeconds"] == 20 + + +def test_broken_metadata_does_not_break_aggregation(stats_db): + now = datetime.now() + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES ('t4', 'Broken', 'base', 'm', ?, ?)", + (_ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) " + "VALUES ('t4-a0', 't4', 'assistant', '[]', ?, ?)", + ("{not json", _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) " + "VALUES ('t4-a1', 't4', 'assistant', '[]', ?, ?)", + (json.dumps({"contextUsage": {"totalTokens": "lots"}}), _ms(now)), + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + assert stats["totals"]["assistantMessages"] == 2 + assert stats["totals"]["totalTokens"] == 0 + + +def test_training_totals(stats_db): + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, ended_at, total_steps, final_step, final_loss, duration_seconds) " + "VALUES ('r1', 'completed', 'unsloth/llama-3-8b', 'tatsu-lab/alpaca', '{}', " + "'2026-01-01T10:00:00', '2026-01-01T11:00:00', 100, 100, 0.42, 3600)", + ) + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, final_loss, duration_seconds) " + "VALUES ('r2', 'error', 'unsloth/qwen3-4b', 'my/dataset', '{}', " + "'2026-01-02T10:00:00', 100, 20, 1.8, 600)", + ) + # num_tokens is a running total, so the last row is the run's figure. + conn.executemany( + "INSERT INTO training_metrics (run_id, step, loss, num_tokens) VALUES (?, ?, ?, ?)", + [("r1", step, 1.0, (step + 1) * 1000) for step in range(10)], + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + training = stats["training"] + assert training["runs"] == 2 + assert training["completed"] == 1 + assert training["steps"] == 120 + assert training["tokens"] == 10_000 + assert training["seconds"] == 4200 + assert training["models"] == 2 + assert training["bestLoss"] == pytest.approx(0.42) + assert training["recent"][0]["id"] == "r2" + assert training["recent"][0]["modelLabel"] == "qwen3-4b" + + +def test_first_token_time_is_read_as_a_duration(stats_db): + """firstTokenTime is `Date.now() - streamStartTime`, not a wall-clock stamp. + + Treating it as a stamp and subtracting streamStartTime made the comparison + fail for every real message, so the average was always empty. + """ + now = datetime.now() + conn = studio_db.get_connection() + try: + _seed_thread(conn, "tft", "m", [(now, _metadata(10, 10))]) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + assert stats["speed"]["averageFirstTokenMs"] == pytest.approx(200.0) + + +def test_forked_threads_do_not_double_count_copied_history(stats_db): + """Forking clones the ancestry, so the copies must not be counted again.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "src", "m", [(now - timedelta(hours = 2), _metadata(100, 50))]) + conn.commit() + finally: + conn.close() + + before = compute_profile_stats(days = 7) + assert before["totals"]["totalTokens"] == 150 + assert before["totals"]["messages"] == 2 + + fork_at = now + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES ('fork', 'fork of src', 'base', 'm', ?, ?, 'src', 'src-a0')", + (_ms(fork_at), _ms(fork_at)), + ) + # The clone keeps the original timestamp, exactly as fork_chat_thread does. + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('fork-a0', 'fork', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), + ) + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + after = compute_profile_stats(days = 7) + + assert after["totals"]["totalTokens"] == 150 + assert after["totals"]["messages"] == 2 + + # A genuinely new turn in the fork still counts. + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('fork-a1', 'fork', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(10, 5)), _ms(fork_at + timedelta(minutes = 1))), + ) + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + grown = compute_profile_stats(days = 7) + assert grown["totals"]["totalTokens"] == 165 + assert grown["totals"]["messages"] == 3 + + +def test_resumed_runs_do_not_double_count_steps_or_tokens(stats_db): + """A resume continues the source's counters, so only the tail is counted.""" + conn = studio_db.get_connection() + try: + # 'stopped' at step 10, then claimed by the resume below. The claim sets + # resume_blocked and leaves output_dir, which is how it is told apart + # from a cancelled run. + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) " + "VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, " + "'/runs/out', 1)", + ) + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) " + "VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, 300, " + "'/runs/out', 0)", + ) + conn.executemany( + "INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)", + # The continuation's counter picks up where the source stopped. + [("src", step, step * 100) for step in range(1, 11)] + + [("cont", step, step * 100) for step in range(11, 16)], + ) + conn.commit() + finally: + conn.close() + + training = compute_profile_stats(days = 7)["training"] + + # Training reached step 15, not 10 + 15. + assert training["steps"] == 15 + assert training["tokens"] == 1500 + # Both attempts still show up as runs. + assert training["runs"] == 2 + + +def test_cancelled_runs_keep_the_work_they_did(stats_db): + """Cancelling sets resume_blocked too, but nothing resumed from that run.""" + conn = studio_db.get_connection() + try: + # mark_run_cancel_requested clears output_dir and sets resume_blocked. + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) " + "VALUES ('cancelled', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 8, " + "400, NULL, 1)", + ) + conn.executemany( + "INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)", + [("cancelled", step, step * 100) for step in range(1, 9)], + ) + conn.commit() + finally: + conn.close() + + training = compute_profile_stats(days = 7)["training"] + + assert training["runs"] == 1 + assert training["steps"] == 8 + assert training["tokens"] == 800 + + +def test_forks_count_as_chats_before_their_first_new_turn(stats_db): + """A fork is a visible thread the moment it exists.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "orig", "m", [(now - timedelta(hours = 2), _metadata(100, 50))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES ('branch', 'fork', 'base', 'm', ?, ?, 'orig', 'orig-a0')", + (_ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + # Two conversations, but the cloned turn is not counted twice. + assert stats["totals"]["threads"] == 2 + assert stats["totals"]["messages"] == 2 + assert stats["totals"]["totalTokens"] == 150 + + +def test_tokens_follow_the_model_that_answered(stats_db): + """A routed provider records the real producer in responseDetails.""" + now = datetime.now() + metadata = _metadata(100, 50) + metadata["contextUsage"]["modelId"] = "openrouter/auto" + metadata["responseDetails"] = {"responseModelId": "anthropic/claude-sonnet-4"} + conn = studio_db.get_connection() + try: + _seed_thread(conn, "routed", "openrouter/auto", [(now, metadata)]) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + assert stats["models"][0]["id"] == "anthropic/claude-sonnet-4" + assert stats["models"][0]["tokens"] == 150 + + +def test_recent_run_name_prefers_the_users_rename(stats_db): + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, display_name) VALUES ('named', 'completed', 'unsloth/llama-3-8b', " + "'tatsu-lab/alpaca', '{}', '2026-01-02T10:00:00', 'Support triage v3')", + ) + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at) VALUES ('plain', 'completed', 'unsloth/qwen3-4b', 'my/dataset', '{}', " + "'2026-01-01T10:00:00')", + ) + conn.commit() + finally: + conn.close() + + recent = {run["id"]: run for run in compute_profile_stats(days = 7)["training"]["recent"]} + + assert recent["named"]["name"] == "Support triage v3" + assert recent["named"]["modelLabel"] == "llama-3-8b" + # Unnamed runs fall back to the short label, not the full repo id. + assert recent["plain"]["name"] == "qwen3-4b" + + +def test_historical_daylight_saving_offsets_are_respected(stats_db): + """A fixed offset would put a winter message on the wrong day. + + 2026-01-15 04:30 UTC is 23:30 on the 14th in New York, which is UTC-5 in + January. Reusing a summer offset of UTC-4 pushes it to 00:30 on the 15th, + so the one hour of drift crosses midnight and moves the activity grid. + """ + winter = datetime(2026, 1, 15, 4, 30, tzinfo = timezone.utc) + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES ('dst', 'dst', 'base', 'm', ?, ?)", + (int(winter.timestamp() * 1000), int(winter.timestamp() * 1000)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('dst-a0', 'dst', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(10, 10)), int(winter.timestamp() * 1000)), + ) + conn.commit() + finally: + conn.close() + + # A browser on summer time sends offset 240 (UTC-4) with the zone name. + named = compute_profile_stats(days = 366, tz_offset_minutes = 240, tz_name = "America/New_York") + invalidate_profile_stats_cache() + offset_only = compute_profile_stats(days = 366, tz_offset_minutes = 240) + + assert {day["date"] for day in named["daily"] if day["messages"]} == {"2026-01-14"} + + # The fixed offset lands an hour late, which is what the zone name fixes. + assert {day["date"] for day in offset_only["daily"] if day["messages"]} == {"2026-01-15"} + + +def test_unknown_timezone_falls_back_to_the_offset(stats_db): + now = datetime.now() + conn = studio_db.get_connection() + try: + _seed_thread(conn, "tzbad", "m", [(now, _metadata(10, 10))]) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7, tz_offset_minutes = 0, tz_name = "Not/A/Zone") + + assert stats["totals"]["totalTokens"] == 20 + + +def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db): + """forked_from_thread_id is not a foreign key, so it outlives the source.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "gone", "m", [(now - timedelta(hours = 2), _metadata(100, 50))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES ('kept', 'fork', 'base', 'm', ?, ?, 'gone', 'gone-a0')", + (_ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('kept-a0', 'kept', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), + ) + conn.commit() + finally: + conn.close() + + assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM chat_threads WHERE id = 'gone'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + stats = compute_profile_stats(days = 7) + + # The fork now holds the only copy, so it must still be counted once. + assert stats["totals"]["totalTokens"] == 150 + assert stats["totals"]["messages"] == 1 + + +def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db): + """Two forks of one thread must not both re-count the shared ancestry.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + older = now - timedelta(hours = 3) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "root", "m", [(older, _metadata(100, 50))]) + for fork_id in ("forkA", "forkB"): + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES (?, 'fork', 'base', 'm', ?, ?, 'root', 'root-a0')", + (fork_id, _ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, " + "metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)", + ( + f"{fork_id}-a0", + fork_id, + json.dumps(_metadata(100, 50)), + _ms(older + REPLY_DELAY), + ), + ) + conn.commit() + finally: + conn.close() + + assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM chat_threads WHERE id = 'root'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + stats = compute_profile_stats(days = 7) + + # Exactly one surviving copy is counted, not one per sibling fork. + assert stats["totals"]["totalTokens"] == 150 + assert stats["totals"]["messages"] == 1 + + +def test_deleting_an_original_message_keeps_the_forks_clone(stats_db): + """Pruning one pre-fork message leaves the clone as the only copy.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + older = now - timedelta(hours = 3) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "orig", "m", [(older, _metadata(100, 50))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES ('branch', 'fork', 'base', 'm', ?, ?, 'orig', 'orig-a0')", + (_ms(now), _ms(now)), + ) + # The clone keeps the original's timestamp and role. + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(100, 50)), _ms(older + timedelta(seconds = 10))), + ) + conn.commit() + finally: + conn.close() + + # While the original is there the clone is ignored. + assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM chat_messages WHERE id = 'orig-a0'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + stats = compute_profile_stats(days = 7) + + # The thread survives but that message does not, so the clone stands in. + assert stats["totals"]["totalTokens"] == 150 + assert stats["totals"]["assistantMessages"] == 1 + + +def test_future_history_cannot_pad_the_longest_streak(stats_db): + """Only the current streak was guarded; longest and lastActiveDay were not.""" + base = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread( + conn, + "skew", + "m", + [(base + timedelta(days = day), _metadata(10, 10)) for day in (3, 4, 5, 6)], + ) + conn.commit() + finally: + conn.close() + + streak = compute_profile_stats(days = 366)["streak"] + + assert streak == {"current": 0, "longest": 0, "lastActiveDay": None} + + +def test_comparison_panes_count_as_one_chat(stats_db): + """Compare mode stores a thread per pane; the sidebar shows one chat.""" + now = datetime.now() + conn = studio_db.get_connection() + try: + for pane in ("left", "right"): + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, pair_id, " + "created_at, updated_at) VALUES (?, 'compare', 'base', 'm', 'pair-1', ?, ?)", + (pane, _ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, " + "metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)", + (f"{pane}-a0", pane, json.dumps(_metadata(100, 50)), _ms(now)), + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + assert stats["totals"]["threads"] == 1 + # Both panes still contribute their own messages and tokens. + assert stats["totals"]["messages"] == 2 + assert stats["totals"]["totalTokens"] == 300 + + +def test_deleting_a_continuation_restores_its_source(stats_db): + """delete_run leaves resume_blocked set, so supersession needs a live tail.""" + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, output_dir, resume_blocked) " + "VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, " + "'/runs/out', 1)", + ) + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, output_dir, resume_blocked) " + "VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, " + "'/runs/out', 0)", + ) + conn.commit() + finally: + conn.close() + + assert compute_profile_stats(days = 7)["training"]["steps"] == 15 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM training_runs WHERE id = 'cont'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + + # With the continuation gone the source is the only record of that work. + assert compute_profile_stats(days = 7)["training"]["steps"] == 10 + + +def test_out_of_range_timestamps_do_not_break_the_panel(stats_db): + """created_at is client supplied and SQLite stores it unchecked.""" + now = datetime.now() + conn = studio_db.get_connection() + try: + _seed_thread(conn, "sane", "m", [(now, _metadata(10, 10))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES ('bad', 'bad', 'base', 'm', 1, 1)", + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('bad-a0', 'bad', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(7, 3)), 99_999_999_999_999_999), + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + # Totals still include the bad row; only its day bucket is dropped, so the + # activity grid holds just the one well-formed day. + assert stats["totals"]["totalTokens"] == 30 + assert stats["totals"]["messages"] == 3 + assert stats["totals"]["activeDays"] == 1 + assert sum(day["messages"] for day in stats["daily"]) == 2 + + +def test_future_dated_history_is_not_a_current_streak(stats_db): + """A client clock that ran ahead must not report a streak that has not happened.""" + future = datetime.now() + timedelta(days = 5) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "ahead", "m", [(future, _metadata(10, 10))]) + conn.commit() + finally: + conn.close() + + streak = compute_profile_stats(days = 366)["streak"] + + assert streak["current"] == 0 + + +def test_days_and_hours_use_the_callers_timezone(stats_db): + """A remote browser must not be bucketed against the server's calendar.""" + # 01:30 UTC. In UTC that is one day; at UTC-4 it is 21:30 the day before. + when = datetime(2026, 3, 10, 1, 30, tzinfo = timezone.utc) + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES ('tz', 'tz', 'base', 'm', ?, ?)", + (int(when.timestamp() * 1000), int(when.timestamp() * 1000)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('tz-a0', 'tz', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(10, 10)), int(when.timestamp() * 1000)), + ) + conn.commit() + finally: + conn.close() + + at_utc = compute_profile_stats(days = 366, tz_offset_minutes = 0) + invalidate_profile_stats_cache() + at_minus_four = compute_profile_stats(days = 366, tz_offset_minutes = 240) + + utc_days = {day["date"] for day in at_utc["daily"] if day["messages"]} + local_days = {day["date"] for day in at_minus_four["daily"] if day["messages"]} + assert utc_days == {"2026-03-10"} + assert local_days == {"2026-03-09"} + + +def test_repeat_calls_are_served_from_cache_until_history_changes(stats_db): + now = datetime.now() + conn = studio_db.get_connection() + try: + _seed_thread(conn, "t5", "m", [(now, _metadata(10, 10))]) + conn.commit() + finally: + conn.close() + + first = compute_profile_stats(days = 7) + second = compute_profile_stats(days = 7) + assert first is second + + conn = studio_db.get_connection() + try: + _seed_thread(conn, "t6", "m", [(now, _metadata(20, 20))]) + conn.commit() + finally: + conn.close() + + third = compute_profile_stats(days = 7) + assert third is not first + assert third["totals"]["totalTokens"] == 60 + + +def test_daily_series_is_dense_and_clamped(stats_db): + stats = compute_profile_stats(days = 10_000) + assert len(stats["daily"]) == profile_stats_db.MAX_DAILY_DAYS + dates = [day["date"] for day in stats["daily"]] + assert dates == sorted(dates) + assert len(set(dates)) == len(dates) + + +def test_route_does_not_block_the_event_loop(stats_db, monkeypatch): + """A cold stats pass must not stall streaming for the rest of the app. + + The aggregation is CPU-bound and can run for a second on large histories, + so the route offloads it to a worker thread. This drives the endpoint with a + heartbeat coroutine alongside it and asserts the loop kept ticking. + """ + import asyncio + + from routes import profile_stats as route_module + + def slow_compute( + days = 366, + tz_offset_minutes = 0, + tz_name = "", + ): + time.sleep(0.5) + return {"totals": {"messages": 0}} + + monkeypatch.setattr(route_module, "compute_profile_stats", slow_compute) + + async def drive() -> int: + ticks = 0 + + async def heartbeat() -> None: + nonlocal ticks + while True: + await asyncio.sleep(0.01) + ticks += 1 + + beat = asyncio.create_task(heartbeat()) + try: + await route_module.get_profile_stats( + days = 366, tz_offset_minutes = 0, current_subject = "unsloth" + ) + finally: + beat.cancel() + return ticks + + ticks = asyncio.run(drive()) + + # ~50 ticks fit in 0.5s; a blocking call on the loop would yield 0. + assert ticks > 10, f"event loop stalled during stats computation ({ticks} ticks)" + + +def test_an_oversized_token_counter_does_not_break_the_panel(stats_db): + """json parses ints of any width; float() gives up long before that.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "sane", "m", [(now - timedelta(hours = 1), _metadata(100, 50))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES ('bad', 'Bad', 'base', 'm', ?, ?)", + (_ms(now), _ms(now)), + ) + huge = _metadata(100, 50) + # Wider than float can hold, so every counter reads as unusable. + oversized = int("9" * 309) + for field in ("promptTokens", "completionTokens", "totalTokens"): + huge["contextUsage"][field] = oversized + huge["timing"]["tokenCount"] = oversized + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('bad-a0', 'bad', 'assistant', '[]', ?, ?)", + (json.dumps(huge), _ms(now)), + ) + conn.commit() + finally: + conn.close() + + # The row degrades to zero instead of raising, and the absurd counter + # never reaches the totals; the healthy thread still reports. + stats = compute_profile_stats(days = 7) + assert stats["totals"]["totalTokens"] == 150 + assert stats["totals"]["messages"] == 3 + + +def test_divergent_sibling_forks_keep_their_own_branch_messages(stats_db): + """fork_chat_thread copies one parent_id branch, so siblings differ.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + older = now - timedelta(hours = 3) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "root", "m", [(older, _metadata(100, 50))]) + # A regeneration of the same turn, a second later. + regenerated = older + REPLY_DELAY + timedelta(seconds = 1) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('root-a1', 'root', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(200, 100)), _ms(regenerated)), + ) + # One fork per branch: each carries only its own reply. + for fork_id, stamp, meta in ( + ("forkA", older + REPLY_DELAY, _metadata(100, 50)), + ("forkB", regenerated, _metadata(200, 100)), + ): + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES (?, 'fork', 'base', 'm', ?, ?, 'root', 'root-a0')", + (fork_id, _ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, " + "metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)", + (f"{fork_id}-a0", fork_id, json.dumps(meta), _ms(stamp)), + ) + conn.commit() + finally: + conn.close() + + # Both originals counted, both clones suppressed. + assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 450 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM chat_threads WHERE id = 'root'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + stats = compute_profile_stats(days = 7) + + # Each branch survives in exactly one fork, so nothing is lost or doubled. + assert stats["totals"]["totalTokens"] == 450 + assert stats["totals"]["messages"] == 2 + + +def test_a_resume_that_never_logged_a_step_keeps_the_source_counters(stats_db): + """create_run claims the source before the continuation flushes a metric.""" + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) " + "VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, " + "'/runs/out', 1)", + ) + # Errored before its first training step: no final_step, no metrics. + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) " + "VALUES ('cont', 'error', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, NULL, 5, " + "'/runs/out', 0)", + ) + conn.executemany( + "INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)", + [("src", step, step * 100) for step in range(1, 11)], + ) + conn.commit() + finally: + conn.close() + + training = compute_profile_stats(days = 7)["training"] + + # The source's completed work is still the only work there is. + assert training["steps"] == 10 + assert training["tokens"] == 1000 diff --git a/studio/frontend/src/features/profile/api/profile-stats.ts b/studio/frontend/src/features/profile/api/profile-stats.ts new file mode 100644 index 0000000000..7f3211232d --- /dev/null +++ b/studio/frontend/src/features/profile/api/profile-stats.ts @@ -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 { + // 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; +} diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx index d403f7fe66..2f4ac8db4d 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -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(null); const [draftName, setDraftName] = useState(displayName); const [draftNickname, setDraftNickname] = useState(nickname); + const [pickerOpen, setPickerOpen] = useState(false); const fileInputRef = useRef(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 ( -
-
- - { - void onPickFile(e.target.files?.[0]); - e.target.value = ""; - }} - /> - -
+
+ { + void onPickFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> -
- -
- 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" - /> - -
-
- -
- -
- 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" - /> - -
-
- -
- -
- {(["circle", "rounded"] as const).map((shape) => ( - - ))} -
-
- -
-
- -

- {t("settings.profile.greetingSlothDescription")} -

-
- -
- -
- -
- {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 ( - - ); - })} +
+
+ {/* The picture itself is the shortcut to "upload a photo"; the pencil + opens the rest of the options. */} + + + + + + +
+ + {t("settings.profile.avatarShape")} + +
+ {(["circle", "rounded"] as const).map((shape) => ( + + ))} +
+
+ +
+ + +
+ +
+ + {t("settings.profile.chooseSloth")} + +
+ {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 ( + + ); + })} +
+
+
+
+
+ + {/* Name fields sit beside the picture. These are not SettingsRows, so + data-settings-label is set by hand for settings search. */} +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
{imageError ? ( -

+

{imageError}

) : null} diff --git a/studio/frontend/src/features/profile/components/stats/insights-card.tsx b/studio/frontend/src/features/profile/components/stats/insights-card.tsx new file mode 100644 index 0000000000..b730d0c207 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/insights-card.tsx @@ -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 ( + +
+ + + + + 0 + ? t("settings.profile.stats.cachedValue", { + tokens: formatCompactNumber(totals.cachedTokens), + percent: Math.round(cacheShare * 100), + }) + : formatCompactNumber(totals.cachedTokens) + } + /> + + + + + + + + +
+
+ ); +} + +/** 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 ( + + {models.length === 0 ? ( +

+ {t("settings.profile.stats.noModels")} +

+ ) : ( +
    + {models.map((model, index) => ( +
  1. +
    + + + {index + 1} + + + {model.label} + + + + {t("settings.profile.stats.modelSummary", { + tokens: formatCompactNumber(model.tokens), + messages: formatFullNumber(model.messages), + })} + +
    + 0 ? model.tokens / peak : 0} /> +
  2. + ))} +
+ )} +
+ ); +} diff --git a/studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx b/studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx new file mode 100644 index 0000000000..29c9381030 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx @@ -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 ; + } + + if (error !== null && stats === null) { + return ( + +
+

{error}

+ +
+
+ ); + } + + if (stats === null) return null; + + const hasChats = stats.totals.messages > 0; + const hasTraining = stats.training.runs > 0; + + return ( +
+
+

+ {t("settings.profile.stats.title")} +

+

+ {t("settings.profile.stats.subtitle")} +

+
+ + + + {hasChats ? ( + <> + +
+ + +
+ + ) : ( + +

+ {t("settings.profile.stats.emptyChats")} +

+
+ )} + + {hasTraining ? : null} + +

+ {t("settings.profile.stats.privacyNote")} +

+
+ ); +} diff --git a/studio/frontend/src/features/profile/components/stats/profile-stats-panel.tsx b/studio/frontend/src/features/profile/components/stats/profile-stats-panel.tsx new file mode 100644 index 0000000000..fde4432254 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/profile-stats-panel.tsx @@ -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 ( + }> + + + ); +} diff --git a/studio/frontend/src/features/profile/components/stats/stat-primitives.tsx b/studio/frontend/src/features/profile/components/stats/stat-primitives.tsx new file mode 100644 index 0000000000..d0a458750c --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/stat-primitives.tsx @@ -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 ( +
+ {title ? ( +
+
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+ {action ?
{action}
: null} +
+ ) : null} + {children} +
+ ); +} + +/** 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 ( +
+ + {value} + + {label} +
+ ); +} + +/** Label left, value right: the "Activity insights" rows. */ +export function StatRow({ + label, + value, + emphasis, +}: { + label: string; + value: string; + emphasis?: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +/** 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 ( +
+
+
+ ); +} diff --git a/studio/frontend/src/features/profile/components/stats/stats-highlights.tsx b/studio/frontend/src/features/profile/components/stats/stats-highlights.tsx new file mode 100644 index 0000000000..7a01039465 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/stats-highlights.tsx @@ -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 ( +
+ + + + + +
+ ); +} diff --git a/studio/frontend/src/features/profile/components/stats/stats-skeleton.tsx b/studio/frontend/src/features/profile/components/stats/stats-skeleton.tsx new file mode 100644 index 0000000000..9a5e9c83e6 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/stats-skeleton.tsx @@ -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 ( +
+ + + +
+ + +
+
+ ); +} diff --git a/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx new file mode 100644 index 0000000000..7a11ec7769 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx @@ -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(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 ( +
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 ( +
+ {column.map((cell) => { + if (!cell.day) { + return ; + } + return ( + + ); + })} +
+ ); +} + +/** 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 ( +
+ {Array.from({ length: DAYS_PER_WEEK }, (_, row) => ( + = DAYS_PER_WEEK - height ? SOLID_LEVEL : 0} + /> + ))} +
+ ); +} + +export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) { + const t = useT(); + const [mode, setMode] = useState("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 ( + + {MODES.map((option) => ( + + ))} +
+ } + > + {/* Measured, never scrolled: the grid is trimmed to fit instead. */} +
+
+ {grid.map((column) => + shaded ? ( + + ) : ( + + ), + )} +
+ +
+ {monthLabels.map((label) => ( + + {label.text} + + ))} +
+
+ + ); +} diff --git a/studio/frontend/src/features/profile/components/stats/training-card.tsx b/studio/frontend/src/features/profile/components/stats/training-card.tsx new file mode 100644 index 0000000000..c6e30b9ff8 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/training-card.tsx @@ -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 = { + 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 ( + +
+ + + + + + +
+ + {training.recent.length > 0 ? ( +
    + {training.recent.map((run) => ( +
  • +
    + {/* A renamed run leads with the name the user chose, so the + model moves down beside the dataset to stay visible. */} + + {run.name} + + + {run.name === run.modelLabel + ? run.datasetLabel + : `${run.modelLabel} · ${run.datasetLabel}`} + +
    +
    + + {t("settings.profile.stats.runSteps", { + steps: formatFullNumber(run.steps), + })} + + + {run.finalLoss === null + ? "—" + : t("settings.profile.stats.runLoss", { + loss: run.finalLoss.toFixed(3), + })} + + + {run.status} + +
    +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/studio/frontend/src/features/profile/components/user-avatar.tsx b/studio/frontend/src/features/profile/components/user-avatar.tsx index e883fc5fad..99d7a4f339 100644 --- a/studio/frontend/src/features/profile/components/user-avatar.tsx +++ b/studio/frontend/src/features/profile/components/user-avatar.tsx @@ -30,14 +30,27 @@ const SHAPE: Record = { 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 ( - + ); diff --git a/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts b/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts index 6e919505ea..0e4ee97ff1 100644 --- a/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts +++ b/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts @@ -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 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; diff --git a/studio/frontend/src/features/profile/hooks/use-profile-stats.ts b/studio/frontend/src/features/profile/hooks/use-profile-stats.ts new file mode 100644 index 0000000000..c14f8e81e1 --- /dev/null +++ b/studio/frontend/src/features/profile/hooks/use-profile-stats.ts @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // A refresh aborts the in-flight request so a slow first load cannot land + // after (and overwrite) the newer one. + const abortRef = useRef(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 }; +} diff --git a/studio/frontend/src/features/profile/index.ts b/studio/frontend/src/features/profile/index.ts index 33bc4e3ef6..981cf993e7 100644 --- a/studio/frontend/src/features/profile/index.ts +++ b/studio/frontend/src/features/profile/index.ts @@ -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"; diff --git a/studio/frontend/src/features/profile/utils/resize-image-file.ts b/studio/frontend/src/features/profile/utils/resize-image-file.ts index 77ead49dea..beb9aeea74 100644 --- a/studio/frontend/src/features/profile/utils/resize-image-file.ts +++ b/studio/frontend/src/features/profile/utils/resize-image-file.ts @@ -28,7 +28,11 @@ function loadImage(file: File): Promise { }); } -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 { // 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.", + ); } diff --git a/studio/frontend/src/features/profile/utils/stats-format.ts b/studio/frontend/src/features/profile/utils/stats-format.ts new file mode 100644 index 0000000000..4f1b1ea16f --- /dev/null +++ b/studio/frontend/src/features/profile/utils/stats-format.ts @@ -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); +} diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index f4ba98b1ce..8f56b5b551 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -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", diff --git a/studio/frontend/src/features/settings/settings-search.ts b/studio/frontend/src/features/settings/settings-search.ts index a5b008579c..00bf52c2d8 100644 --- a/studio/frontend/src/features/settings/settings-search.ts +++ b/studio/frontend/src/features/settings/settings-search.ts @@ -37,8 +37,8 @@ export const SETTINGS_SEARCH_INDEX: Record = { "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 = { chat: [ "settings.general.chatDefaults", "settings.general.autoTitleNewChats", + "settings.profile.greetingSloth", "settings.chat.artifacts.title", "settings.chat.artifacts.collapseHtmlBlocks", "settings.chat.artifacts.allowNetworkAccess", diff --git a/studio/frontend/src/features/settings/tabs/chat-tab.tsx b/studio/frontend/src/features/settings/tabs/chat-tab.tsx index a6fcf87c57..f37504da40 100644 --- a/studio/frontend/src/features/settings/tabs/chat-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/chat-tab.tsx @@ -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() { > + + + diff --git a/studio/frontend/src/features/settings/tabs/profile-tab.tsx b/studio/frontend/src/features/settings/tabs/profile-tab.tsx index c515c3f7d7..1051c7e51f 100644 --- a/studio/frontend/src/features/settings/tabs/profile-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/profile-tab.tsx @@ -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() { +
); } diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index bdfcf38231..0e74c9d921 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -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", diff --git a/studio/frontend/tests/profile-stats-format.test.ts b/studio/frontend/tests/profile-stats-format.test.ts new file mode 100644 index 0000000000..16a5ed13e7 --- /dev/null +++ b/studio/frontend/tests/profile-stats-format.test.ts @@ -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); +});