diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 38a3fdfe45..041d1e2d6e 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -90,10 +90,18 @@ def _resolve_zone(tz_name: str, tz_offset_minutes: int): def _local_stamp(created_at_ms: int, zone) -> Optional[datetime]: - """Wall-clock time in the caller's timezone, not the server's.""" + """Wall-clock time in the caller's timezone, not the server's. + + created_at is a client-supplied integer that SQLite stores unchecked, so a + value outside datetime's range is possible. Drop that row rather than let + one bad import take down the whole panel. + """ if created_at_ms <= 0: return None - return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None) + try: + return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None) + except (ValueError, OverflowError, OSError): + return None def _streaks(days: set[date], today: date) -> dict[str, Any]: @@ -114,7 +122,9 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]: last = ordered[-1] current_streak = 0 - if today - last <= timedelta(days = 1): + # A future-dated row (client clock ahead) is not a live streak: the last + # active day still has to be today or yesterday. + if timedelta(0) <= today - last <= timedelta(days = 1): current_streak = 1 cursor = last while cursor - timedelta(days = 1) in days: @@ -182,9 +192,13 @@ def _fold_messages(conn, zone) -> _MessageFold: """ SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at, t.title, t.model_id, t.model_type, - t.created_at AS thread_created_at, t.forked_from_thread_id + t.created_at AS thread_created_at, src.id AS fork_source_id FROM chat_messages m LEFT JOIN chat_threads t ON t.id = m.thread_id + -- forked_from_thread_id is not a foreign key, so it outlives the source. + -- Joining to the real row means the copies are only suppressed while + -- the originals are still there to be counted. + LEFT JOIN chat_threads src ON src.id = t.forked_from_thread_id ORDER BY m.thread_id, m.created_at """ ) @@ -225,8 +239,9 @@ def _fold_messages(conn, zone) -> _MessageFold: # Forking clones the whole ancestry into the new thread, keeping each # copy's original timestamp. Counting those again would double every # metric for the branched-from conversation, so skip anything older - # than the fork itself. - if row["forked_from_thread_id"] and created_at < _as_int(row["thread_created_at"]): + # than the fork itself, but only while the source survives to be + # counted in its place. + if row["fork_source_id"] and created_at < _as_int(row["thread_created_at"]): continue fold.messages += 1 @@ -342,14 +357,29 @@ def _daily_series(fold: _MessageFold, today: date, days: int) -> list[dict[str, return series -def _superseded(prefix: str) -> str: +def _superseded(prefix: str = "r.") -> str: """SQL for "a later run resumed from this one, so its counters live there". + ``prefix`` must qualify the outer row: the EXISTS subquery selects from the + same table, so a bare column name would bind to the subquery instead. + ``create_run``'s resume claim sets ``resume_blocked`` and leaves ``output_dir`` alone. Cancelling clears ``output_dir`` while setting the same flag, so the flag alone cannot tell the two apart. + + ``delete_run`` never clears the flag, so the continuation has to still be + there. Otherwise deleting it would strand the source at zero while its row + and metrics stay visible in history. + """ + return f""" + {prefix}resume_blocked = 1 + AND {prefix}output_dir IS NOT NULL + AND EXISTS ( + SELECT 1 FROM training_runs continuation + WHERE continuation.output_dir = {prefix}output_dir + AND continuation.started_at > {prefix}started_at + ) """ - return f"{prefix}resume_blocked = 1 AND {prefix}output_dir IS NOT NULL" def _training_stats(conn) -> dict[str, Any]: @@ -371,7 +401,7 @@ def _training_stats(conn) -> dict[str, Any]: # resume_blocked while leaving output_dir intact, whereas cancelling clears # output_dir, so a cancelled run keeps contributing the work it did do. steps = conn.execute( - f"SELECT COALESCE(SUM(final_step), 0) FROM training_runs WHERE NOT ({_superseded('')})" + f"SELECT COALESCE(SUM(r.final_step), 0) FROM training_runs r WHERE NOT ({_superseded()})" ).fetchone()[0] # num_tokens is state.num_input_tokens_seen, a running total logged at each diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index 608b9a023d..ed5af2aff4 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -522,6 +522,122 @@ def test_unknown_timezone_falls_back_to_the_offset(stats_db): assert stats["totals"]["totalTokens"] == 20 +def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db): + """forked_from_thread_id is not a foreign key, so it outlives the source.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "gone", "m", [(now - timedelta(hours = 2), _metadata(100, 50))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES ('kept', 'fork', 'base', 'm', ?, ?, 'gone', 'gone-a0')", + (_ms(now), _ms(now)), + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('kept-a0', 'kept', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))), + ) + conn.commit() + finally: + conn.close() + + assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM chat_threads WHERE id = 'gone'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + stats = compute_profile_stats(days = 7) + + # The fork now holds the only copy, so it must still be counted once. + assert stats["totals"]["totalTokens"] == 150 + assert stats["totals"]["messages"] == 1 + + +def test_deleting_a_continuation_restores_its_source(stats_db): + """delete_run leaves resume_blocked set, so supersession needs a live tail.""" + conn = studio_db.get_connection() + try: + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, output_dir, resume_blocked) " + "VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, " + "'/runs/out', 1)", + ) + conn.execute( + "INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, " + "started_at, total_steps, final_step, output_dir, resume_blocked) " + "VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, " + "'/runs/out', 0)", + ) + conn.commit() + finally: + conn.close() + + assert compute_profile_stats(days = 7)["training"]["steps"] == 15 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM training_runs WHERE id = 'cont'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + + # With the continuation gone the source is the only record of that work. + assert compute_profile_stats(days = 7)["training"]["steps"] == 10 + + +def test_out_of_range_timestamps_do_not_break_the_panel(stats_db): + """created_at is client supplied and SQLite stores it unchecked.""" + now = datetime.now() + conn = studio_db.get_connection() + try: + _seed_thread(conn, "sane", "m", [(now, _metadata(10, 10))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) " + "VALUES ('bad', 'bad', 'base', 'm', 1, 1)", + ) + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('bad-a0', 'bad', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(7, 3)), 99_999_999_999_999_999), + ) + conn.commit() + finally: + conn.close() + + stats = compute_profile_stats(days = 7) + + # Totals still include the bad row; only its day and hour buckets are + # dropped, so the two well-formed messages are all that is placed. + assert stats["totals"]["totalTokens"] == 30 + assert stats["totals"]["messages"] == 3 + assert sum(stats["hourly"]) == 2 + + +def test_future_dated_history_is_not_a_current_streak(stats_db): + """A client clock that ran ahead must not report a streak that has not happened.""" + future = datetime.now() + timedelta(days = 5) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "ahead", "m", [(future, _metadata(10, 10))]) + conn.commit() + finally: + conn.close() + + streak = compute_profile_stats(days = 366)["streak"] + + assert streak["current"] == 0 + + def test_days_and_hours_use_the_callers_timezone(stats_db): """A remote browser must not be bucketed against the server's calendar.""" # 01:30 UTC. In UTC that is one day; at UTC-4 it is 21:30 the day before. diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx index dff9808248..2f4ac8db4d 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -138,6 +138,18 @@ export function ProfilePersonalizationPanel() { } }; + // Escape, or any programmatic close, unmounts the tab without dispatching a + // blur, which would drop whatever was typed. Commit the drafts on the way + // out; both saves no-op on an unchanged value, so a double commit is safe. + const flushDrafts = useRef<() => void>(() => {}); + useEffect(() => { + flushDrafts.current = () => { + saveName(); + saveNickname(); + }; + }); + useEffect(() => () => flushDrafts.current(), []); + const applyAvatar = (value: string | null) => { setAvatarDataUrl(value); const persisted = readPersistedProfile(); diff --git a/studio/frontend/src/features/profile/components/user-avatar.tsx b/studio/frontend/src/features/profile/components/user-avatar.tsx index e883fc5fad..99d7a4f339 100644 --- a/studio/frontend/src/features/profile/components/user-avatar.tsx +++ b/studio/frontend/src/features/profile/components/user-avatar.tsx @@ -30,14 +30,27 @@ const SHAPE: Record = { rounded: "rounded-[22%]", }; -export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) { +export function UserAvatar({ + name, + imageUrl, + size, + className, + shape, +}: UserAvatarProps) { const label = initialsFromName(name); const storedShape = useUserProfileStore((s) => s.avatarShape); const shapeClass = SHAPE[shape ?? storedShape]; if (imageUrl) { return ( - + ); diff --git a/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts b/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts index 6e919505ea..0e4ee97ff1 100644 --- a/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts +++ b/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts @@ -238,7 +238,8 @@ export function usePersonalizationSync(enabled: boolean): void { // default so the push detects the diff) rather than treating the // default as an explicit remote choice. A record that actually stored // the field reports Saved=true and still wins. - const localGreeting = useUserProfileStore.getState().showGreetingSloth; + const localGreeting = + useUserProfileStore.getState().showGreetingSloth; const remoteGreeting = remote.profile.showGreetingSloth !== false; const keepLocalGreeting = remote.greetingSlothSaved === false && localGreeting === false; @@ -248,7 +249,9 @@ export function usePersonalizationSync(enabled: boolean): void { avatarDataUrl: remote.profile.avatarDataUrl ?? null, avatarShape: remote.profile.avatarShape === "rounded" ? "rounded" : "circle", - showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting, + showGreetingSloth: keepLocalGreeting + ? localGreeting + : remoteGreeting, }; const nextTheme = remote.appearance.theme; const localPalette = latestPaletteRef.current; diff --git a/studio/frontend/src/features/profile/utils/resize-image-file.ts b/studio/frontend/src/features/profile/utils/resize-image-file.ts index 77ead49dea..beb9aeea74 100644 --- a/studio/frontend/src/features/profile/utils/resize-image-file.ts +++ b/studio/frontend/src/features/profile/utils/resize-image-file.ts @@ -28,7 +28,11 @@ function loadImage(file: File): Promise { }); } -function canvasHasTransparency(ctx: CanvasRenderingContext2D, width: number, height: number): boolean { +function canvasHasTransparency( + ctx: CanvasRenderingContext2D, + width: number, + height: number, +): boolean { const { data } = ctx.getImageData(0, 0, width, height); for (let i = 3; i < data.length; i += 4) { if (data[i] < 255) return true; @@ -67,7 +71,12 @@ function drawScaled( w: number, h: number, maxEdge: number, -): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } { +): { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + cw: number; + ch: number; +} { const scale = Math.min(1, maxEdge / Math.max(w, h)); const cw = Math.max(1, Math.round(w * scale)); const ch = Math.max(1, Math.round(h * scale)); @@ -96,16 +105,28 @@ export async function resizeImageFileToDataUrl(file: File): Promise { // would paint a background behind a transparent image. for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) { const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge); - const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START); + const webpDataUrl = encodeCanvasWithinLimit( + canvas, + "image/webp", + WEBP_QUALITY_START, + ); if (webpDataUrl) return webpDataUrl; const pngDataUrl = encodePngWithinLimit(canvas); if (pngDataUrl) return pngDataUrl; } - throw new Error("Image is still too large after compression. Try a smaller file."); + throw new Error( + "Image is still too large after compression. Try a smaller file.", + ); } - const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START); + const jpegDataUrl = encodeCanvasWithinLimit( + base.canvas, + "image/jpeg", + JPEG_QUALITY_START, + ); if (jpegDataUrl) return jpegDataUrl; - throw new Error("Image is still too large after compression. Try a smaller file."); + throw new Error( + "Image is still too large after compression. Try a smaller file.", + ); } diff --git a/studio/frontend/src/features/profile/utils/stats-format.ts b/studio/frontend/src/features/profile/utils/stats-format.ts index 49e4c8821d..518585e213 100644 --- a/studio/frontend/src/features/profile/utils/stats-format.ts +++ b/studio/frontend/src/features/profile/utils/stats-format.ts @@ -21,16 +21,21 @@ export function formatCompactNumber(value: number): string { { limit: 1e6, suffix: "M" }, { limit: 1e3, suffix: "K" }, ]; - for (const { limit, suffix } of units) { - if (abs >= limit) { - const scaled = value / limit; - // One decimal below 100 keeps "1.9B" readable; above it the decimal is noise. - const text = - Math.abs(scaled) >= 100 - ? Math.round(scaled).toString() - : scaled.toFixed(1); - return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`; + for (const [index, { limit, suffix }] of units.entries()) { + if (abs < limit) continue; + const scaled = value / limit; + // One decimal below 100 keeps "1.9B" readable; above it the decimal is noise. + const rounded = + Math.abs(scaled) >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1)); + // Rounding can push a value over the next boundary, and "1000K" is not + // compact. Step up a unit rather than print four digits. + const next = units[index - 1]; + if (next && Math.abs(rounded) >= 1000) { + return `${(value / next.limit).toFixed(1).replace(TRAILING_ZERO_DECIMAL, "")}${next.suffix}`; } + const text = + Math.abs(scaled) >= 100 ? rounded.toString() : scaled.toFixed(1); + return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`; } return String(Math.round(value)); } diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 05c4477881..5253c48f9a 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -411,10 +411,10 @@ export const en = { stats: { title: "Your stats", subtitle: - "Everything below is counted from history on this machine. Nothing is uploaded.", + "Everything below is counted from your own history. Nothing is collected or sent to Unsloth.", retry: "Try again", privacyNote: - "Stats are computed locally from your chat and training history and never leave this device.", + "Stats are computed from the chat and training history held by your Unsloth install. Nothing is collected, and nothing is sent to Unsloth or any third party.", emptyChats: "No chats yet. Start a conversation and your stats will fill in here.", lifetimeTokens: "Lifetime tokens", diff --git a/studio/frontend/tests/profile-stats-format.test.ts b/studio/frontend/tests/profile-stats-format.test.ts index 5324c48071..7bf464ce97 100644 --- a/studio/frontend/tests/profile-stats-format.test.ts +++ b/studio/frontend/tests/profile-stats-format.test.ts @@ -25,6 +25,17 @@ test("compact numbers match the tile format", () => { assert.equal(formatCompactNumber(Number.NaN), "0"); }); +test("rounding up a unit steps to the next suffix", () => { + // Rounding 999.5K to "1000K" is four digits, which is not compact. + assert.equal(formatCompactNumber(999_999), "1M"); + assert.equal(formatCompactNumber(999_500), "1M"); + assert.equal(formatCompactNumber(999_999_999), "1B"); + assert.equal(formatCompactNumber(999_999_999_999), "1T"); + assert.equal(formatCompactNumber(-999_999), "-1M"); + // Just below the rounding boundary the unit is unchanged. + assert.equal(formatCompactNumber(999_499), "999K"); +}); + test("durations read the way the header does", () => { assert.equal(formatDuration(0), "0m"); assert.equal(formatDuration(45), "45s");