diff --git a/Makefile b/Makefile index f4ef9e6..4f5d4e3 100644 --- a/Makefile +++ b/Makefile @@ -12,9 +12,9 @@ install: run: SLP_BASE_URL=https://api.smartloop.ai npm run dev -# Dev server against an API running on localhost:8888 +# Dev server against the API running locally: slp-api's `make supabase-dev` run-local: - SLP_BASE_URL=http://localhost:8888 npm run dev + SLP_BASE_URL=http://127.0.0.1:55321/functions/v1/api npm run dev # Build the static export into ./out build: diff --git a/README.md b/README.md index 0a8a26a..9d2b662 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,12 @@ make run `make run` starts a dev server at http://localhost:3000 against the production API, so you can sign in with your own Smartloop account. To point it at an API -running on your machine, use `make run-local` (expects `localhost:8888`) or set -`SLP_BASE_URL` yourself: +running on your machine, start it with `make supabase-dev` in slp-api (it seeds +`dev@smartloop.local` and `admin@smartloop.local`, password `SmartloopDev1#`) +and use `make run-local`, or set `SLP_BASE_URL` yourself: ```sh -SLP_BASE_URL=http://localhost:8888 npm run dev +SLP_BASE_URL=http://127.0.0.1:55321/functions/v1/api npm run dev ``` | Command | What it does | diff --git a/src/app/cloud-models/history-section.tsx b/src/app/cloud-models/history-section.tsx index dde39ff..8a0fc87 100644 --- a/src/app/cloud-models/history-section.tsx +++ b/src/app/cloud-models/history-section.tsx @@ -1,36 +1,46 @@ 'use client'; /** - * Session History: the account's view of the cloud ledger, one row per cloud - * session — when it was settled and the credits it cost. + * Session History: the account's cloud sessions, newest first — when each + * started, where it is, and the credits it cost. * * No project column: projects are local to the desktop app, and the cloud * only ever sees an opaque project id it cannot name. Per-project cloud usage * belongs in the desktop app, which can resolve those ids itself. * - * The ledger is a billing record, so this page is itemized rather than - * summarized and can be exported. The API decides what each account may see - * by leaving fields out, so the page shows whatever it sends: the Credits and - * USD columns appear when any row has them, a row - * opens to list every field it came with, and the export has a column for - * every field. + * GET /v1/chat/sessions pages on the server: the first page brings the + * range's summary, which feeds the footer, so the table never loads every + * row; "Show more" asks for the next page. Sessions that ended without + * charging anything (a key the app replaced before using it) are hidden + * unless asked for, and the summary says how many there are. The export is + * the whole range as the server writes it. * - * GET /v1/chat/ledger returns the whole range in one response, so rows are - * paged here, in the browser. + * The API decides what each account may see by leaving fields out, so the + * page shows whatever it sends: the USD column appears when rows have it, and + * a row opens to list every field it came with. * - * Changing the range keeps the current rows on screen, dimmed, until the new - * range arrives, so the table never collapses to a spinner and back. + * Changing the range or the filter keeps the current rows on screen, dimmed, + * until the new ones arrive, so the table never collapses to a spinner and + * back. */ -import { Fragment, useEffect, useMemo, useState } from 'react'; -import { Box, CircularProgress } from '@mui/material'; +import { Fragment, useEffect, useState } from 'react'; +import { Box, CircularProgress, Switch } from '@mui/material'; import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { Row, Section } from '@/app/components/rows'; -import { type LedgerEntry, count, credits, fetchLedger } from './shared'; +import { + SESSION_PAGE, + type SessionPage, + type SessionRow, + type SessionStatus, + count, + credits, + fetchSessions, + fetchSessionsCsv, +} from './shared'; const RANGES = [7, 30, 90] as const; -const PAGE = 50; const when = (iso: string) => new Date(iso).toLocaleString(undefined, { @@ -40,144 +50,183 @@ const when = (iso: string) => minute: '2-digit', }); +const STATUS_LABEL: Record = { + active: 'In progress', + ended: 'Charge pending', + settled: 'Settled', +}; + /** Labels for the fields a row's details list; any other field gets its key, * de-snaked. Listed in this order, then the rest as the API sent them. */ const FIELD_LABEL: Record = { - created_at: 'Settled', - cost: 'Credits', - upstream_cost_micros: 'Upstream cost', - provider: 'Provider', - plan: 'Plan', - period: 'Period', + status: 'Status', + started_at: 'Started', + expires_at: 'Key expires', + settled_at: 'Settled', + credits: 'Credits', + reserved: 'Credits held', + usd: 'Upstream cost', + limit_usd: 'Key limit', + markup: 'Markup', project_id: 'Project id', + key_hash: 'OpenRouter key', id: 'Session id', }; const label = (key: string) => FIELD_LABEL[key] ?? key.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()); -/** One field's value as the details show it. Amounts kept in micro-USD or - * millicredits on the ledger read in dollars and credits. */ +const usd = (n: number) => `$${n.toFixed(n < 0.01 ? 4 : 2)}`; + +/** One field's value as the details show it. */ function display(key: string, value: unknown): string { if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'number') { - if (key.endsWith('_micros')) return `$${(value / 1_000_000).toFixed(6).replace(/\.?0+$/, '')}`; - if (key.endsWith('_millicredits')) return `${credits(value / 1000)} credits`; - if (key === 'cost') return `${credits(value)} credits`; + if (key === 'usd' || key === 'limit_usd') return `$${value.toFixed(6).replace(/\.?0+$/, '')}`; + if (key === 'credits' || key === 'reserved') return `${credits(value)} credits`; + if (key === 'markup') return `×${value}`; return value.toLocaleString(undefined, { maximumFractionDigits: 6 }); } - if (key === 'created_at' && typeof value === 'string') return new Date(value).toLocaleString(); + if (key === 'status' && typeof value === 'string') return STATUS_LABEL[value as SessionStatus] ?? value; + if (key.endsWith('_at') && typeof value === 'string') return new Date(value).toLocaleString(); if (typeof value === 'string') return value; return JSON.stringify(value); } /** The fields present on a row, labelled ones first. */ -function fieldsOf(e: LedgerEntry): string[] { - const present = Object.keys(e).filter((k) => e[k] !== null && e[k] !== undefined); +function fieldsOf(row: SessionRow): string[] { + const present = Object.keys(row).filter((k) => row[k] !== null && row[k] !== undefined); const known = Object.keys(FIELD_LABEL).filter((k) => present.includes(k)); return [...known, ...present.filter((k) => !(k in FIELD_LABEL))]; } -/** Every field any row has, as columns: the export is for reconciling, so it - * keeps full precision and leaves nothing out. */ -function toCsv(entries: LedgerEntry[]): string { - const columns: string[] = []; - for (const e of entries) for (const k of fieldsOf(e)) if (!columns.includes(k)) columns.push(k); - const quote = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); - const cell = (v: unknown) => - v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); - const rows = entries.map((e) => columns.map((k) => quote(cell(e[k]))).join(',')); - return [columns.join(','), ...rows].join('\n'); -} - -const has = (entries: LedgerEntry[], key: string) => - entries.some((e) => e[key] !== null && e[key] !== undefined); - const TH = 'font-medium py-2 px-4 first:pl-0 last:pr-0 whitespace-nowrap'; const TD = 'py-2.5 px-4 first:pl-0 last:pr-0'; -const usd = (micros: number) => `$${(micros / 1_000_000).toFixed(micros < 10_000 ? 4 : 2)}`; - -function Details({ entry }: { entry: LedgerEntry }) { +function Details({ row }: { row: SessionRow }) { return (
- {fieldsOf(entry).map((k) => ( + {fieldsOf(row).map((k) => (
{label(k)}
-
{display(k, entry[k])}
+
{display(k, row[k])}
))}
); } +/** What a session cost, or what it holds while its charge is still to come. */ +function CreditsCell({ row }: { row: SessionRow }) { + if (row.credits !== null) return <>{credits(row.credits)}; + return {row.reserved > 0 ? `${credits(row.reserved)} held` : '—'}; +} + +type Filter = { days: (typeof RANGES)[number]; empty: boolean }; + +/** True once `busy` has held for `ms`. A load that answers sooner never shows + * a spinner or dims the table, so a quick toggle doesn't flash the page. */ +function useSlow(busy: boolean, ms = 300): boolean { + const [slow, setSlow] = useState(false); + useEffect(() => { + if (!busy) { + setSlow(false); + return; + } + const timer = setTimeout(() => setSlow(true), ms); + return () => clearTimeout(timer); + }, [busy, ms]); + return slow; +} + export default function CloudHistorySection({ user }: { user: any }) { - const [days, setDays] = useState<(typeof RANGES)[number]>(30); - const [entries, setEntries] = useState(null); + const [filter, setFilter] = useState({ days: 30, empty: false }); + // The first page's summary and every page loaded since, for `loaded`. + const [data, setData] = useState<(SessionPage & { loaded: Filter }) | null>(null); const [error, setError] = useState(null); - const [shown, setShown] = useState(PAGE); + const [more, setMore] = useState(false); + const [exporting, setExporting] = useState(false); const [open, setOpen] = useState(null); - // The days the rows on screen are for. Differs from `days` while a new - // range is loading. - const [loadedDays, setLoadedDays] = useState(days); - const loading = !error && (entries === null || loadedDays !== days); + const loading = !error && (data === null || data.loaded.days !== filter.days || data.loaded.empty !== filter.empty); + const slow = useSlow(loading || exporting); useEffect(() => { if (!user) return; let cancelled = false; setError(null); - fetchLedger(days) - .then((data) => { + fetchSessions(filter) + .then((page) => { if (cancelled) return; - if (data) { - setEntries(data); - setLoadedDays(days); - setShown(PAGE); + if (page) { + setData({ ...page, loaded: filter }); + setOpen(null); } else { setError('Could not load your session history.'); } }) .catch((err) => { - console.error('Failed to load cloud ledger:', err); + console.error('Failed to load cloud sessions:', err); if (!cancelled) setError('Could not load your session history.'); }); return () => { cancelled = true; }; - }, [user, days]); - - const totals = useMemo( - () => - (entries ?? []).reduce( - (t, e) => ({ - cost: t.cost + (e.cost ?? 0), - usd: t.usd + (e.upstream_cost_micros ?? 0), - }), - { cost: 0, usd: 0 } - ), - [entries] - ); + }, [user, filter]); - const rows = entries ?? []; - const showCost = has(rows, 'cost'); - const showUsd = has(rows, 'upstream_cost_micros'); - const columns = 2 + Number(showCost) + Number(showUsd); + const showMore = async () => { + if (!data?.next || more) return; + setMore(true); + try { + const page = await fetchSessions({ ...data.loaded, before: data.next }); + if (page) { + setData((d) => d && { ...d, sessions: [...d.sessions, ...page.sessions], next: page.next }); + } else { + setError('Could not load more sessions.'); + } + } catch (err) { + console.error('Failed to load more cloud sessions:', err); + setError('Could not load more sessions.'); + } finally { + setMore(false); + } + }; - const exportCsv = () => { - if (!entries?.length) return; - const blob = new Blob([toCsv(entries)], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `smartloop-cloud-sessions-${loadedDays}d.csv`; - a.click(); - URL.revokeObjectURL(url); + const exportCsv = async () => { + if (!data || exporting) return; + setExporting(true); + try { + const blob = await fetchSessionsCsv(data.loaded); + if (!blob) { + setError('Could not export your session history.'); + return; + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `smartloop-cloud-sessions-${data.loaded.days}d.csv`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Failed to export cloud sessions:', err); + setError('Could not export your session history.'); + } finally { + setExporting(false); + } }; + const rows = data?.sessions ?? []; + const summary = data?.summary; + // The footer counts what the table can show: the summary includes the + // empty sessions even when they are hidden. + const total = summary ? summary.sessions - (data?.loaded.empty ? 0 : summary.empty) : rows.length; + const remaining = Math.max(0, total - rows.length); + const showUsd = rows.some((r) => r.usd !== undefined) || summary?.usd !== undefined; + const columns = 4 + Number(showUsd); + return (
@@ -185,9 +234,9 @@ export default function CloudHistorySection({ user }: { user: any }) { {RANGES.map((r) => ( - {loading && entries !== null && ( - + {slow && data !== null && } + {(filter.empty || (summary?.empty ?? 0) > 0) && ( + )} - {entries === null && !error ? ( + {data === null && !error ? ( Loading… - ) : entries && entries.length === 0 ? ( + ) : data && rows.length === 0 ? ( - No cloud sessions in the last {loadedDays} days. + + No cloud sessions in the last {data.loaded.days} days. } /> - ) : entries ? ( + ) : data ? ( - - + + {/* Fixed widths: with auto layout the columns resize to whatever + the range's numbers are, and shift on every range change. */} +
- - {showCost && } - {showUsd && } + + + + {showUsd && } - {entries.slice(0, shown).map((e) => { - const isOpen = open === e.id; + {rows.map((r) => { + const isOpen = open === r.id; return ( - + setOpen(isOpen ? null : e.id)} + onClick={() => setOpen(isOpen ? null : r.id)} className={`border-t border-border cursor-pointer hover:bg-surface-2 ${isOpen ? 'bg-surface-2' : ''}`} > - - {showCost && ( - - )} + + + {showUsd && ( )} @@ -265,7 +329,7 @@ export default function CloudHistorySection({ user }: { user: any }) { )} @@ -273,26 +337,34 @@ export default function CloudHistorySection({ user }: { user: any }) { ); })} - - - - {showCost && } - {showUsd && } - - + {summary && ( + + + + + {showUsd && ( + + )} + + + )}
- SettledCreditsUpstream + StartedStatusCreditsUpstream
@@ -249,15 +312,16 @@ export default function CloudHistorySection({ user }: { user: any }) { sx={{ fontSize: 16, transition: 'transform 150ms', transform: isOpen ? 'rotate(90deg)' : 'none' }} /> {when(e.created_at)} - {e.cost == null ? '—' : credits(e.cost)} - {when(r.started_at)} + {STATUS_LABEL[r.status] ?? r.status} + + + - {e.upstream_cost_micros == null ? '—' : usd(e.upstream_cost_micros)} + {r.usd == null ? '—' : usd(r.usd)}
-
+
- - {count(entries.length)} {entries.length === 1 ? 'session' : 'sessions'} - {credits(totals.cost)}{usd(totals.usd)}
+ + {count(total)} {total === 1 ? 'session' : 'sessions'} + {credits(summary.credits)} + {summary.usd == null ? '—' : usd(summary.usd)} +
- {shown < entries.length && ( - + {data.next && remaining > 0 && ( + + {more && } )}
diff --git a/src/app/cloud-models/shared.ts b/src/app/cloud-models/shared.ts index 2cf7137..5075008 100644 --- a/src/app/cloud-models/shared.ts +++ b/src/app/cloud-models/shared.ts @@ -3,9 +3,8 @@ * * Cloud model use is metered in sessions. Each session holds part of the * account's monthly allowance of credits while it is open; once it ends, what - * it used is charged and recorded as one row in the cloud ledger - * (GET /v1/chat/ledger). The ledger has no per-model or token detail: one - * charge per session. + * it used is charged to it (GET /v1/chat/sessions). There is no per-model or + * token detail: one charge per session. * * Users see credits: what is left of the month and what each session cost. * Some accounts don't get the month's credit counts, so for them they are null @@ -34,8 +33,6 @@ export interface CloudQuota { percent_available: number | null; /** Open sessions, each holding its budget until it is settled. */ in_flight: number; - /** Sessions settled this month. */ - requests: number; /** YYYY-MM. */ period: string; resets_at: string; @@ -51,24 +48,6 @@ export interface CloudStatus { quota: CloudQuota; } -/** One settled session: when it was charged and what it cost. */ -export interface LedgerEntry { - id: string; - /** When the session was settled, after it ended. */ - created_at: string; - project_id: string | null; - /** Credits the session cost. */ - cost: number | null; - period: string; - // Sent with a value only to accounts allowed to see them: shown when - // present, never assumed. - provider?: string | null; - /** In micro-USD. */ - upstream_cost_micros?: number | null; - /** Anything else the API adds to a row: listed in its details as is. */ - [field: string]: unknown; -} - export const PLAN_LABEL: Record = { free: 'Free', paid: 'Pro', @@ -105,5 +84,104 @@ async function getJson(path: string): Promise { export const fetchStatus = () => getJson('/chat/status'); -export const fetchLedger = async (days: number) => - (await getJson(`/chat/ledger?days=${days}`)) ?? null; +/** The browser's time zone, so days are the user's days. */ +export const browserTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + +/** One day of cloud use, in the browser's time zone. */ +export interface UsageDay { + /** YYYY-MM-DD. */ + date: string; + /** Sessions started that day, settled or not. */ + sessions: number; + /** Credits charged for them; a session adds its credits once settled. */ + credits: number; + /** What OpenRouter charged, in USD. Sent only to accounts allowed to see it. */ + usd?: number; +} + +/** GET /v1/chat/usage: every day of the range, oldest first, zeros included. */ +export interface CloudUsage { + tz: string; + totals: Omit; + days: UsageDay[]; +} + +export const fetchUsage = (days: number) => + getJson(`/chat/usage?${new URLSearchParams({ days: String(days), tz: browserTimeZone() })}`); + +/** Where a session is: its key still works, its key expired and the charge is + * pending, or it has been charged. */ +export type SessionStatus = 'active' | 'ended' | 'settled'; + +/** One cloud session, from GET /v1/chat/sessions. */ +export interface SessionRow { + id: string; + started_at: string; + /** When its key stops working. */ + expires_at: string; + settled_at: string | null; + status: SessionStatus; + /** Credits charged; null until settled. */ + credits: number | null; + /** Credits held while it is open. */ + reserved: number; + project_id: string | null; + // Sent only to accounts allowed to see them: shown when present. + /** What OpenRouter charged, in USD. */ + usd?: number | null; + limit_usd?: number; + markup?: number; + key_hash?: string; + [field: string]: unknown; +} + +/** The whole range, counting what the empty filter hides. */ +export interface SessionSummary { + sessions: number; + /** Settled sessions that charged nothing. */ + empty: number; + credits: number; + usd?: number; +} + +export interface SessionPage { + tz: string; + /** On the first page only. */ + summary?: SessionSummary; + sessions: SessionRow[]; + /** Pass as `before` for the next page; null on the last. */ + next: string | null; +} + +/** Sessions per page of history. */ +export const SESSION_PAGE = 50; + +export interface SessionQuery { + days: number; + /** Include settled sessions that charged nothing. */ + empty: boolean; + before?: string | null; +} + +function sessionsPath({ days, empty, before }: SessionQuery, extra: Record = {}) { + const params = new URLSearchParams({ + days: String(days), + tz: browserTimeZone(), + empty: String(empty), + limit: String(SESSION_PAGE), + ...extra, + }); + if (before) params.set('before', before); + return `/chat/sessions?${params}`; +} + +export const fetchSessions = (query: SessionQuery) => getJson(sessionsPath(query)); + +/** Every session in the range as a CSV file, as the server writes it. */ +export async function fetchSessionsCsv(query: SessionQuery): Promise { + const response = await userService.makeAuthenticatedRequest( + `${API_ENDPOINT}${sessionsPath({ ...query, before: null }, { format: 'csv' })}`, + { method: 'GET' }, + ); + return response && response.ok ? await response.blob() : null; +} diff --git a/src/app/cloud-models/usage-section.tsx b/src/app/cloud-models/usage-section.tsx index c29552f..1fd1ab8 100644 --- a/src/app/cloud-models/usage-section.tsx +++ b/src/app/cloud-models/usage-section.tsx @@ -20,9 +20,8 @@ */ import { type ReactNode, useEffect, useState } from 'react'; +import Link from 'next/link'; import { Box, CircularProgress } from '@mui/material'; -import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; -import LockOpenIcon from '@mui/icons-material/LockOpen'; import { userService } from '@/app/services/user.service'; import { API_ENDPOINT } from '@/app/constants'; import { Row, Section } from '@/app/components/rows'; @@ -30,11 +29,12 @@ import ActivityChart from '@/app/components/activity-chart'; import type { ModelEntry } from '@/app/models/catalog'; import { type CloudStatus, + type CloudUsage, PLAN_LABEL, count, credits, - fetchLedger, fetchStatus, + fetchUsage, resetDate, wholeCredits, } from './shared'; @@ -91,21 +91,6 @@ function Balance({ headline, detail, badge, danger }: { ); } -/** The credits row's shape before status arrives: no numbers, an empty bar. */ -function CreditsPlaceholder() { - return ( - - - - — credits left - - - - - - ); -} - function CreditsRow({ status }: { status: CloudStatus }) { const { quota, reason } = status; @@ -113,7 +98,7 @@ function CreditsRow({ status }: { status: CloudStatus }) { return ( } /> @@ -157,7 +142,7 @@ function CreditsRow({ status }: { status: CloudStatus }) { 0 ? 2 : 0)}%` }} + style={{ width: `${Math.max(used, used > 0 ? 2 : 0)}%` }} /> {held > 0 && ( @@ -217,7 +202,7 @@ const paramsLabel = (m: ModelEntry) => ? `${m.param_size_b}B${m.active_param_size_b ? ` (${m.active_param_size_b}B active)` : ''}` : null; -function ModelList({ models, locked }: { models: ModelEntry[]; locked: boolean }) { +function ModelList({ models }: { models: ModelEntry[] }) { if (models.length === 0) { return

No cloud models are available yet.

; } @@ -249,15 +234,6 @@ function ModelList({ models, locked }: { models: ModelEntry[]; locked: boolean }
)}
- {m.is_accessible && !locked ? ( - - - - ) : ( - - - - )}
))} @@ -279,8 +255,81 @@ const fetchModels = async (): Promise => { return Array.isArray(catalog) ? catalog.filter((m: ModelEntry) => m.type === 'remote') : []; }; -/** Run one request into its own state, so each row fills in when its own data - * arrives rather than waiting for the slowest. */ +const RANGES = [7, 30, 90] as const; + +type Metric = 'credits' | 'sessions' | 'usd'; + +const METRIC_LABEL: Record = { + credits: 'Credits', + sessions: 'Sessions', + usd: 'Upstream USD', +}; + +const METRIC_FORMAT: Record string> = { + credits, + sessions: count, + usd: (v) => `$${v.toFixed(v > 0 && v < 0.01 ? 4 : 2)}`, +}; + +/** A pill group, as the history page's range picker. */ +function Toggle({ options, value, label, onChange }: { + options: readonly T[]; + value: T; + label: (option: T) => string; + onChange: (option: T) => void; +}) { + return ( + + {options.map((o) => ( + + ))} + + ); +} + +/** + * Cloud use per day. GET /v1/chat/usage sends the last 90 days already + * counted per day in the browser's time zone, so switching the range or the + * measure only re-slices what is on hand: no request. + */ +function UsageChart({ usage }: { usage: CloudUsage | null | undefined }) { + const [days, setDays] = useState<(typeof RANGES)[number]>(30); + const [metric, setMetric] = useState('credits'); + const metrics: Metric[] = usage?.totals.usd !== undefined ? ['credits', 'sessions', 'usd'] : ['credits', 'sessions']; + const shown = (usage?.days ?? []).slice(-days); + const format = METRIC_FORMAT[metric]; + // A failed load shows in the section's error row. + if (usage === null) return null; + + return ( + + + `${d} days`} onChange={setDays} /> + METRIC_LABEL[m]} onChange={setMetric} /> + + d.date), + series: [{ key: metric, label: METRIC_LABEL[metric], values: shown.map((d) => d[metric] ?? 0) }], + }} + format={format} + emptyText={`No cloud sessions in the last ${days} days.`} + loading={usage === undefined} + /> + + ); +} + +/** Run one request into its own state; the section shows its rows once every + * request has answered. */ function useLoad(user: any, load: () => Promise): Loaded { const [value, setValue] = useState>(undefined); useEffect(() => { @@ -300,14 +349,16 @@ function useLoad(user: any, load: () => Promise): Loaded { export default function CloudUsageSection({ user }: { user: any }) { const status = useLoad(user, fetchStatus); - const ledger = useLoad(user, () => fetchLedger(14)); + const usage = useLoad(user, () => fetchUsage(90)); const models = useLoad(user, fetchModels); - const failed = status === null || ledger === null || models === null; + const failed = status === null || usage === null || models === null; + // The rows render together once all three have answered, so the page + // appears in one step instead of filling in row by row as each lands. + const ready = status !== undefined && usage !== undefined && models !== undefined; - // Free accounts get no credits: cloud models are a Pro feature. Until status - // arrives the plan comes from the user, so the page does not change shape - // when it lands. + // Free accounts get no credits: cloud models are a Pro feature. If status + // could not be loaded, the plan comes from the user. const needsPro = status ? status.reason === 'plan_required' : !!user && !user.admin && !(user.is_paid || user.is_premium); @@ -317,55 +368,57 @@ export default function CloudUsageSection({ user }: { user: any }) { title="Cloud Models" description="Larger models that run in Smartloop's cloud instead of on your device — no setup and no API key of your own. Pro includes credits every month, spent on what you use; unused credits don't carry over." > - {needsPro && ( + {ready && needsPro && ( - Upgrade to Pro - + + + )} + + {!ready && ( + + + + Loading… + )} - {!needsPro && (status ? : status === undefined && )} + {ready && !needsPro && status && } - {!needsPro && ( + {ready && !needsPro && ( - Last 14 days. Every session and what it cost is itemized in{' '} - + Sessions count on the day they start; their credits once they end and are charged. Every session + is itemized in{' '} + {/* In-app navigation, as the sidebar does: a plain link reloads the + whole app and re-checks the session. */} + Session History - + . } stacked > - ({ timestamp: e.created_at, action: 'session' }))} - labelFor={() => 'Sessions'} - emptyText="No cloud sessions in the last 14 days." - loading={ledger === undefined} - /> + )} - - {models === undefined ? ( - - - Loading… - - ) : ( - - )} - + {ready && ( + + + + )} {failed && ( diff --git a/src/app/components/activity-chart.tsx b/src/app/components/activity-chart.tsx index 8922753..3fe2f48 100644 --- a/src/app/components/activity-chart.tsx +++ b/src/app/components/activity-chart.tsx @@ -26,6 +26,13 @@ export interface ActivityEntry { action: string; } +/** Values already counted per day by the server, oldest first. */ +export interface BinnedActivity { + /** YYYY-MM-DD, one per day, oldest first. */ + dates: string[]; + series: { key: string; label: string; values: number[] }[]; +} + // Fixed slot order — index is the category's identity, not its rank. const SERIES_LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300']; const SERIES_DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300']; @@ -48,8 +55,19 @@ const BAR_GAP = 2; const defaultLabel = (action: string) => action === 'websearch' ? 'Web search' : action.replace(/[-_]/g, ' '); +/** A YYYY-MM-DD day as a local date, so it labels as that day. */ +const localDay = (date: string) => { + const [y, m, d] = date.split('-').map(Number); + return new Date(y, m - 1, d); +}; + export default function ActivityChart({ - entries, + entries = [], + // Per-day values the server already counted; charted as given instead of + // binning `entries` over the last DAYS days. + binned, + // How a value reads on the axis and in the tooltip (counts by default). + format = (v: number) => v.toLocaleString(), // Callers whose "action" isn't an access-log category name it themselves // (the Cloud Models page charts sessions). labelFor = defaultLabel, @@ -58,7 +76,9 @@ export default function ActivityChart({ // place while its entries are still on the way. loading = false, }: { - entries: ActivityEntry[]; + entries?: ActivityEntry[]; + binned?: BinnedActivity; + format?: (value: number) => string; labelFor?: (action: string) => string; emptyText?: string; loading?: boolean; @@ -67,14 +87,19 @@ export default function ActivityChart({ const palette = theme === 'dark' ? SERIES_DARK : SERIES_LIGHT; const [hover, setHover] = useState(null); - const dayKeys: string[] = []; - const dayLabels: string[] = []; - for (let i = DAYS - 1; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - dayKeys.push(d.toDateString()); - dayLabels.push(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + const days = binned ? binned.dates.map(localDay) : []; + if (!binned) { + for (let i = DAYS - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + days.push(d); + } } + const dayKeys = days.map((d) => d.toDateString()); + const dayLabels = days.map((d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + const dayCount = Math.max(1, days.length); + // Label about six days across the axis, however many there are. + const labelEvery = Math.max(1, Math.ceil(dayCount / 6)); const actions = Array.from(new Set(entries.map((e) => e.action))); const ordered = [ @@ -82,7 +107,17 @@ export default function ActivityChart({ ...actions.filter((a) => !KNOWN_ACTIONS.includes(a)).sort(), ]; - const series = ordered.map((action, i) => { + const series = binned + ? binned.series + .map((s, i) => ({ + action: s.key, + label: s.label, + color: palette[i % palette.length], + values: s.values, + total: s.values.reduce((n, v) => n + v, 0), + })) + .filter((s) => s.total > 0) + : ordered.map((action, i) => { const counts = new Map(); for (const e of entries) { if (e.action !== action) continue; @@ -100,13 +135,14 @@ export default function ActivityChart({ }; }); - const peak = Math.max(1, ...series.flatMap((s) => s.values)); + // The tallest day, or 1 when nothing happened (a fractional peak is fine: + // binned values can be credits). + const peak = Math.max(0, ...series.flatMap((s) => s.values)) || 1; const plotW = W - PAD_L - PAD_R; const plotH = H - PAD_T - PAD_B; - // DAYS is fixed at >1, so the divisor is always safe. // One band per day, bars centred in their band — so nothing overhangs the // plot the way a point scale would at the first and last day. - const band = plotW / DAYS; + const band = plotW / dayCount; const x = (i: number) => PAD_L + band * (i + 0.5); // Thin marks: the group occupies part of its band, never all of it. @@ -129,7 +165,7 @@ export default function ActivityChart({ {/* Recessive frame: a baseline and a peak rule, no full grid. */} - {peak} + {format(peak)} 0 {/* Grouped bars: one per series per day, 4px rounded ends anchored @@ -167,7 +203,7 @@ export default function ActivityChart({ ))} {dayLabels.map((label, i) => - i % 3 === 0 || i === DAYS - 1 ? ( + (dayCount - 1 - i) % labelEvery === 0 ? ( {label} @@ -193,7 +229,7 @@ export default function ActivityChart({ - {s.label}: {s.values[hover]} + {s.label}: {format(s.values[hover])} ))} @@ -209,7 +245,7 @@ export default function ActivityChart({ style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: s.color, display: 'inline-block' }} /> {s.label} - ({s.total}) + ({format(s.total)}) ))} diff --git a/src/app/models/catalog.ts b/src/app/models/catalog.ts index b3fce62..0fe70d7 100644 --- a/src/app/models/catalog.ts +++ b/src/app/models/catalog.ts @@ -17,8 +17,8 @@ export interface ModelEntry { /** free_login: free, but only to a signed-in account — which everyone in * this app is, so it reads as free here. */ access_level: 'free' | 'free_login' | 'pro' | 'internal'; - /** local runs on the device; remote runs in Smartloop's cloud through - * /v1/chat/completions and is metered (see the Cloud Models pages). */ + /** local runs on the device; remote runs in Smartloop's cloud through a + * short-lived session key and is metered (see the Cloud Models pages). */ type?: 'local' | 'remote'; capabilities?: string[]; context_length?: number | null; diff --git a/src/app/settings/sections/credits.tsx b/src/app/settings/sections/credits.tsx index 70f690a..3513b10 100644 --- a/src/app/settings/sections/credits.tsx +++ b/src/app/settings/sections/credits.tsx @@ -3,9 +3,8 @@ /** * Credits. * - * One monthly pool pays for cloud models and web searches: a model call at - * the model's rate for the tokens it read and wrote, a search at a fixed - * price. Cloud models run on Smartloop's own provider accounts and searches + * One monthly pool pays for cloud models and web searches: a cloud session + * at what it spent upstream once it ends, a search at a fixed price. Cloud models run on Smartloop's own provider accounts and searches * on its credentials, which is why both are metered; a model that runs on * your own machine costs nothing. * @@ -33,11 +32,8 @@ interface Quota { remaining: number | null; /** 0–100 of the allowance spent; null when unmetered. */ percent_used: number | null; - /** Calls in flight. */ + /** Open cloud sessions, each holding credits until it is charged. */ in_flight: number; - prompt_tokens: number; - completion_tokens: number; - requests: number; period: string; resets_at: string; plan: 'free' | 'paid' | 'plus' | 'admin'; @@ -113,7 +109,6 @@ export default function CreditsSection({ user }: { user: any }) { const spent = status?.reason === 'quota_exceeded' || status?.reason === 'quota_low'; // Credits show only for accounts the API gives the amounts to (admins). const amounts = quota != null && quota.limit != null && quota.used != null; - const tokens = quota ? quota.prompt_tokens + quota.completion_tokens : 0; // Amber before the wall, red at it — the point is to be warned in time to // upgrade, not to find out by the assistant staying on local models. const barColor = spent ? 'bg-danger' : pct >= 80 ? 'bg-warn' : 'bg-accent-strong'; @@ -171,7 +166,9 @@ export default function CreditsSection({ user }: { user: any }) { - {count(quota.requests)} cloud calls so far — unlimited + {quota.used != null + ? `${count(Math.round(quota.used))} credits used so far — unlimited` + : 'Unlimited'} ) : ( @@ -191,14 +188,15 @@ export default function CreditsSection({ user }: { user: any }) { )} - {amounts ? `${count(quota.remaining ?? 0)} left · ` : ''} - {count(quota.requests)} calls · {count(tokens)} tokens + {amounts ? `${count(quota.remaining ?? 0)} left` : `${100 - pct}% left`} + {quota.in_flight > 0 && + ` · ${count(quota.in_flight)} ${quota.in_flight === 1 ? 'session' : 'sessions'} open`} 0 ? 2 : 0)}%` }} + style={{ width: `${Math.max(pct, (quota.percent_used ?? 0) > 0 ? 2 : 0)}%` }} />