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