From b8364e34459db1c4201008445b1b3ac159f66e31 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Tue, 28 Jul 2026 22:54:28 -0700 Subject: [PATCH 1/9] Studio: add profile usage stats and tidy the personalization panel Adds a "Your stats" section to the Profile settings tab, built entirely from local history in studio.db. No telemetry, nothing uploaded. Backend - storage/profile_stats_db.py folds every metric in one streaming pass over chat_messages, memoised against a (count, max created_at) fingerprint so reopening the tab is free until history changes. - routes/profile_stats.py serves GET /api/profile/stats from a worker thread, so a cold pass cannot stall token streaming. Frontend - Headline tiles, a token activity grid with daily/weekly/cumulative modes, activity insights, most used models, hour and weekday rhythms, and training run totals. - The stats panel is lazy loaded so recharts stays out of the main bundle. - Personalization panel now puts a larger avatar beside the two name fields, with picture options moved into an edit popover. - "Sloth in greeting" moves to Chat defaults, next to the other chat toggles. Tests: 9 backend tests including a regression test that the endpoint does not block the event loop, plus formatting unit tests. --- studio/backend/main.py | 2 + studio/backend/routes/profile_stats.py | 40 ++ studio/backend/storage/profile_stats_db.py | 462 ++++++++++++++++++ studio/backend/tests/test_profile_stats.py | 324 ++++++++++++ .../src/features/profile/api/profile-stats.ts | 98 ++++ .../profile-personalization-panel.tsx | 440 +++++++++-------- .../components/stats/insights-card.tsx | 151 ++++++ .../stats/profile-stats-content.tsx | 93 ++++ .../components/stats/profile-stats-panel.tsx | 23 + .../profile/components/stats/rhythm-card.tsx | 162 ++++++ .../components/stats/stat-primitives.tsx | 136 ++++++ .../components/stats/stats-highlights.tsx | 49 ++ .../components/stats/stats-skeleton.tsx | 24 + .../components/stats/token-activity-card.tsx | 356 ++++++++++++++ .../components/stats/training-card.tsx | 106 ++++ .../profile/hooks/use-profile-stats.ts | 51 ++ studio/frontend/src/features/profile/index.ts | 2 + .../features/profile/utils/stats-format.ts | 127 +++++ .../src/features/settings/settings-dialog.tsx | 7 +- .../src/features/settings/settings-search.ts | 5 +- .../src/features/settings/tabs/chat-tab.tsx | 15 + .../features/settings/tabs/profile-tab.tsx | 6 +- studio/frontend/src/i18n/locales/en.ts | 71 ++- .../tests/profile-stats-format.test.ts | 70 +++ 24 files changed, 2612 insertions(+), 208 deletions(-) create mode 100644 studio/backend/routes/profile_stats.py create mode 100644 studio/backend/storage/profile_stats_db.py create mode 100644 studio/backend/tests/test_profile_stats.py create mode 100644 studio/frontend/src/features/profile/api/profile-stats.ts create mode 100644 studio/frontend/src/features/profile/components/stats/insights-card.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/profile-stats-panel.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/rhythm-card.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/stat-primitives.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/stats-highlights.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/stats-skeleton.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/token-activity-card.tsx create mode 100644 studio/frontend/src/features/profile/components/stats/training-card.tsx create mode 100644 studio/frontend/src/features/profile/hooks/use-profile-stats.ts create mode 100644 studio/frontend/src/features/profile/utils/stats-format.ts create mode 100644 studio/frontend/tests/profile-stats-format.test.ts 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..239ad98275 --- /dev/null +++ b/studio/backend/routes/profile_stats.py @@ -0,0 +1,40 @@ +# 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, 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), + current_subject: str = Depends(get_current_subject), +) -> dict[str, Any]: + """Usage stats for the signed-in user's local history.""" + 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) + 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..100605ba1b --- /dev/null +++ b/studio/backend/storage/profile_stats_db.py @@ -0,0 +1,462 @@ +# 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 +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 +# 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.""" + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return float(value) if value == value and value 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 _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. + """ + 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.by_hour = [0] * 24 + self.by_weekday = [0] * 7 + 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 _fold_messages(conn) -> _MessageFold: + fold = _MessageFold() + 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 + 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 + + fold.threads.add(thread_id) + 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 = datetime.fromtimestamp(created_at / 1000) if created_at > 0 else None + 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 + + model_id = usage.get("modelId") + if not isinstance(model_id, str) or not model_id.strip(): + model_id = row["model_id"] if isinstance(row["model_id"], str) else "" + if model_id.strip(): + fold.note_model(model_id.strip(), 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) + start_ms = _as_float(timing.get("streamStartTime")) + first_token = _as_float(timing.get("firstTokenTime")) + if start_ms and first_token and first_token > start_ms: + fold.first_token_ms.append(first_token - start_ms) + + if stamp is not None: + fold.by_hour[stamp.hour] += 1 + fold.by_weekday[stamp.weekday()] += 1 + fold.note_day(stamp.date(), message_tokens, thread_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 _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(final_step, 0)) AS steps, + 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() + + tokens = conn.execute( + "SELECT COALESCE(SUM(num_tokens), 0) FROM training_metrics" + ).fetchone()[0] + + recent = conn.execute( + """ + SELECT id, COALESCE(display_name, model_name) AS 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(row["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"], + "name": item["name"], + "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) -> dict[str, Any]: + """Aggregate every profile statistic in one pass, memoised per history state.""" + days = max(1, min(int(days), MAX_DAILY_DAYS)) + conn = get_connection() + try: + fingerprint = (_fingerprint(conn), days) + 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) + training = _training_stats(conn) + + today = date.today() + 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, + "hourly": fold.by_hour, + "weekday": fold.by_weekday, + "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..7cf5e5a94c --- /dev/null +++ b/studio/backend/tests/test_profile_stats.py @@ -0,0 +1,324 @@ +# 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 + +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() + + +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 + timedelta(seconds = 10)), + ), + ) + + +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": { + "streamStartTime": 1000, + "firstTokenTime": 1200, + "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 in metadata: the thread's model is used instead. + assert stats["models"][0]["id"] == "local-gguf" + + +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)", + ) + conn.executemany( + "INSERT INTO training_metrics (run_id, step, loss, num_tokens) VALUES (?, ?, ?, ?)", + [("r1", step, 1.0, 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_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): + 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, 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)" 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..e79878e880 --- /dev/null +++ b/studio/frontend/src/features/profile/api/profile-stats.ts @@ -0,0 +1,98 @@ +// 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[]; + /** Messages per hour of day, index 0..23. */ + hourly: number[]; + /** Messages per weekday, index 0 = Monday. */ + weekday: number[]; + 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 { + const res = await authFetch("/api/profile/stats", { 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..dff9808248 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); @@ -180,201 +185,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..6feb8faa71 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx @@ -0,0 +1,93 @@ +// 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 { HourRhythmCard, WeekdayRhythmCard } from "./rhythm-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, rhythms and training. + * + * All of it comes from `/api/profile/stats`, which reads local history only. + * + * Loaded lazily by `profile-stats-panel.tsx` to keep recharts, pulled in by the + * rhythm charts, out of the main bundle. + */ +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/rhythm-card.tsx b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx new file mode 100644 index 0000000000..d5c7cf6a2d --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx @@ -0,0 +1,162 @@ +// 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 { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ChartConfig } from "@/components/ui/chart"; +import { useT } from "@/i18n"; +import { useMemo } from "react"; +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; +import type { ProfileStats } from "../../api/profile-stats"; +import { formatFullNumber } from "../../utils/stats-format"; +import { StatsCard } from "./stat-primitives"; + +const HOURS_IN_DAY = 24; +const CHART_CLASS = "h-[160px] w-full"; + +function chartConfig(label: string): ChartConfig { + return { messages: { label, color: "var(--primary)" } } satisfies ChartConfig; +} + +/** Hour-of-day histogram: when during the day the user actually works. */ +export function HourRhythmCard({ stats }: { stats: ProfileStats }) { + const t = useT(); + const data = useMemo( + () => + Array.from({ length: HOURS_IN_DAY }, (_, hour) => ({ + hour, + label: `${`${hour}`.padStart(2, "0")}:00`, + messages: stats.hourly[hour] ?? 0, + })), + [stats.hourly], + ); + + const busiest = useMemo( + () => + data.reduce( + (best, entry) => (entry.messages > best.messages ? entry : best), + data[0] ?? { hour: 0, label: "00:00", messages: 0 }, + ), + [data], + ); + + return ( + 0 + ? t("settings.profile.stats.hourDescription", { hour: busiest.label }) + : t("settings.profile.stats.noRhythm") + } + > + + + + `${hour}`} + className="text-ui-11" + /> + + `${payload?.[0]?.payload?.label ?? ""}` + } + formatter={(value) => formatFullNumber(Number(value))} + /> + } + /> + + + + + ); +} + +/** Weekday distribution, Monday-first to match the activity grid columns. */ +export function WeekdayRhythmCard({ stats }: { stats: ProfileStats }) { + const t = useT(); + const names = useMemo(() => { + const formatter = new Intl.DateTimeFormat(navigator.language, { + weekday: "short", + }); + // 2024-01-01 was a Monday, so this walks Mon..Sun in the user's locale. + return Array.from({ length: 7 }, (_, index) => + formatter.format(new Date(2024, 0, 1 + index)), + ); + }, []); + + const data = useMemo( + () => + names.map((name, index) => ({ + day: name, + messages: stats.weekday[index] ?? 0, + })), + [names, stats.weekday], + ); + + const busiest = useMemo( + () => + data.reduce( + (best, entry) => (entry.messages > best.messages ? entry : best), + data[0] ?? { day: "", messages: 0 }, + ), + [data], + ); + + return ( + 0 + ? t("settings.profile.stats.weekdayDescription", { day: busiest.day }) + : t("settings.profile.stats.noRhythm") + } + > + + + + + formatFullNumber(Number(value))} + /> + } + /> + + + + + ); +} 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..3a9565c471 --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx @@ -0,0 +1,356 @@ +// 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 { useEffect, useMemo, useRef, useState } from "react"; +import type { ProfileStatsDay } from "../../api/profile-stats"; +import { + type ActivityMode, + formatCompactNumber, + formatFullNumber, + heatLevel, + parseDayKey, + seriesForMode, +} 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, +): 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); + + 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 }); + } + + 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); + const title = summary.firstDay + ? t("settings.profile.stats.weekTooltip", { + date: dateFormatter.format(parseDayKey(summary.firstDay)), + tokens: formatFullNumber(summary.value), + }) + : ""; + + 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), + [daily, values, columns], + ); + const monthLabels = useMemo( + () => buildMonthLabels(grid, navigator.language), + [grid], + ); + // 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(navigator.language, { + month: "short", + day: "numeric", + year: "numeric", + }), + [], + ); + + 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..89024aae5a --- /dev/null +++ b/studio/frontend/src/features/profile/components/stats/training-card.tsx @@ -0,0 +1,106 @@ +// 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) => ( +
  • +
    + + {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/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/stats-format.ts b/studio/frontend/src/features/profile/utils/stats-format.ts new file mode 100644 index 0000000000..49e4c8821d --- /dev/null +++ b/studio/frontend/src/features/profile/utils/stats-format.ts @@ -0,0 +1,127 @@ +// 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 { limit, suffix } of units) { + if (abs >= limit) { + const scaled = value / limit; + // One decimal below 100 keeps "1.9B" readable; above it the decimal is noise. + const text = + Math.abs(scaled) >= 100 + ? Math.round(scaled).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 shows the running lifetime total, which only ever grows. + */ +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..05c4477881 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,70 @@ 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 history on this machine. Nothing is uploaded.", + retry: "Try again", + privacyNote: + "Stats are computed locally from your chat and training history and never leave this device.", + 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", + messages: "Messages", + topModelsTitle: "Most used models", + topModelsDescription: "Ranked by tokens exchanged", + modelSummary: "{tokens} ยท {messages} msgs", + noModels: "No model usage recorded yet.", + hourTitle: "When you work", + hourDescription: "Busiest hour: {hour}", + weekdayTitle: "Your week", + weekdayDescription: "Busiest day: {day}", + noRhythm: "Not enough activity 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..5324c48071 --- /dev/null +++ b/studio/frontend/tests/profile-stats-format.test.ts @@ -0,0 +1,70 @@ +// 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, +} 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("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"), []); +}); From f9b4ca4ff0b1f84545a6e11ccec47937fd837adf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:55:57 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/storage/profile_stats_db.py | 19 +++++++++++-------- studio/backend/tests/test_profile_stats.py | 8 +++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 100605ba1b..94e82af88a 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -49,7 +49,9 @@ def _as_float(value: Any) -> Optional[float]: if isinstance(value, bool) or value is None: return None if isinstance(value, (int, float)): - return float(value) if value == value and value not in (float("inf"), float("-inf")) else None + return ( + float(value) if value == value and value not in (float("inf"), float("-inf")) else None + ) return None @@ -114,7 +116,12 @@ class _MessageFold: 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.longest_chat: dict[str, Any] = { + "threadId": None, + "title": None, + "seconds": 0.0, + "messages": 0, + } self.by_day: dict[date, dict[str, Any]] = {} self.by_hour = [0] * 24 self.by_weekday = [0] * 7 @@ -301,9 +308,7 @@ def _training_stats(conn) -> dict[str, Any]: """ ).fetchone() - tokens = conn.execute( - "SELECT COALESCE(SUM(num_tokens), 0) FROM training_metrics" - ).fetchone()[0] + tokens = conn.execute("SELECT COALESCE(SUM(num_tokens), 0) FROM training_metrics").fetchone()[0] recent = conn.execute( """ @@ -375,9 +380,7 @@ def compute_profile_stats(days: int = MAX_DAILY_DAYS) -> dict[str, Any]: 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 - ) + 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] diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index 7cf5e5a94c..35a8d72a20 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -60,7 +60,13 @@ def _seed_thread(conn, thread_id: str, model_id: str, turns: list[tuple[datetime ) -def _metadata(prompt: int, completion: int, *, speed: float = 40.0, tools: int = 0) -> dict: +def _metadata( + prompt: int, + completion: int, + *, + speed: float = 40.0, + tools: int = 0, +) -> dict: return { "contextUsage": { "promptTokens": prompt, From 6ff20ecdeb07630756fbd931628bd0288563e48a Mon Sep 17 00:00:00 2001 From: Unsloth Date: Tue, 28 Jul 2026 23:21:18 -0700 Subject: [PATCH 3/9] Studio: correct profile stat aggregation Six accuracy fixes, each with a test that fails without it. - firstTokenTime is already an elapsed duration, not a timestamp. Subtracting streamStartTime made the comparison false for every real message, so "Average time to first token" was always empty. - Forking clones the whole ancestry into the new thread with the original timestamps. Those copies were counted again, doubling tokens, messages, attachments and activity for the source conversation. Rows older than the fork are now skipped. - training_metrics.num_tokens is state.num_input_tokens_seen, a running total logged at each step, so summing the samples multiplied the real figure. Take each run's final counter, matching get_run_metrics. - A resumed run continues its source's step and token counters from the checkpoint, so adding both reported the same progress twice. Only runs no later run resumed from are counted. - Days, hours and weekdays were bucketed in the server's timezone while the client parses the keys as browser-local. The endpoint now takes the caller's getTimezoneOffset(). - A turn with no contextUsage.modelId fell back to the thread's model_id, which tracks the current selection and so misattributed older turns after a mid-conversation switch. Those turns are left uncredited instead. --- studio/backend/routes/profile_stats.py | 18 +- studio/backend/storage/profile_stats_db.py | 87 +++++++-- studio/backend/tests/test_profile_stats.py | 166 +++++++++++++++++- .../src/features/profile/api/profile-stats.ts | 7 +- 4 files changed, 247 insertions(+), 31 deletions(-) diff --git a/studio/backend/routes/profile_stats.py b/studio/backend/routes/profile_stats.py index 239ad98275..f474cf0426 100644 --- a/studio/backend/routes/profile_stats.py +++ b/studio/backend/routes/profile_stats.py @@ -15,7 +15,11 @@ 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, compute_profile_stats +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() @@ -26,14 +30,22 @@ 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), current_subject: str = Depends(get_current_subject), ) -> dict[str, Any]: - """Usage stats for the signed-in user's local history.""" + """Usage stats for the signed-in user's local history. + + ``tz_offset_minutes`` is the caller's ``Date.getTimezoneOffset()``. Days and + hours are bucketed with it so a remote browser does not read the server's + calendar. + """ 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) + return await asyncio.to_thread( + compute_profile_stats, days = days, tz_offset_minutes = tz_offset_minutes + ) except Exception as exc: raise log_and_http_error( exc, 500, "Failed to compute profile statistics", log = logger diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 94e82af88a..28865bf2c2 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -19,7 +19,7 @@ the Profile tab is free until history changes. import json import threading import time -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from typing import Any, Optional from loggers import get_logger @@ -33,6 +33,8 @@ logger = get_logger(__name__) 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 @@ -66,6 +68,18 @@ def _iso(day: date) -> str: return day.isoformat() +def _local_stamp(created_at_ms: int, tz_offset_minutes: int) -> Optional[datetime]: + """Wall-clock time in the caller's timezone, not the server's. + + ``tz_offset_minutes`` follows the browser's ``getTimezoneOffset()``: minutes + to add to local time to reach UTC, so UTC-5 sends 300. + """ + if created_at_ms <= 0: + return None + utc = datetime.fromtimestamp(created_at_ms / 1000, tz = timezone.utc) + return (utc - timedelta(minutes = tz_offset_minutes)).replace(tzinfo = None) + + def _streaks(days: set[date], today: date) -> dict[str, Any]: """Current and longest run of consecutive active days. @@ -146,12 +160,13 @@ class _MessageFold: bucket["threads"].add(thread_id) -def _fold_messages(conn) -> _MessageFold: +def _fold_messages(conn, tz_offset_minutes: int = 0) -> _MessageFold: fold = _MessageFold() 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.title, t.model_id, t.model_type, + 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 @@ -187,6 +202,13 @@ def _fold_messages(conn) -> _MessageFold: thread_messages = 0 previous_created = None + # Forking clones the whole ancestry into the new thread, keeping each + # copy's original timestamp. Counting those again would double every + # metric for the branched-from conversation, so skip anything older + # than the fork itself. + if row["forked_from_thread_id"] and created_at < _as_int(row["thread_created_at"]): + continue + fold.threads.add(thread_id) fold.messages += 1 thread_messages += 1 @@ -197,7 +219,7 @@ def _fold_messages(conn) -> _MessageFold: thread_seconds += gap previous_created = created_at - stamp = datetime.fromtimestamp(created_at / 1000) if created_at > 0 else None + stamp = _local_stamp(created_at, tz_offset_minutes) role = row["role"] if role == "user": fold.user_messages += 1 @@ -245,11 +267,13 @@ def _fold_messages(conn) -> _MessageFold: fold.tool_calls += _as_int(timing.get("toolCallCount")) message_tokens = total_tokens - model_id = usage.get("modelId") - if not isinstance(model_id, str) or not model_id.strip(): - model_id = row["model_id"] if isinstance(row["model_id"], str) else "" - if model_id.strip(): - fold.note_model(model_id.strip(), message_tokens) + # Only the checkpoint recorded on the turn itself. The thread's + # model_id tracks whatever is selected now, so using it as a + # fallback credits older turns to the wrong model after a switch. + raw_model_id = usage.get("modelId") + model_id = raw_model_id.strip() if isinstance(raw_model_id, str) else "" + 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. @@ -262,10 +286,10 @@ def _fold_messages(conn) -> _MessageFold: stream_ms = _as_float(timing.get("totalStreamTime")) if stream_ms is not None and stream_ms > 0: fold.response_ms.append(stream_ms) - start_ms = _as_float(timing.get("streamStartTime")) + # firstTokenTime is already an elapsed duration, not a timestamp. first_token = _as_float(timing.get("firstTokenTime")) - if start_ms and first_token and first_token > start_ms: - fold.first_token_ms.append(first_token - start_ms) + if first_token is not None and first_token > 0: + fold.first_token_ms.append(first_token) if stamp is not None: fold.by_hour[stamp.hour] += 1 @@ -299,7 +323,6 @@ def _training_stats(conn) -> dict[str, Any]: """ SELECT COUNT(*) AS runs, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, - SUM(COALESCE(final_step, 0)) AS steps, SUM(COALESCE(duration_seconds, 0)) AS seconds, COUNT(DISTINCT model_name) AS models, COUNT(DISTINCT dataset_name) AS datasets, @@ -308,7 +331,28 @@ def _training_stats(conn) -> dict[str, Any]: """ ).fetchone() - tokens = conn.execute("SELECT COALESCE(SUM(num_tokens), 0) FROM training_metrics").fetchone()[0] + # A resumed run continues its source's step and token counters from the + # checkpoint, so both absolute totals already include the source's work. + # resume_blocked marks a run that some later run resumed from; counting + # only the unresumed tails avoids adding the same progress twice. + steps = conn.execute( + "SELECT COALESCE(SUM(final_step), 0) FROM training_runs WHERE resume_blocked = 0" + ).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( + """ + 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 r.resume_blocked = 0 + GROUP BY m.run_id + ) + """ + ).fetchone()[0] recent = conn.execute( """ @@ -324,7 +368,7 @@ def _training_stats(conn) -> dict[str, Any]: return { "runs": _as_int(row["runs"]), "completed": _as_int(row["completed"]), - "steps": _as_int(row["steps"]), + "steps": _as_int(steps), "tokens": _as_int(tokens), "seconds": _as_float(row["seconds"]) or 0.0, "models": _as_int(row["models"]), @@ -357,12 +401,15 @@ def _fingerprint(conn) -> tuple: return (message_row[0], message_row[1], run_row[0], run_row[1]) -def compute_profile_stats(days: int = MAX_DAILY_DAYS) -> dict[str, Any]: +def compute_profile_stats(days: int = MAX_DAILY_DAYS, tz_offset_minutes: int = 0) -> 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) + ) conn = get_connection() try: - fingerprint = (_fingerprint(conn), days) + fingerprint = (_fingerprint(conn), days, tz_offset_minutes) now = time.monotonic() with _cache_lock: if ( @@ -373,10 +420,12 @@ def compute_profile_stats(days: int = MAX_DAILY_DAYS) -> dict[str, Any]: return _cache["payload"] started = time.perf_counter() - fold = _fold_messages(conn) + fold = _fold_messages(conn, tz_offset_minutes) training = _training_stats(conn) - today = date.today() + # "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), tz_offset_minutes) or datetime.now()).date() streak = _streaks(set(fold.by_day.keys()), today) daily = _daily_series(fold, today, days) diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index 35a8d72a20..aa4bc52bcf 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -5,7 +5,7 @@ import json import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import pytest @@ -76,8 +76,10 @@ def _metadata( "modelId": "unsloth/gpt-oss-20b", }, "timing": { - "streamStartTime": 1000, - "firstTokenTime": 1200, + # 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, @@ -166,8 +168,10 @@ def test_completion_tokens_fall_back_to_adapter_count(stats_db): assert stats["totals"]["completionTokens"] == 64 assert stats["totals"]["totalTokens"] == 64 - # No modelId in metadata: the thread's model is used instead. - assert stats["models"][0]["id"] == "local-gguf" + # 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): @@ -236,9 +240,10 @@ def test_training_totals(stats_db): "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, 1000) for step in range(10)], + [("r1", step, 1.0, (step + 1) * 1000) for step in range(10)], ) conn.commit() finally: @@ -258,6 +263,149 @@ def test_training_totals(stats_db): 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))), + ) + 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. + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, resume_blocked) " + "VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, 1)", + ) + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, duration_seconds, resume_blocked) " + "VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, 300, 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_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) + + assert at_utc["hourly"][1] == 1 + assert at_minus_four["hourly"][21] == 1 + + 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() @@ -302,7 +450,7 @@ def test_route_does_not_block_the_event_loop(stats_db, monkeypatch): from routes import profile_stats as route_module - def slow_compute(days = 366): + def slow_compute(days = 366, tz_offset_minutes = 0): time.sleep(0.5) return {"totals": {"messages": 0}} @@ -319,7 +467,9 @@ def test_route_does_not_block_the_event_loop(stats_db, monkeypatch): beat = asyncio.create_task(heartbeat()) try: - await route_module.get_profile_stats(days = 366, current_subject = "unsloth") + await route_module.get_profile_stats( + days = 366, tz_offset_minutes = 0, current_subject = "unsloth" + ) finally: beat.cancel() return ticks diff --git a/studio/frontend/src/features/profile/api/profile-stats.ts b/studio/frontend/src/features/profile/api/profile-stats.ts index e79878e880..529b5e66f6 100644 --- a/studio/frontend/src/features/profile/api/profile-stats.ts +++ b/studio/frontend/src/features/profile/api/profile-stats.ts @@ -90,7 +90,12 @@ export type ProfileStats = { export async function loadProfileStats( signal?: AbortSignal, ): Promise { - const res = await authFetch("/api/profile/stats", { signal }); + // Bucket days and hours in this browser's timezone, which is not the + // server's when Studio is reached over the network. + const query = new URLSearchParams({ + tz_offset_minutes: String(new Date().getTimezoneOffset()), + }); + const res = await authFetch(`/api/profile/stats?${query}`, { signal }); if (!res.ok) { throw new Error(await readFastApiError(res, "Failed to load your stats")); } From 9ae3090b98a4e9e6128e6ba954ff43c98df1b8c8 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Tue, 28 Jul 2026 23:46:45 -0700 Subject: [PATCH 4/9] Studio: further profile stat corrections Follow-up to the previous pass, each with a test that fails without it. - Cancelling a run also sets resume_blocked, so filtering on that flag alone dropped the steps and tokens of cancelled work while still counting the run and its duration. Only a run a later run resumed from is excluded now: the resume claim leaves output_dir intact, cancelling clears it, which tells the two apart. - A fixed getTimezoneOffset() was applied to every historical message, so winter records read an hour out when the panel is opened on summer time, moving messages near midnight onto the wrong day. The endpoint now takes an IANA timezone and converts each timestamp with its own offset, keeping the fixed offset as the fallback. - A fork with no new turn was skipped before its thread was registered, so it did not appear in the chat total until its first message. The thread is now counted before the cloned rows are suppressed. - Routed and aliased responses record the model that actually answered in responseDetails.responseModelId, while contextUsage.modelId stays the requested checkpoint. Most used models now prefers the former. - Renamed runs showed the model label instead of the name the user chose. The name leads and the model moves beside the dataset. --- studio/backend/routes/profile_stats.py | 13 +- studio/backend/storage/profile_stats_db.py | 92 +++++++--- studio/backend/tests/test_profile_stats.py | 165 +++++++++++++++++- .../src/features/profile/api/profile-stats.ts | 5 +- .../components/stats/training-card.tsx | 8 +- 5 files changed, 245 insertions(+), 38 deletions(-) diff --git a/studio/backend/routes/profile_stats.py b/studio/backend/routes/profile_stats.py index f474cf0426..ff48646eff 100644 --- a/studio/backend/routes/profile_stats.py +++ b/studio/backend/routes/profile_stats.py @@ -31,20 +31,25 @@ logger = get_logger(__name__) 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. - ``tz_offset_minutes`` is the caller's ``Date.getTimezoneOffset()``. Days and - hours are bucketed with it so a remote browser does not read the server's - calendar. + 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 + compute_profile_stats, + days = days, + tz_offset_minutes = tz_offset_minutes, + tz_name = tz, ) except Exception as exc: raise log_and_http_error( diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 28865bf2c2..38a3fdfe45 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -20,6 +20,7 @@ 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 @@ -68,16 +69,31 @@ def _iso(day: date) -> str: return day.isoformat() -def _local_stamp(created_at_ms: int, tz_offset_minutes: int) -> Optional[datetime]: - """Wall-clock time in the caller's timezone, not the server's. +def _clean_str(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" - ``tz_offset_minutes`` follows the browser's ``getTimezoneOffset()``: minutes - to add to local time to reach UTC, so UTC-5 sends 300. + +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.""" if created_at_ms <= 0: return None - utc = datetime.fromtimestamp(created_at_ms / 1000, tz = timezone.utc) - return (utc - timedelta(minutes = tz_offset_minutes)).replace(tzinfo = None) + return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None) def _streaks(days: set[date], today: date) -> dict[str, Any]: @@ -160,7 +176,7 @@ class _MessageFold: bucket["threads"].add(thread_id) -def _fold_messages(conn, tz_offset_minutes: int = 0) -> _MessageFold: +def _fold_messages(conn, zone) -> _MessageFold: fold = _MessageFold() rows = conn.execute( """ @@ -202,6 +218,10 @@ def _fold_messages(conn, tz_offset_minutes: int = 0) -> _MessageFold: thread_messages = 0 previous_created = None + # 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(thread_id) + # Forking clones the whole ancestry into the new thread, keeping each # copy's original timestamp. Counting those again would double every # metric for the branched-from conversation, so skip anything older @@ -209,7 +229,6 @@ def _fold_messages(conn, tz_offset_minutes: int = 0) -> _MessageFold: if row["forked_from_thread_id"] and created_at < _as_int(row["thread_created_at"]): continue - fold.threads.add(thread_id) fold.messages += 1 thread_messages += 1 @@ -219,7 +238,7 @@ def _fold_messages(conn, tz_offset_minutes: int = 0) -> _MessageFold: thread_seconds += gap previous_created = created_at - stamp = _local_stamp(created_at, tz_offset_minutes) + stamp = _local_stamp(created_at, zone) role = row["role"] if role == "user": fold.user_messages += 1 @@ -267,11 +286,16 @@ def _fold_messages(conn, tz_offset_minutes: int = 0) -> _MessageFold: fold.tool_calls += _as_int(timing.get("toolCallCount")) message_tokens = total_tokens - # Only the checkpoint recorded on the turn itself. The thread's - # model_id tracks whatever is selected now, so using it as a - # fallback credits older turns to the wrong model after a switch. - raw_model_id = usage.get("modelId") - model_id = raw_model_id.strip() if isinstance(raw_model_id, str) else "" + # 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) @@ -318,6 +342,16 @@ def _daily_series(fold: _MessageFold, today: date, days: int) -> list[dict[str, return series +def _superseded(prefix: str) -> str: + """SQL for "a later run resumed from this one, so its counters live there". + + ``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. + """ + return f"{prefix}resume_blocked = 1 AND {prefix}output_dir IS NOT NULL" + + def _training_stats(conn) -> dict[str, Any]: row = conn.execute( """ @@ -333,22 +367,23 @@ def _training_stats(conn) -> dict[str, Any]: # A resumed run continues its source's step and token counters from the # checkpoint, so both absolute totals already include the source's work. - # resume_blocked marks a run that some later run resumed from; counting - # only the unresumed tails avoids adding the same progress twice. + # 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( - "SELECT COALESCE(SUM(final_step), 0) FROM training_runs WHERE resume_blocked = 0" + f"SELECT COALESCE(SUM(final_step), 0) FROM training_runs 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 r.resume_blocked = 0 + WHERE NOT ({_superseded("r.")}) GROUP BY m.run_id ) """ @@ -356,7 +391,7 @@ def _training_stats(conn) -> dict[str, Any]: recent = conn.execute( """ - SELECT id, COALESCE(display_name, model_name) AS name, model_name, dataset_name, + 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 @@ -377,7 +412,9 @@ def _training_stats(conn) -> dict[str, Any]: "recent": [ { "id": item["id"], - "name": item["name"], + # 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"], @@ -401,15 +438,20 @@ def _fingerprint(conn) -> tuple: 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) -> dict[str, Any]: +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) + fingerprint = (_fingerprint(conn), days, tz_offset_minutes, tz_name) now = time.monotonic() with _cache_lock: if ( @@ -420,12 +462,12 @@ def compute_profile_stats(days: int = MAX_DAILY_DAYS, tz_offset_minutes: int = 0 return _cache["payload"] started = time.perf_counter() - fold = _fold_messages(conn, tz_offset_minutes) + 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), tz_offset_minutes) or datetime.now()).date() + 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) diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index aa4bc52bcf..608b9a023d 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -343,16 +343,20 @@ 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. + # '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, resume_blocked) " - "VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, 1)", + "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, resume_blocked) " - "VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, 300, 0)", + "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 (?, ?, ?)", @@ -373,6 +377,151 @@ def test_resumed_runs_do_not_double_count_steps_or_tokens(stats_db): 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))), + ) + 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 in the wrong hour.""" + # 2026-01-15 02:30 UTC. New York is UTC-5 in January, so 21:30 on the 14th. + winter = datetime(2026, 1, 15, 2, 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 named["hourly"][21] == 1 + 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 offset_only["hourly"][22] == 1 + + +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_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. @@ -450,7 +599,11 @@ def test_route_does_not_block_the_event_loop(stats_db, monkeypatch): from routes import profile_stats as route_module - def slow_compute(days = 366, tz_offset_minutes = 0): + def slow_compute( + days = 366, + tz_offset_minutes = 0, + tz_name = "", + ): time.sleep(0.5) return {"totals": {"messages": 0}} diff --git a/studio/frontend/src/features/profile/api/profile-stats.ts b/studio/frontend/src/features/profile/api/profile-stats.ts index 529b5e66f6..a35cc23887 100644 --- a/studio/frontend/src/features/profile/api/profile-stats.ts +++ b/studio/frontend/src/features/profile/api/profile-stats.ts @@ -91,9 +91,12 @@ 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. + // 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) { diff --git a/studio/frontend/src/features/profile/components/stats/training-card.tsx b/studio/frontend/src/features/profile/components/stats/training-card.tsx index 89024aae5a..c6e30b9ff8 100644 --- a/studio/frontend/src/features/profile/components/stats/training-card.tsx +++ b/studio/frontend/src/features/profile/components/stats/training-card.tsx @@ -65,14 +65,18 @@ export function TrainingHighlightsCard({ stats }: { stats: ProfileStats }) { className="flex items-center justify-between gap-3 py-2" >
+ {/* A renamed run leads with the name the user chose, so the + model moves down beside the dataset to stay visible. */} - {run.modelLabel} + {run.name} - {run.datasetLabel} + {run.name === run.modelLabel + ? run.datasetLabel + : `${run.modelLabel} ยท ${run.datasetLabel}`}
From 1338f09b97622f13a8b48cca6f5386740a5cb447 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 29 Jul 2026 00:10:13 -0700 Subject: [PATCH 5/9] Studio: harden profile stats against deleted and malformed history - Deleting a forked conversation's source left the fork holding the only copies of those messages, but the skip still dropped them because forked_from_thread_id is not a foreign key. The suppression now joins the source row, so copies are only ignored while the originals survive. - delete_run never clears the predecessor's resume_blocked, so removing a continuation stranded its source at zero steps and tokens while the run stayed visible. Supersession now requires a continuation that still exists. - created_at is a client-supplied integer stored unchecked, so one out-of-range row made datetime raise and returned 500 for the whole panel. Those rows are now skipped for day and hour buckets. - A future-dated row satisfied the current-streak window, reporting days that have not happened. The last active day must now be today or yesterday. - Compact numbers rounded 999,999 to "1000K". Rounding into the next unit now steps up the suffix. - Escape or a programmatic close unmounts the Profile tab without a blur, dropping an in-progress name edit. Drafts are committed on unmount; both saves no-op when unchanged. - Stats copy no longer claims the numbers never leave the device, which is untrue when Studio is reached from another machine. It states what actually holds: nothing is collected or sent to Unsloth. --- studio/backend/storage/profile_stats_db.py | 48 ++++++-- studio/backend/tests/test_profile_stats.py | 116 ++++++++++++++++++ .../profile-personalization-panel.tsx | 12 ++ .../profile/components/user-avatar.tsx | 17 ++- .../profile/hooks/use-personalization-sync.ts | 7 +- .../profile/utils/resize-image-file.ts | 33 ++++- .../features/profile/utils/stats-format.ts | 23 ++-- studio/frontend/src/i18n/locales/en.ts | 4 +- .../tests/profile-stats-format.test.ts | 11 ++ 9 files changed, 241 insertions(+), 30 deletions(-) diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 38a3fdfe45..041d1e2d6e 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -90,10 +90,18 @@ def _resolve_zone(tz_name: str, tz_offset_minutes: int): def _local_stamp(created_at_ms: int, zone) -> Optional[datetime]: - """Wall-clock time in the caller's timezone, not the server's.""" + """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 - return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = 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]: @@ -114,7 +122,9 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]: last = ordered[-1] current_streak = 0 - if today - last <= timedelta(days = 1): + # A future-dated row (client clock ahead) is not a live streak: the last + # active day still has to be today or yesterday. + if timedelta(0) <= today - last <= timedelta(days = 1): current_streak = 1 cursor = last while cursor - timedelta(days = 1) in days: @@ -182,9 +192,13 @@ def _fold_messages(conn, zone) -> _MessageFold: """ SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at, t.title, t.model_id, t.model_type, - t.created_at AS thread_created_at, t.forked_from_thread_id + t.created_at AS thread_created_at, src.id AS fork_source_id FROM chat_messages m LEFT JOIN chat_threads t ON t.id = m.thread_id + -- forked_from_thread_id is not a foreign key, so it outlives the source. + -- Joining to the real row means the copies are only suppressed while + -- the originals are still there to be counted. + LEFT JOIN chat_threads src ON src.id = t.forked_from_thread_id ORDER BY m.thread_id, m.created_at """ ) @@ -225,8 +239,9 @@ def _fold_messages(conn, zone) -> _MessageFold: # Forking clones the whole ancestry into the new thread, keeping each # copy's original timestamp. Counting those again would double every # metric for the branched-from conversation, so skip anything older - # than the fork itself. - if row["forked_from_thread_id"] and created_at < _as_int(row["thread_created_at"]): + # than the fork itself, but only while the source survives to be + # counted in its place. + if row["fork_source_id"] and created_at < _as_int(row["thread_created_at"]): continue fold.messages += 1 @@ -342,14 +357,29 @@ def _daily_series(fold: _MessageFold, today: date, days: int) -> list[dict[str, return series -def _superseded(prefix: str) -> str: +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. + """ + 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 + ) """ - return f"{prefix}resume_blocked = 1 AND {prefix}output_dir IS NOT NULL" def _training_stats(conn) -> dict[str, Any]: @@ -371,7 +401,7 @@ def _training_stats(conn) -> dict[str, Any]: # 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(final_step), 0) FROM training_runs WHERE NOT ({_superseded('')})" + 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 diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index 608b9a023d..ed5af2aff4 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -522,6 +522,122 @@ def test_unknown_timezone_falls_back_to_the_offset(stats_db): 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))), + ) + 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_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 and hour buckets are + # dropped, so the two well-formed messages are all that is placed. + assert stats["totals"]["totalTokens"] == 30 + assert stats["totals"]["messages"] == 3 + assert sum(stats["hourly"]) == 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. 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 dff9808248..2f4ac8db4d 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -138,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(); 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/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 index 49e4c8821d..518585e213 100644 --- a/studio/frontend/src/features/profile/utils/stats-format.ts +++ b/studio/frontend/src/features/profile/utils/stats-format.ts @@ -21,16 +21,21 @@ export function formatCompactNumber(value: number): string { { limit: 1e6, suffix: "M" }, { limit: 1e3, suffix: "K" }, ]; - for (const { limit, suffix } of units) { - if (abs >= limit) { - const scaled = value / limit; - // One decimal below 100 keeps "1.9B" readable; above it the decimal is noise. - const text = - Math.abs(scaled) >= 100 - ? Math.round(scaled).toString() - : scaled.toFixed(1); - return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`; + 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)); } diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 05c4477881..5253c48f9a 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -411,10 +411,10 @@ export const en = { stats: { title: "Your stats", subtitle: - "Everything below is counted from history on this machine. Nothing is uploaded.", + "Everything below is counted from your own history. Nothing is collected or sent to Unsloth.", retry: "Try again", privacyNote: - "Stats are computed locally from your chat and training history and never leave this device.", + "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", diff --git a/studio/frontend/tests/profile-stats-format.test.ts b/studio/frontend/tests/profile-stats-format.test.ts index 5324c48071..7bf464ce97 100644 --- a/studio/frontend/tests/profile-stats-format.test.ts +++ b/studio/frontend/tests/profile-stats-format.test.ts @@ -25,6 +25,17 @@ test("compact numbers match the tile format", () => { 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"); From f9970b87440762b73d78642f52bde560ef57dbd1 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 29 Jul 2026 00:45:12 -0700 Subject: [PATCH 6/9] Studio: dedupe sibling forks and group comparison panes - Deleting a thread that had been forked more than once left every sibling holding a copy of the shared ancestry, and the source-existence gate then let all of them count it, multiplying the usage by the number of forks. One fork per dead source is now elected to keep its copies, the one carrying the most, so nothing is lost or counted twice. - Compare mode persists one thread per pane under a shared pair_id and the sidebar renders them as a single conversation. The chat total now keys on the pair, so one comparison is one chat and average tokens per chat is not halved. - Corrected the note on cumulative mode: it is the running total across the displayed window, not lifetime. Seeding it with everything older than the window would flatten every bar against a baseline the grid has no room to show. --- studio/backend/storage/profile_stats_db.py | 55 +++++++++++++-- studio/backend/tests/test_profile_stats.py | 68 +++++++++++++++++++ .../features/profile/utils/stats-format.ts | 4 +- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 041d1e2d6e..8c1332c46e 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -186,13 +186,44 @@ class _MessageFold: bucket["threads"].add(thread_id) +def _orphan_fork_keepers(conn) -> set[str]: + """Forks whose source is gone and that should still count their copies. + + ``forked_from_thread_id`` is not a foreign key, so deleting a source leaves + its forks holding the only copies of that ancestry. Enabling all of them + would multiply the usage by the number of siblings, so exactly one per dead + source is kept: the one carrying the most copied rows, which is the fork + that branched latest and therefore holds the longest ancestry. + """ + rows = conn.execute( + """ + SELECT t.id, t.forked_from_thread_id AS source_id, + (SELECT COUNT(*) FROM chat_messages m + WHERE m.thread_id = t.id AND m.created_at < t.created_at) AS copied + FROM chat_threads t + WHERE t.forked_from_thread_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM chat_threads s WHERE s.id = t.forked_from_thread_id) + """ + ).fetchall() + + best: dict[str, tuple[int, str]] = {} + for row in rows: + rank = (_as_int(row["copied"]), row["id"]) + current = best.get(row["source_id"]) + if current is None or rank > current: + best[row["source_id"]] = rank + return {thread_id for _, thread_id in best.values()} + + def _fold_messages(conn, zone) -> _MessageFold: fold = _MessageFold() + keepers = _orphan_fork_keepers(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.created_at AS thread_created_at, src.id AS fork_source_id + t.title, t.model_id, t.model_type, t.pair_id, + t.created_at AS thread_created_at, t.forked_from_thread_id, + src.id AS fork_source_id FROM chat_messages m LEFT JOIN chat_threads t ON t.id = m.thread_id -- forked_from_thread_id is not a foreign key, so it outlives the source. @@ -232,16 +263,26 @@ def _fold_messages(conn, zone) -> _MessageFold: 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(thread_id) + fold.threads.add(conversation_id) # Forking clones the whole ancestry into the new thread, keeping each # copy's original timestamp. Counting those again would double every # metric for the branched-from conversation, so skip anything older - # than the fork itself, but only while the source survives to be - # counted in its place. - if row["fork_source_id"] and created_at < _as_int(row["thread_created_at"]): + # than the fork itself. Once the source is gone one fork is elected to + # keep its copies, since they are then the only record left. + if ( + row["forked_from_thread_id"] + and created_at < _as_int(row["thread_created_at"]) + and (row["fork_source_id"] or thread_id not in keepers) + ): continue fold.messages += 1 @@ -333,7 +374,7 @@ def _fold_messages(conn, zone) -> _MessageFold: if stamp is not None: fold.by_hour[stamp.hour] += 1 fold.by_weekday[stamp.weekday()] += 1 - fold.note_day(stamp.date(), message_tokens, thread_id) + fold.note_day(stamp.date(), message_tokens, conversation_id) close_thread() return fold diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index ed5af2aff4..f5a00c3ec9 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -560,6 +560,74 @@ def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db): 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)), + ) + 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_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() diff --git a/studio/frontend/src/features/profile/utils/stats-format.ts b/studio/frontend/src/features/profile/utils/stats-format.ts index 518585e213..aee026f4c1 100644 --- a/studio/frontend/src/features/profile/utils/stats-format.ts +++ b/studio/frontend/src/features/profile/utils/stats-format.ts @@ -99,7 +99,9 @@ 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 shows the running lifetime total, which only ever grows. + * 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. */ export function seriesForMode( daily: Array<{ date: string; tokens: number }>, From 485684db5f64e718e7e7d3499ebee9e37446ff4f Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 29 Jul 2026 01:06:06 -0700 Subject: [PATCH 7/9] Studio: count fork clones per message and drop future streaks - A pre-fork message can be pruned from its original thread while the clone stays in the fork. The suppression only checked that the source thread existed, so that message's tokens and activity vanished. Clones are now matched to the row they came from, and one fork per source is elected to stand in whenever the original is gone, whether the whole thread or just that message went. - Future-dated history was filtered out of the current streak but still padded the longest streak and could be reported as the last active day. It is dropped before any streak field is computed. - Cumulative mode labelled its tooltip "week of" while showing the running total. It now reports that week's tokens; the bar height still uses the running total. - The activity grid and weekday axis formatted dates with the browser language rather than the language chosen in Settings, and the memos could not react to a change. They follow the app locale now. The fork fixtures were also corrected: fork_chat_thread copies created_at verbatim, so a clone carries the original's timestamp, which the previous fixtures did not model. --- studio/backend/storage/profile_stats_db.py | 72 ++++++++++------- studio/backend/tests/test_profile_stats.py | 81 +++++++++++++++++-- .../profile/components/stats/rhythm-card.tsx | 9 ++- .../components/stats/token-activity-card.tsx | 18 +++-- 4 files changed, 138 insertions(+), 42 deletions(-) diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 8c1332c46e..795da78b7d 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -109,7 +109,12 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]: 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} @@ -122,9 +127,7 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]: last = ordered[-1] current_streak = 0 - # A future-dated row (client clock ahead) is not a live streak: the last - # active day still has to be today or yesterday. - if timedelta(0) <= today - last <= timedelta(days = 1): + if today - last <= timedelta(days = 1): current_streak = 1 cursor = last while cursor - timedelta(days = 1) in days: @@ -186,14 +189,15 @@ class _MessageFold: bucket["threads"].add(thread_id) -def _orphan_fork_keepers(conn) -> set[str]: - """Forks whose source is gone and that should still count their copies. +def _fork_keepers(conn) -> set[str]: + """One fork per source, elected to stand in for originals that are gone. - ``forked_from_thread_id`` is not a foreign key, so deleting a source leaves - its forks holding the only copies of that ancestry. Enabling all of them - would multiply the usage by the number of siblings, so exactly one per dead - source is kept: the one carrying the most copied rows, which is the fork - that branched latest and therefore holds the longest ancestry. + A clone is normally ignored because the original is counted instead. When + the original is not there any more, whether its thread was deleted or just + that message was pruned, the clones become the only record. Letting every + sibling count them would multiply the usage, so exactly one fork per source + is allowed to: the one holding the most copied rows, which is the fork that + branched latest and therefore carries the longest ancestry. """ rows = conn.execute( """ @@ -202,7 +206,6 @@ def _orphan_fork_keepers(conn) -> set[str]: WHERE m.thread_id = t.id AND m.created_at < t.created_at) AS copied FROM chat_threads t WHERE t.forked_from_thread_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM chat_threads s WHERE s.id = t.forked_from_thread_id) """ ).fetchall() @@ -215,21 +218,36 @@ def _orphan_fork_keepers(conn) -> set[str]: return {thread_id for _, thread_id in best.values()} +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 = _orphan_fork_keepers(conn) + 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, - src.id AS fork_source_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 - -- forked_from_thread_id is not a foreign key, so it outlives the source. - -- Joining to the real row means the copies are only suppressed while - -- the originals are still there to be counted. - LEFT JOIN chat_threads src ON src.id = t.forked_from_thread_id ORDER BY m.thread_id, m.created_at """ ) @@ -274,16 +292,14 @@ def _fold_messages(conn, zone) -> _MessageFold: fold.threads.add(conversation_id) # Forking clones the whole ancestry into the new thread, keeping each - # copy's original timestamp. Counting those again would double every - # metric for the branched-from conversation, so skip anything older - # than the fork itself. Once the source is gone one fork is elected to - # keep its copies, since they are then the only record left. - if ( - row["forked_from_thread_id"] - and created_at < _as_int(row["thread_created_at"]) - and (row["fork_source_id"] or thread_id not in keepers) - ): - continue + # 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 thread_id not in keepers: + continue fold.messages += 1 thread_messages += 1 diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index f5a00c3ec9..aee1578c90 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -23,6 +23,11 @@ def stats_db(tmp_path, monkeypatch): 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) @@ -55,7 +60,7 @@ def _seed_thread(conn, thread_id: str, model_id: str, turns: list[tuple[datetime "assistant", json.dumps([{"type": "text", "text": "hello"}]), json.dumps(metadata), - _ms(when + timedelta(seconds = 10)), + _ms(when + REPLY_DELAY), ), ) @@ -309,7 +314,7 @@ def test_forked_threads_do_not_double_count_copied_history(stats_db): 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))), + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), ) conn.commit() finally: @@ -418,7 +423,7 @@ def test_forks_count_as_chats_before_their_first_new_turn(stats_db): 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))), + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), ) conn.commit() finally: @@ -537,7 +542,7 @@ def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db): 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))), + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), ) conn.commit() finally: @@ -577,7 +582,12 @@ def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db): 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)), + ( + f"{fork_id}-a0", + fork_id, + json.dumps(_metadata(100, 50)), + _ms(older + REPLY_DELAY), + ), ) conn.commit() finally: @@ -600,6 +610,67 @@ def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db): 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() diff --git a/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx index d5c7cf6a2d..99e0f04838 100644 --- a/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx +++ b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx @@ -7,7 +7,7 @@ import { ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; -import { useT } from "@/i18n"; +import { useLocale, useT } from "@/i18n"; import { useMemo } from "react"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; import type { ProfileStats } from "../../api/profile-stats"; @@ -92,15 +92,18 @@ export function HourRhythmCard({ stats }: { stats: ProfileStats }) { /** Weekday distribution, Monday-first to match the activity grid columns. */ export function WeekdayRhythmCard({ stats }: { stats: ProfileStats }) { const t = useT(); + // The app language, so the axis matches the rest of the panel when the user + // has picked a language that differs from the browser's. + const locale = useLocale(); const names = useMemo(() => { - const formatter = new Intl.DateTimeFormat(navigator.language, { + const formatter = new Intl.DateTimeFormat(locale, { weekday: "short", }); // 2024-01-01 was a Monday, so this walks Mon..Sun in the user's locale. return Array.from({ length: 7 }, (_, index) => formatter.format(new Date(2024, 0, 1 + index)), ); - }, []); + }, [locale]); const data = useMemo( () => 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 index 3a9565c471..4e6b1230bd 100644 --- a/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx +++ b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx @@ -1,7 +1,7 @@ // 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 { useLocale, useT } from "@/i18n"; import { cn } from "@/lib/utils"; import { useEffect, useMemo, useRef, useState } from "react"; import type { ProfileStatsDay } from "../../api/profile-stats"; @@ -214,10 +214,12 @@ function BarColumn({ 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.value), + tokens: formatFullNumber(summary.tokens), }) : ""; @@ -246,9 +248,13 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) { () => buildColumns(daily, values, columns), [daily, values, columns], ); + // 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, navigator.language), - [grid], + () => 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. @@ -281,12 +287,12 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) { const dateFormatter = useMemo( () => - new Intl.DateTimeFormat(navigator.language, { + new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", }), - [], + [locale], ); return ( From 8644b4c208c1ed576198509f3774c8e5ea3d553a Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 29 Jul 2026 01:16:11 -0700 Subject: [PATCH 8/9] Studio: drop the hour and weekday rhythm cards Removes the "When you work" and "Your week" row from the Profile stats. The backend stopped computing the hour and weekday buckets too, since nothing else read them, and the payload no longer carries them. The timezone and malformed-timestamp tests now assert on day buckets, which is what the activity grid and streaks actually use; the daylight-saving case moved to a timestamp where the hour of drift crosses midnight so the day still proves it. This was the only recharts consumer in the profile panel, so the lazy split's note about keeping charts out of the main bundle no longer applies and has been corrected. Build drops about 22 KB across two fewer chunks. --- studio/backend/storage/profile_stats_db.py | 6 - studio/backend/tests/test_profile_stats.py | 23 +-- .../src/features/profile/api/profile-stats.ts | 4 - .../stats/profile-stats-content.tsx | 11 +- .../profile/components/stats/rhythm-card.tsx | 165 ------------------ studio/frontend/src/i18n/locales/en.ts | 6 - 6 files changed, 15 insertions(+), 200 deletions(-) delete mode 100644 studio/frontend/src/features/profile/components/stats/rhythm-card.tsx diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 795da78b7d..6f0d05de96 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -166,8 +166,6 @@ class _MessageFold: "messages": 0, } self.by_day: dict[date, dict[str, Any]] = {} - self.by_hour = [0] * 24 - self.by_weekday = [0] * 7 self.models: dict[str, dict[str, Any]] = {} self.speed_samples: list[float] = [] self.best_speed = 0.0 @@ -388,8 +386,6 @@ def _fold_messages(conn, zone) -> _MessageFold: fold.first_token_ms.append(first_token) if stamp is not None: - fold.by_hour[stamp.hour] += 1 - fold.by_weekday[stamp.weekday()] += 1 fold.note_day(stamp.date(), message_tokens, conversation_id) close_thread() @@ -598,8 +594,6 @@ def compute_profile_stats( else None ), "daily": daily, - "hourly": fold.by_hour, - "weekday": fold.by_weekday, "models": models, "speed": { "averageTokensPerSecond": ( diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index aee1578c90..e36c5440d1 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -482,9 +482,13 @@ def test_recent_run_name_prefers_the_users_rename(stats_db): def test_historical_daylight_saving_offsets_are_respected(stats_db): - """A fixed offset would put a winter message in the wrong hour.""" - # 2026-01-15 02:30 UTC. New York is UTC-5 in January, so 21:30 on the 14th. - winter = datetime(2026, 1, 15, 2, 30, tzinfo = timezone.utc) + """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( @@ -506,11 +510,10 @@ def test_historical_daylight_saving_offsets_are_respected(stats_db): invalidate_profile_stats_cache() offset_only = compute_profile_stats(days = 366, tz_offset_minutes = 240) - assert named["hourly"][21] == 1 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 offset_only["hourly"][22] == 1 + 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): @@ -755,11 +758,12 @@ def test_out_of_range_timestamps_do_not_break_the_panel(stats_db): stats = compute_profile_stats(days = 7) - # Totals still include the bad row; only its day and hour buckets are - # dropped, so the two well-formed messages are all that is placed. + # 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 sum(stats["hourly"]) == 2 + 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): @@ -801,9 +805,6 @@ def test_days_and_hours_use_the_callers_timezone(stats_db): invalidate_profile_stats_cache() at_minus_four = compute_profile_stats(days = 366, tz_offset_minutes = 240) - assert at_utc["hourly"][1] == 1 - assert at_minus_four["hourly"][21] == 1 - 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"} diff --git a/studio/frontend/src/features/profile/api/profile-stats.ts b/studio/frontend/src/features/profile/api/profile-stats.ts index a35cc23887..7f3211232d 100644 --- a/studio/frontend/src/features/profile/api/profile-stats.ts +++ b/studio/frontend/src/features/profile/api/profile-stats.ts @@ -61,10 +61,6 @@ export type ProfileStats = { messages: number; } | null; daily: ProfileStatsDay[]; - /** Messages per hour of day, index 0..23. */ - hourly: number[]; - /** Messages per weekday, index 0 = Monday. */ - weekday: number[]; models: ProfileStatsModel[]; speed: { averageTokensPerSecond: number | null; 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 index 6feb8faa71..29c9381030 100644 --- a/studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx +++ b/studio/frontend/src/features/profile/components/stats/profile-stats-content.tsx @@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"; import { useT } from "@/i18n"; import { useProfileStats } from "../../hooks/use-profile-stats"; import { ActivityInsightsCard, TopModelsCard } from "./insights-card"; -import { HourRhythmCard, WeekdayRhythmCard } from "./rhythm-card"; import { StatsCard } from "./stat-primitives"; import { StatsHighlights } from "./stats-highlights"; import { StatsSkeleton } from "./stats-skeleton"; @@ -14,12 +13,12 @@ import { TrainingHighlightsCard } from "./training-card"; /** * Everything below the personalization form on the Profile tab: headline - * numbers, activity grid, insights, rhythms and training. + * 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` to keep recharts, pulled in by the - * rhythm charts, out of the main bundle. + * 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(); @@ -70,10 +69,6 @@ export function ProfileStatsContent() {
-
- - -
) : ( diff --git a/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx deleted file mode 100644 index 99e0f04838..0000000000 --- a/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// 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 { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import type { ChartConfig } from "@/components/ui/chart"; -import { useLocale, useT } from "@/i18n"; -import { useMemo } from "react"; -import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; -import type { ProfileStats } from "../../api/profile-stats"; -import { formatFullNumber } from "../../utils/stats-format"; -import { StatsCard } from "./stat-primitives"; - -const HOURS_IN_DAY = 24; -const CHART_CLASS = "h-[160px] w-full"; - -function chartConfig(label: string): ChartConfig { - return { messages: { label, color: "var(--primary)" } } satisfies ChartConfig; -} - -/** Hour-of-day histogram: when during the day the user actually works. */ -export function HourRhythmCard({ stats }: { stats: ProfileStats }) { - const t = useT(); - const data = useMemo( - () => - Array.from({ length: HOURS_IN_DAY }, (_, hour) => ({ - hour, - label: `${`${hour}`.padStart(2, "0")}:00`, - messages: stats.hourly[hour] ?? 0, - })), - [stats.hourly], - ); - - const busiest = useMemo( - () => - data.reduce( - (best, entry) => (entry.messages > best.messages ? entry : best), - data[0] ?? { hour: 0, label: "00:00", messages: 0 }, - ), - [data], - ); - - return ( - 0 - ? t("settings.profile.stats.hourDescription", { hour: busiest.label }) - : t("settings.profile.stats.noRhythm") - } - > - - - - `${hour}`} - className="text-ui-11" - /> - - `${payload?.[0]?.payload?.label ?? ""}` - } - formatter={(value) => formatFullNumber(Number(value))} - /> - } - /> - - - - - ); -} - -/** Weekday distribution, Monday-first to match the activity grid columns. */ -export function WeekdayRhythmCard({ stats }: { stats: ProfileStats }) { - const t = useT(); - // The app language, so the axis matches the rest of the panel when the user - // has picked a language that differs from the browser's. - const locale = useLocale(); - const names = useMemo(() => { - const formatter = new Intl.DateTimeFormat(locale, { - weekday: "short", - }); - // 2024-01-01 was a Monday, so this walks Mon..Sun in the user's locale. - return Array.from({ length: 7 }, (_, index) => - formatter.format(new Date(2024, 0, 1 + index)), - ); - }, [locale]); - - const data = useMemo( - () => - names.map((name, index) => ({ - day: name, - messages: stats.weekday[index] ?? 0, - })), - [names, stats.weekday], - ); - - const busiest = useMemo( - () => - data.reduce( - (best, entry) => (entry.messages > best.messages ? entry : best), - data[0] ?? { day: "", messages: 0 }, - ), - [data], - ); - - return ( - 0 - ? t("settings.profile.stats.weekdayDescription", { day: busiest.day }) - : t("settings.profile.stats.noRhythm") - } - > - - - - - formatFullNumber(Number(value))} - /> - } - /> - - - - - ); -} diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 5253c48f9a..0e74c9d921 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -451,16 +451,10 @@ export const en = { bestSpeed: "Fastest response", firstToken: "Average time to first token", tokensPerSecond: "{value} tok/s", - messages: "Messages", topModelsTitle: "Most used models", topModelsDescription: "Ranked by tokens exchanged", modelSummary: "{tokens} ยท {messages} msgs", noModels: "No model usage recorded yet.", - hourTitle: "When you work", - hourDescription: "Busiest hour: {hour}", - weekdayTitle: "Your week", - weekdayDescription: "Busiest day: {day}", - noRhythm: "Not enough activity yet.", trainingTitle: "Training", trainingDescription: "Fine-tuning runs from this workspace", trainingRuns: "Runs", From df7300a3e10b3e70fc96bfebcd562e80929abb42 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 29 Jul 2026 02:34:24 -0700 Subject: [PATCH 9/9] Studio: harden the profile stats aggregation Four fixes from review: - _as_float raised OverflowError on a JSON integer wider than float, so one oversized counter returned 500 for the whole panel. It degrades to zero now, like every other unreadable field. - Fork dedup elected one winner per source thread, but fork_chat_thread copies a single parent_id branch, so sibling forks of a retry and a regeneration hold different rows. Electing per original message keeps each branch when the source is deleted. - create_run claims a resume source before the continuation logs its first step, so a continuation that failed early took the source's completed steps and tokens with it. Supersession now needs the continuation to have reached the source's step. - Cumulative activity is a running total over the displayed window, but a narrow card trims older weeks without rebasing, opening the first visible bar at the hidden total and flattening the rest. --- studio/backend/storage/profile_stats_db.py | 67 ++++++---- studio/backend/tests/test_profile_stats.py | 117 ++++++++++++++++++ .../components/stats/token-activity-card.tsx | 15 ++- .../features/profile/utils/stats-format.ts | 14 +++ .../tests/profile-stats-format.test.ts | 32 +++++ 5 files changed, 218 insertions(+), 27 deletions(-) diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 6f0d05de96..f5c6795ea3 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -48,13 +48,19 @@ _cache: dict[str, Any] = {"fingerprint": None, "expires_at": 0.0, "payload": Non def _as_float(value: Any) -> Optional[float]: - """Coerce JSON numbers defensively; metadata is written by the client.""" + """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)): - return ( - float(value) if value == value and value not in (float("inf"), float("-inf")) else None - ) + 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 @@ -187,33 +193,39 @@ class _MessageFold: bucket["threads"].add(thread_id) -def _fork_keepers(conn) -> set[str]: - """One fork per source, elected to stand in for originals that are gone. +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. When - the original is not there any more, whether its thread was deleted or just - that message was pruned, the clones become the only record. Letting every - sibling count them would multiply the usage, so exactly one fork per source - is allowed to: the one holding the most copied rows, which is the fork that - branched latest and therefore carries the longest ancestry. + 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 t.id, t.forked_from_thread_id AS source_id, - (SELECT COUNT(*) FROM chat_messages m - WHERE m.thread_id = t.id AND m.created_at < t.created_at) AS copied - FROM chat_threads t + 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 """ - ).fetchall() + ) - best: dict[str, tuple[int, str]] = {} + best: dict[tuple[str, int, str], str] = {} for row in rows: - rank = (_as_int(row["copied"]), row["id"]) - current = best.get(row["source_id"]) - if current is None or rank > current: - best[row["source_id"]] = rank - return {thread_id for _, thread_id in best.values()} + 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]]: @@ -296,7 +308,7 @@ def _fold_messages(conn, zone) -> _MessageFold: 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 thread_id not in keepers: + if original in surviving or keepers.get(original) != thread_id: continue fold.messages += 1 @@ -423,6 +435,11 @@ def _superseded(prefix: str = "r.") -> str: ``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 @@ -431,6 +448,8 @@ def _superseded(prefix: str = "r.") -> str: 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) ) """ diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index e36c5440d1..489f53625e 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -887,3 +887,120 @@ def test_route_does_not_block_the_event_loop(stats_db, monkeypatch): # ~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/components/stats/token-activity-card.tsx b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx index 4e6b1230bd..7a11ec7769 100644 --- a/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx +++ b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx @@ -12,6 +12,7 @@ import { heatLevel, parseDayKey, seriesForMode, + windowBaseline, } from "../../utils/stats-format"; import { StatsCard } from "./stat-primitives"; @@ -39,6 +40,7 @@ function buildColumns( daily: ProfileStatsDay[], values: number[], columns: number, + mode: ActivityMode, ): Cell[][] { if (daily.length === 0 || columns <= 0) return []; @@ -52,6 +54,9 @@ function buildColumns( 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. @@ -62,7 +67,11 @@ function buildColumns( 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 }); + cells.push({ + key: day.date, + day, + value: (values[start + index] ?? 0) - baseline, + }); } const grid: Cell[][] = []; @@ -245,8 +254,8 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) { const shaded = mode === "daily"; const values = useMemo(() => seriesForMode(daily, mode), [daily, mode]); const grid = useMemo( - () => buildColumns(daily, values, columns), - [daily, values, columns], + () => 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 diff --git a/studio/frontend/src/features/profile/utils/stats-format.ts b/studio/frontend/src/features/profile/utils/stats-format.ts index aee026f4c1..4f1b1ea16f 100644 --- a/studio/frontend/src/features/profile/utils/stats-format.ts +++ b/studio/frontend/src/features/profile/utils/stats-format.ts @@ -103,6 +103,20 @@ export type ActivityMode = "daily" | "weekly" | "cumulative"; * 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, diff --git a/studio/frontend/tests/profile-stats-format.test.ts b/studio/frontend/tests/profile-stats-format.test.ts index 7bf464ce97..16a5ed13e7 100644 --- a/studio/frontend/tests/profile-stats-format.test.ts +++ b/studio/frontend/tests/profile-stats-format.test.ts @@ -11,6 +11,7 @@ import { heatLevel, parseDayKey, seriesForMode, + windowBaseline, } from "../src/features/profile/utils/stats-format.ts"; test("compact numbers match the tile format", () => { @@ -79,3 +80,34 @@ test("series modes reshape the same daily data", () => { 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); +});