Studio: harden profile stats against deleted and malformed history
- Deleting a forked conversation's source left the fork holding the only copies of those messages, but the skip still dropped them because forked_from_thread_id is not a foreign key. The suppression now joins the source row, so copies are only ignored while the originals survive. - delete_run never clears the predecessor's resume_blocked, so removing a continuation stranded its source at zero steps and tokens while the run stayed visible. Supersession now requires a continuation that still exists. - created_at is a client-supplied integer stored unchecked, so one out-of-range row made datetime raise and returned 500 for the whole panel. Those rows are now skipped for day and hour buckets. - A future-dated row satisfied the current-streak window, reporting days that have not happened. The last active day must now be today or yesterday. - Compact numbers rounded 999,999 to "1000K". Rounding into the next unit now steps up the suffix. - Escape or a programmatic close unmounts the Profile tab without a blur, dropping an in-progress name edit. Drafts are committed on unmount; both saves no-op when unchanged. - Stats copy no longer claims the numbers never leave the device, which is untrue when Studio is reached from another machine. It states what actually holds: nothing is collected or sent to Unsloth.
This commit is contained in:
parent
9ae3090b98
commit
1338f09b97
9 changed files with 241 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -30,14 +30,27 @@ const SHAPE: Record<AvatarShape, string> = {
|
|||
rounded: "rounded-[22%]",
|
||||
};
|
||||
|
||||
export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) {
|
||||
export function UserAvatar({
|
||||
name,
|
||||
imageUrl,
|
||||
size,
|
||||
className,
|
||||
shape,
|
||||
}: UserAvatarProps) {
|
||||
const label = initialsFromName(name);
|
||||
const storedShape = useUserProfileStore((s) => s.avatarShape);
|
||||
const shapeClass = SHAPE[shape ?? storedShape];
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<span className={cn("relative inline-flex shrink-0 overflow-hidden bg-transparent", shapeClass, SIZE[size], className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 overflow-hidden bg-transparent",
|
||||
shapeClass,
|
||||
SIZE[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="size-full object-cover" />
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@ export function usePersonalizationSync(enabled: boolean): void {
|
|||
// default so the push detects the diff) rather than treating the
|
||||
// default as an explicit remote choice. A record that actually stored
|
||||
// the field reports <field>Saved=true and still wins.
|
||||
const localGreeting = useUserProfileStore.getState().showGreetingSloth;
|
||||
const localGreeting =
|
||||
useUserProfileStore.getState().showGreetingSloth;
|
||||
const remoteGreeting = remote.profile.showGreetingSloth !== false;
|
||||
const keepLocalGreeting =
|
||||
remote.greetingSlothSaved === false && localGreeting === false;
|
||||
|
|
@ -248,7 +249,9 @@ export function usePersonalizationSync(enabled: boolean): void {
|
|||
avatarDataUrl: remote.profile.avatarDataUrl ?? null,
|
||||
avatarShape:
|
||||
remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
|
||||
showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting,
|
||||
showGreetingSloth: keepLocalGreeting
|
||||
? localGreeting
|
||||
: remoteGreeting,
|
||||
};
|
||||
const nextTheme = remote.appearance.theme;
|
||||
const localPalette = latestPaletteRef.current;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ function loadImage(file: File): Promise<HTMLImageElement> {
|
|||
});
|
||||
}
|
||||
|
||||
function canvasHasTransparency(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
function canvasHasTransparency(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
): boolean {
|
||||
const { data } = ctx.getImageData(0, 0, width, height);
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] < 255) return true;
|
||||
|
|
@ -67,7 +71,12 @@ function drawScaled(
|
|||
w: number,
|
||||
h: number,
|
||||
maxEdge: number,
|
||||
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } {
|
||||
): {
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
cw: number;
|
||||
ch: number;
|
||||
} {
|
||||
const scale = Math.min(1, maxEdge / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
|
|
@ -96,16 +105,28 @@ export async function resizeImageFileToDataUrl(file: File): Promise<string> {
|
|||
// would paint a background behind a transparent image.
|
||||
for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) {
|
||||
const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge);
|
||||
const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START);
|
||||
const webpDataUrl = encodeCanvasWithinLimit(
|
||||
canvas,
|
||||
"image/webp",
|
||||
WEBP_QUALITY_START,
|
||||
);
|
||||
if (webpDataUrl) return webpDataUrl;
|
||||
const pngDataUrl = encodePngWithinLimit(canvas);
|
||||
if (pngDataUrl) return pngDataUrl;
|
||||
}
|
||||
throw new Error("Image is still too large after compression. Try a smaller file.");
|
||||
throw new Error(
|
||||
"Image is still too large after compression. Try a smaller file.",
|
||||
);
|
||||
}
|
||||
|
||||
const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START);
|
||||
const jpegDataUrl = encodeCanvasWithinLimit(
|
||||
base.canvas,
|
||||
"image/jpeg",
|
||||
JPEG_QUALITY_START,
|
||||
);
|
||||
if (jpegDataUrl) return jpegDataUrl;
|
||||
|
||||
throw new Error("Image is still too large after compression. Try a smaller file.");
|
||||
throw new Error(
|
||||
"Image is still too large after compression. Try a smaller file.",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue