diff --git a/.changeset/tangy-boats-sing.md b/.changeset/tangy-boats-sing.md new file mode 100644 index 00000000000..f5e68165340 --- /dev/null +++ b/.changeset/tangy-boats-sing.md @@ -0,0 +1,8 @@ +--- +"@gradio/client": minor +"@gradio/core": minor +"@self/spa": minor +"gradio": minor +--- + +feat:Add browser-local run history and loading diff --git a/client/js/src/client.ts b/client/js/src/client.ts index 75ea0f46eac..a43e04bf856 100644 --- a/client/js/src/client.ts +++ b/client/js/src/client.ts @@ -34,6 +34,7 @@ import { import { check_and_wake_space, check_space_status } from "./helpers/spaces"; import { initialize_zerogpu_handshake } from "./helpers/zerogpu"; import { open_stream, readable_stream, close_stream } from "./utils/stream"; +import { clear_run_history } from "./utils/run_history"; import { API_INFO_ERROR_MSG, APP_ID_URL, @@ -414,6 +415,15 @@ export class Client { this.config = _config; this.api_prefix = _config.api_prefix || ""; + // Opting out also purges, so an app that turns the feature off does not + // leave behind what it stored while it was on. + if (_config.run_history === false) { + clear_run_history({ + app_id: _config.app_id, + username: _config.username + }); + } + if (this.config.auth_required) { return this.prepare_return_obj(); } diff --git a/client/js/src/index.ts b/client/js/src/index.ts index 6ea916ad113..0c3185edf6e 100644 --- a/client/js/src/index.ts +++ b/client/js/src/index.ts @@ -5,6 +5,18 @@ export { submit } from "./utils/submit"; export { upload_files } from "./utils/upload_files"; export { FileData, upload, prepare_files } from "./upload"; export { handle_file } from "./helpers/data"; +export { + clear_run_history, + consume_run_history_replay, + delete_run_history, + on_run_history_change, + read_run_history, + run_history_url, + stage_run_history_replay, + type RunHistoryScope, + type StoredRunComponent, + type StoredRun +} from "./utils/run_history"; export type { SpaceStatus, diff --git a/client/js/src/test/run_history.test.ts b/client/js/src/test/run_history.test.ts new file mode 100644 index 00000000000..c2a18e73505 --- /dev/null +++ b/client/js/src/test/run_history.test.ts @@ -0,0 +1,345 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + clear_run_history, + consume_run_history_replay, + delete_run_history, + on_run_history_change, + read_run_history, + run_history_url, + stage_run_history_replay, + start_run_history, + update_run_history, + update_run_inputs +} from "../utils/run_history"; + +const app_id = "app-under-test"; +const other_app = "some-other-app"; +const scope = { app_id }; +const other_scope = { app_id: other_app }; +const replacement_apps = Array.from( + { length: 8 }, + (_, index) => `replacement-app-${index}` +); +const in_browser = typeof window !== "undefined"; + +afterEach(() => { + clear_run_history(scope); + clear_run_history(other_scope); + for (const replacement_app of replacement_apps) { + clear_run_history({ app_id: replacement_app }); + } + vi.useRealTimers(); +}); + +describe.skipIf(!in_browser)("run history", () => { + test("stores runs per app id with the page and inputs", () => { + const id = start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"], + input_components: [ + { + type: "textbox", + component_class_id: "textbox-id", + props: { label: "Prompt" } + } + ] + }); + + const runs = read_run_history(scope); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + id, + endpoint: "/predict", + api_name: "/predict", + inputs: ["hello"], + status: "running", + input_components: [ + { + type: "textbox", + component_class_id: "textbox-id", + props: { label: "Prompt" } + } + ], + page: `${window.location.pathname}${window.location.search}` + }); + expect(read_run_history(other_scope)).toEqual([]); + }); + + test("replaces inputs with their upload-processed values", () => { + const id = start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: [new File(["hello"], "hello.txt")] + }); + + update_run_inputs(scope, id, [ + { path: "/tmp/hello.txt", url: "/gradio_api/file=/tmp/hello.txt" } + ]); + + expect(read_run_history(scope)[0].inputs).toEqual([ + { path: "/tmp/hello.txt", url: "/gradio_api/file=/tmp/hello.txt" } + ]); + }); + + test("updates outputs and completion state", () => { + const id = start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"] + }); + + update_run_history(scope, id, { + type: "data", + endpoint: "/predict", + fn_index: 0, + data: ["hello hello"] + }); + update_run_history(scope, id, { + type: "status", + endpoint: "/predict", + fn_index: 0, + queue: false, + stage: "complete", + time: new Date("2025-05-16T21:45:00Z") + }); + + expect(read_run_history(scope)[0]).toMatchObject({ + outputs: ["hello hello"], + status: "completed", + completed_at: "2025-05-16T21:45:00.000Z" + }); + }); + + test("records the runtime reported by the server", () => { + const id = start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"] + }); + + update_run_history(scope, id, { + type: "status", + endpoint: "/predict", + fn_index: 0, + queue: true, + stage: "complete", + cache_duration: 1.25, + time: new Date("2025-05-16T21:45:00Z") + }); + + expect(read_run_history(scope)[0]).toMatchObject({ + duration_ms: 1250, + completed_at: "2025-05-16T21:45:00.000Z" + }); + }); + + test("records failed runs and their error message", () => { + const id = start_run_history({ + app_id, + endpoint: 3, + api_name: "Function 3", + fn_index: 3, + inputs: [{ circular: null }] + }); + + update_run_history(scope, id, { + type: "status", + endpoint: "/predict", + fn_index: 3, + queue: true, + stage: "error", + message: "generation failed" + }); + + expect(read_run_history(scope)[0]).toMatchObject({ + status: "failed", + error: "generation failed" + }); + expect(read_run_history(scope)[0].duration_ms).toBeGreaterThanOrEqual(0); + }); + + test("keeps only the newest 100 runs", () => { + for (let index = 0; index < 105; index++) { + start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: [index] + }); + } + + const runs = read_run_history(scope); + expect(runs).toHaveLength(100); + expect(runs[0].inputs).toEqual([104]); + expect(runs.at(-1)?.inputs).toEqual([5]); + }); + + test("deletes an individual run", () => { + const first = start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["first"] + }); + start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["second"] + }); + + delete_run_history(scope, first!); + + expect(read_run_history(scope)).toHaveLength(1); + expect(read_run_history(scope)[0].inputs).toEqual(["second"]); + }); + + test("notifies this tab when runs are added, deleted or cleared", () => { + let notified = 0; + const unsubscribe = on_run_history_change(() => notified++); + + const id = start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"] + }); + expect(notified).toBe(1); + + // Progress updates are not structural, so they must not notify. + update_run_history(scope, id, { + type: "status", + endpoint: "/predict", + fn_index: 0, + queue: true, + stage: "complete" + }); + expect(notified).toBe(1); + + delete_run_history(scope, id!); + expect(notified).toBe(2); + + clear_run_history(scope); + expect(notified).toBe(3); + + unsubscribe(); + start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["bye"] + }); + expect(notified).toBe(3); + }); + + test("keeps a separate history per app id", () => { + start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["mine"] + }); + start_run_history({ + app_id: other_app, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["theirs"] + }); + + expect(read_run_history(scope)[0].inputs).toEqual(["mine"]); + expect(read_run_history(other_scope)[0].inputs).toEqual(["theirs"]); + }); + + test("keeps a separate history per user of the same app", () => { + const ada = { app_id, username: "ada" }; + const grace = { app_id, username: "grace" }; + + start_run_history({ + ...ada, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["ada's secret"] + }); + + expect(read_run_history(grace)).toEqual([]); + expect(read_run_history(scope)).toEqual([]); + expect(read_run_history(ada)[0].inputs).toEqual(["ada's secret"]); + + clear_run_history(ada); + }); + + test("removes staged replays when pruning an old app", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + start_run_history({ + app_id: other_app, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["old"] + }); + stage_run_history_replay(other_scope, read_run_history(other_scope)[0]); + + for (const [index, replacement_app] of replacement_apps.entries()) { + vi.setSystemTime(new Date(Date.UTC(2025, 0, index + 2))); + start_run_history({ + app_id: replacement_app, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: [index] + }); + } + + expect(read_run_history(other_scope)).toEqual([]); + expect(consume_run_history_replay(other_scope)).toBeNull(); + }); +}); + +describe("run history url", () => { + test("joins the app root and the api prefix", () => { + expect(run_history_url("http://localhost:7860")).toBe( + "http://localhost:7860/gradio_api/runs" + ); + expect(run_history_url("http://localhost:7860/my-app/")).toBe( + "http://localhost:7860/my-app/gradio_api/runs" + ); + expect(run_history_url("http://localhost:7860", "/api")).toBe( + "http://localhost:7860/api/runs" + ); + }); +}); + +describe.skipIf(in_browser)("run history outside the browser", () => { + test("is a no-op without storage", () => { + expect( + start_run_history({ + app_id, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"] + }) + ).toBeNull(); + expect(read_run_history(scope)).toEqual([]); + expect(consume_run_history_replay(scope)).toBeNull(); + }); +}); diff --git a/client/js/src/types.ts b/client/js/src/types.ts index 36240d81c78..1836dcc1e47 100644 --- a/client/js/src/types.ts +++ b/client/js/src/types.ts @@ -211,6 +211,12 @@ export interface Config { pwa?: boolean; i18n_translations?: Record> | null; mcp_server?: boolean; + /** + * Whether the app permits its runs being saved in the browser. Set from + * `run_history` on `launch()`; absent on apps served by older versions of + * Gradio, which is treated as permitted. + */ + run_history?: boolean; } // todo: DRY up types @@ -347,6 +353,14 @@ export interface ClientOptions { * endpoints that declare they need it. */ oauth_token?: string; + /** + * Whether to save each call's inputs and outputs in this browser's local + * storage, so they can be reviewed on the app's run history page. Defaults + * to true, and only ever applies in a browser: in Node there is no storage + * to write to and nothing is recorded. The app can turn it off for everyone + * with `run_history=False` on `launch()`, which takes precedence over this. + */ + record_history?: boolean; } export interface FileData { diff --git a/client/js/src/utils/run_history.ts b/client/js/src/utils/run_history.ts new file mode 100644 index 00000000000..0b09f4b713d --- /dev/null +++ b/client/js/src/utils/run_history.ts @@ -0,0 +1,520 @@ +import type { GradioEvent, StatusMessage } from "../types"; + +// Keyed on the app id, so two different apps served on the same port never +// share a history. The id is regenerated whenever an app starts, so a restart +// begins a fresh history and leaves the previous one orphaned; `prune_apps` +// keeps those from accumulating. +// +// The key also carries the logged-in user, because local storage outlives a +// session: without it, whoever logs into an authenticated app next on this +// browser would open the previous user's runs. +const KEY_ROOT = "gradio:run-history:"; +const STORAGE_PREFIX = `${KEY_ROOT}v2:`; +const REPLAY_PREFIX = `${KEY_ROOT}replay:v2:`; +const MAX_RUNS = 100; +const MAX_APPS = 8; + +export type AppId = string | number | null | undefined; + +/** + * Which history to read or write. `Config` satisfies this shape, so callers + * that hold one can pass it straight through. + */ +export interface RunHistoryScope { + app_id?: AppId; + /** The authenticated user, when the app uses `auth`. */ + username?: string | null; +} + +/** + * Run history is a side effect of submitting, never the point of it, so no + * failure in here may propagate into the caller and break the app. Every + * exported function routes through this. + */ +function safely(operation: () => T, fallback: T): T { + try { + return operation(); + } catch (error) { + console.warn("Could not update the run history.", error); + return fallback; + } +} + +export type RunStatus = "running" | "completed" | "failed"; + +export interface StoredRunComponent { + type: string; + component_class_id: string; + props: Record; +} + +export interface StoredRun { + id: string; + endpoint: string | number; + api_name: string; + fn_index: number; + page: string; + inputs: unknown; + outputs: unknown | null; + input_components?: (StoredRunComponent | null)[]; + output_components?: (StoredRunComponent | null)[]; + status: RunStatus; + error?: string; + started_at: string; + /** When the server started running the function, if it reported queueing. */ + process_started_at?: string; + completed_at?: string; + /** How long the function itself took, server-reported where available. */ + duration_ms?: number; + /** How long the run waited in the queue before the function started. */ + queued_ms?: number; + /** Whether the run produced its output in chunks, i.e. came from a generator. */ + streamed?: boolean; +} + +interface StartRunOptions extends RunHistoryScope { + endpoint: string | number; + api_name: string; + fn_index: number; + inputs: unknown; + input_components?: (StoredRunComponent | null)[]; + output_components?: (StoredRunComponent | null)[]; +} + +/** + * The URL of the run history page for an app. `root` never carries a trailing + * slash, so it cannot simply be concatenated with the path. + */ +export function run_history_url( + root: string, + api_prefix = "/gradio_api" +): string { + return `${root.replace(/\/+$/, "")}${api_prefix}/runs`; +} + +function storage_key(scope: RunHistoryScope | null | undefined): string | null { + if (typeof window === "undefined") return null; + const app_id = scope?.app_id; + if (app_id === null || app_id === undefined || app_id === "") return null; + + try { + if (!window.localStorage) return null; + // Encoded so that a username containing the separator cannot be made to + // collide with another user's key. + const user = scope?.username + ? `:user:${encodeURIComponent(scope.username)}` + : ""; + return `${STORAGE_PREFIX}${app_id}${user}`; + } catch { + return null; + } +} + +function replay_key(scope: RunHistoryScope | null | undefined): string | null { + const key = storage_key(scope); + return key ? key.replace(STORAGE_PREFIX, REPLAY_PREFIX) : null; +} + +/** When a run was most recently saved under a key, for deciding what to drop. */ +function last_saved_at(key: string): number { + try { + const runs = JSON.parse(window.localStorage.getItem(key) || "[]"); + // Runs are stored newest first. + return Array.isArray(runs) && runs.length + ? Date.parse(runs[0]?.started_at) || 0 + : 0; + } catch { + return 0; + } +} + +/** + * Drops the histories of long-gone app instances. Every restart mints a new app + * id, so without this the browser would keep every history an app ever had and + * eventually run out of room for the current one. + */ +function prune_apps(current_key: string): void { + const keys = Object.keys(window.localStorage).filter((key) => + key.startsWith(KEY_ROOT) + ); + const stale = [ + // Keys written by an older layout can never be read again. + ...keys.filter( + (key) => !key.startsWith(STORAGE_PREFIX) && !key.startsWith(REPLAY_PREFIX) + ), + ...keys + .filter((key) => key.startsWith(STORAGE_PREFIX) && key !== current_key) + .sort((a, b) => last_saved_at(b) - last_saved_at(a)) + .slice(MAX_APPS - 1) + ]; + for (const key of stale) { + try { + window.localStorage.removeItem(key); + window.sessionStorage.removeItem( + key.replace(STORAGE_PREFIX, REPLAY_PREFIX) + ); + } catch { + // Nothing to do if the browser will not let us clean up. + } + } +} + +function make_id(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function clone_for_storage(value: unknown): unknown { + const seen = new WeakSet(); + try { + return JSON.parse( + JSON.stringify(value, (_key, item) => { + if (typeof item === "bigint") return item.toString(); + if (typeof item === "object" && item !== null) { + if (seen.has(item)) return "[Circular]"; + seen.add(item); + } + return item; + }) + ); + } catch { + return "[Unserializable value]"; + } +} + +function read_run_history_impl( + scope: RunHistoryScope | null | undefined +): StoredRun[] { + const key = storage_key(scope); + if (!key) return []; + + try { + const value = JSON.parse(window.localStorage.getItem(key) || "[]"); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +function write_run_history( + scope: RunHistoryScope | null | undefined, + runs: StoredRun[] +): void { + const key = storage_key(scope); + if (!key) return; + + let next = runs.slice(0, MAX_RUNS); + let pruned = false; + while (next.length > 0) { + try { + window.localStorage.setItem(key, JSON.stringify(next)); + return; + } catch { + // Reclaim the space held by app instances that are long gone before + // giving up on the oldest runs of the current one. + if (!pruned) { + pruned = true; + prune_apps(key); + continue; + } + next = next.slice(0, -1); + } + } + try { + window.localStorage.removeItem(key); + } catch { + // Run history must never interfere with an app submission. + } +} + +function clear_run_history_impl( + scope: RunHistoryScope | null | undefined +): void { + const key = storage_key(scope); + if (!key) return; + try { + window.localStorage.removeItem(key); + } catch { + // Storage may be disabled by the browser. + } + notify_run_history_change(); +} + +function delete_run_history_impl( + scope: RunHistoryScope | null | undefined, + id: string +): void { + write_run_history( + scope, + read_run_history_impl(scope).filter((run) => run.id !== id) + ); + notify_run_history_change(); +} + +const CHANGE_EVENT = "gradio:run-history-change"; + +function notify_run_history_change(): void { + if (typeof window === "undefined") return; + try { + window.dispatchEvent(new Event(CHANGE_EVENT)); + } catch { + // Nothing depends on the notification arriving. + } +} + +/** + * Subscribes to runs being added, deleted or cleared. `storage` covers other + * tabs; the custom event covers this one, which `storage` never fires for. + * Only counts change, not per-run progress, so listeners stay cheap. + * + * @returns a function that unsubscribes. + */ +function on_run_history_change_impl(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, listener); + window.addEventListener("storage", listener); + return () => { + window.removeEventListener(CHANGE_EVENT, listener); + window.removeEventListener("storage", listener); + }; +} + +function stage_run_history_replay_impl( + scope: RunHistoryScope | null | undefined, + run: StoredRun +): void { + const key = replay_key(scope); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(run)); + } catch { + // Session storage may be disabled by the browser. + } +} + +function consume_run_history_replay_impl( + scope: RunHistoryScope | null | undefined +): StoredRun | null { + const key = replay_key(scope); + if (!key) return null; + try { + const value = window.sessionStorage.getItem(key); + window.sessionStorage.removeItem(key); + return value ? (JSON.parse(value) as StoredRun) : null; + } catch { + return null; + } +} + +function start_run_history_impl(options: StartRunOptions): string | null { + const key = storage_key(options); + if (!key) return null; + + // A new app id means a new key, so clear out the ones left behind before + // adding to this one. + prune_apps(key); + + const run: StoredRun = { + id: make_id(), + endpoint: options.endpoint, + api_name: options.api_name, + fn_index: options.fn_index, + page: `${window.location.pathname}${window.location.search}`, + inputs: clone_for_storage(options.inputs), + outputs: null, + ...(options.input_components + ? { + input_components: clone_for_storage( + options.input_components + ) as (StoredRunComponent | null)[] + } + : {}), + ...(options.output_components + ? { + output_components: clone_for_storage( + options.output_components + ) as (StoredRunComponent | null)[] + } + : {}), + status: "running", + started_at: new Date().toISOString() + }; + write_run_history(options, [run, ...read_run_history_impl(options)]); + notify_run_history_change(); + return run.id; +} + +function update_run_inputs_impl( + scope: RunHistoryScope | null | undefined, + id: string | null, + inputs: unknown +): void { + if (!id) return; + + const runs = read_run_history_impl(scope); + const run = runs.find((item) => item.id === id); + if (!run) return; + run.inputs = clone_for_storage(inputs); + write_run_history(scope, runs); +} + +function update_run_history_impl( + scope: RunHistoryScope | null | undefined, + id: string | null, + event: GradioEvent +): void { + if (!id) return; + + const runs = read_run_history_impl(scope); + const run = runs.find((item) => item.id === id); + if (!run) return; + + let finished = false; + if (event.type === "data") { + run.outputs = clone_for_storage(event.data); + deferred_outputs.set(id, run.outputs); + } else if ( + event.type === "status" && + event.original_msg === "process_starts" + ) { + mark_process_start(run, event.time); + } else if ( + event.type === "status" && + (event.stage === "generating" || event.stage === "streaming") + ) { + run.streamed = true; + } else if (event.type === "status" && event.stage === "complete") { + run.status = "completed"; + mark_complete(run, event); + finished = true; + } else if (event.type === "status" && event.stage === "error") { + run.status = "failed"; + run.error = + typeof event.message === "string" + ? event.message + : JSON.stringify(event.message || "Unknown error"); + mark_complete(run, event); + finished = true; + } else { + // Logs, renders and progress updates change nothing worth saving, and + // writing serialises every run we hold. + return; + } + + if (finished) { + // Every call re-reads from storage, so a chunk that was held back above is + // missing from `run`. Put the last one back before this final write. + if (deferred_outputs.has(id)) run.outputs = deferred_outputs.get(id); + deferred_outputs.delete(id); + last_write.delete(id); + } else if (run.streamed) { + // A generator emits an event per chunk. Saving each one would stringify + // the whole history on the main thread over and over, so write at most + // once an interval and let the run's completion flush the rest. + const since = Date.now() - (last_write.get(id) ?? 0); + if (since < STREAM_WRITE_INTERVAL_MS) return; + last_write.set(id, Date.now()); + deferred_outputs.delete(id); + } + + write_run_history(scope, runs); +} + +const STREAM_WRITE_INTERVAL_MS = 500; +/** When each in-flight streamed run was last written. */ +const last_write = new Map(); +/** The newest output of a streamed run that has not been written yet. */ +const deferred_outputs = new Map(); + +function mark_process_start(run: StoredRun, time: Date | undefined): void { + // The queue tells us when the function actually started, which lets us + // report a runtime that excludes however long the run sat in the queue. + if (run.process_started_at) return; + const started = time || new Date(); + run.process_started_at = started.toISOString(); + run.queued_ms = Math.max(0, started.getTime() - Date.parse(run.started_at)); +} + +function mark_complete(run: StoredRun, event: StatusMessage): void { + const completed = event.time || new Date(); + run.completed_at = completed.toISOString(); + // `cache_duration` is how long the function took on the server, which the + // queue reports on every completed run (not just cached ones). A generator + // reports it per chunk though, so the final value covers only the last one + // and the elapsed time is the honest number for a streamed run. + const server_duration = + !run.streamed && + typeof event.cache_duration === "number" && + event.cache_duration >= 0 + ? event.cache_duration * 1000 + : null; + run.duration_ms = + server_duration ?? + Math.max( + 0, + completed.getTime() - Date.parse(run.process_started_at || run.started_at) + ); +} + +// Public API. Each of these is a no-op if anything goes wrong. + +export function read_run_history( + scope: RunHistoryScope | null | undefined +): StoredRun[] { + return safely(() => read_run_history_impl(scope), []); +} + +export function clear_run_history( + scope: RunHistoryScope | null | undefined +): void { + safely(() => clear_run_history_impl(scope), undefined); +} + +export function delete_run_history( + scope: RunHistoryScope | null | undefined, + id: string +): void { + safely(() => delete_run_history_impl(scope, id), undefined); +} + +export function stage_run_history_replay( + scope: RunHistoryScope | null | undefined, + run: StoredRun +): void { + safely(() => stage_run_history_replay_impl(scope, run), undefined); +} + +export function consume_run_history_replay( + scope: RunHistoryScope | null | undefined +): StoredRun | null { + return safely(() => consume_run_history_replay_impl(scope), null); +} + +export function start_run_history(options: StartRunOptions): string | null { + return safely(() => start_run_history_impl(options), null); +} + +export function update_run_inputs( + scope: RunHistoryScope | null | undefined, + id: string | null, + inputs: unknown +): void { + safely(() => update_run_inputs_impl(scope, id, inputs), undefined); +} + +export function update_run_history( + scope: RunHistoryScope | null | undefined, + id: string | null, + event: GradioEvent +): void { + safely(() => update_run_history_impl(scope, id, event), undefined); +} + +export function on_run_history_change(listener: () => void): () => void { + return safely( + () => on_run_history_change_impl(() => safely(listener, undefined)), + () => {} + ); +} diff --git a/client/js/src/utils/submit.ts b/client/js/src/utils/submit.ts index db642d823a3..4876b3b073f 100644 --- a/client/js/src/utils/submit.ts +++ b/client/js/src/utils/submit.ts @@ -29,6 +29,11 @@ import { } from "../constants"; import { apply_diff_stream, close_stream } from "./stream"; import { Client } from "../client"; +import { + start_run_history, + update_run_history, + update_run_inputs +} from "./run_history"; export function submit( this: Client, @@ -73,12 +78,65 @@ export function submit( ); let resolved_data = map_data_to_params(data, endpoint_info); - - let stream: EventSource | null; let protocol = config.protocol ?? "ws"; if (protocol === "ws") { throw new Error(WS_PROTOCOL_MSG); } + const history_endpoint = + typeof dependency.api_name === "string" + ? `/${dependency.api_name}` + : endpoint; + const history_api_name = + typeof dependency.api_name === "string" + ? `/${dependency.api_name}` + : `Function ${fn_index}`; + // Kept index-aligned with the stored payloads (null for components we + // cannot resolve) so every saved value stays matched to its component. + const component_metadata = (id: number) => { + const component = config.components.find((item) => item.id === id); + if (!component) return null; + return { + type: component.type, + component_class_id: component.component_class_id, + props: component.props + }; + }; + // The run history covers the same endpoints the API page documents, which + // it selects with exactly this predicate (see `ApiDocs.svelte`). That also + // keeps out the dependencies Gradio wires up for itself, since example + // loading, flagging and clear buttons are all "undocumented" or "private" + // and some of them fire on page load. The trade is that a component whose + // UI submits through an undocumented dependency — `gr.ChatInterface` does + // — records nothing for its in-app use. + const is_documented_endpoint = dependency.api_visibility === "public"; + // Either side may opt out: the app for everyone who uses it, and this + // caller for itself. + const history_enabled = + config.run_history !== false && this.options.record_history !== false; + const history_scope = { app_id: config.app_id, username: config.username }; + const history_run_id = + !history_enabled || !is_documented_endpoint + ? null + : start_run_history({ + ...history_scope, + endpoint: history_endpoint, + api_name: history_api_name, + fn_index, + // Aligned to `dependency.inputs` the same way the submitted payload + // is, so this placeholder matches the components until the uploaded + // files are swapped in by `update_run_inputs` below. + inputs: handle_payload( + resolved_data, + dependency, + config.components, + "input", + true + ), + input_components: dependency.inputs.map(component_metadata), + output_components: dependency.outputs.map(component_metadata) + }); + + let stream: EventSource | null; let event_id_final = ""; let event_id_cb: () => string = () => event_id_final; @@ -103,6 +161,7 @@ export function submit( // event subscription methods function fire_event(event: GradioEvent): void { + update_run_history(history_scope, history_run_id, event); if (all_events || events_to_publish[event.type]) { push_event(event); } @@ -180,6 +239,7 @@ export function submit( "input", true ); + update_run_inputs(history_scope, history_run_id, input_data || []); payload = { data: input_data || [], event_data, diff --git a/gradio/blocks.py b/gradio/blocks.py index 024d366b1c1..32a1e36e7e6 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -1123,6 +1123,7 @@ def __init__( self.custom_mount_path: str | None = None self.pwa = False self.mcp_server = False + self.run_history = True # For analytics_enabled and allow_flagging: (1) first check for # parameter, (2) check for env variable, (3) default to True/"manual" @@ -2437,6 +2438,7 @@ def get_config_file(self) -> BlocksConfigDict: "enable_queue": True, # launch attributes "show_error": getattr(self, "show_error", False), "footer_links": getattr(self, "footer_links", []), + "run_history": getattr(self, "run_history", True), "is_colab": utils.colab_check(), "max_file_size": getattr(self, "max_file_size", None), "stylesheets": getattr(self, "stylesheets", []), @@ -2663,8 +2665,11 @@ def launch( ssl_keyfile_password: str | None = None, ssl_verify: bool = True, quiet: bool = False, - footer_links: list[Literal["api", "gradio", "settings"] | dict[str, str]] + footer_links: list[ + Literal["api", "gradio", "settings", "runs"] | dict[str, str] + ] | None = None, + run_history: bool | None = None, allowed_paths: list[str] | None = None, blocked_paths: list[str] | None = None, root_path: str | None = None, @@ -2716,7 +2721,8 @@ def launch( ssl_keyfile_password: If a password is provided, will use this with the ssl certificate for https. ssl_verify: If False, skips certificate validation which allows self-signed certificates to be used. quiet: If True, suppresses most print statements. - footer_links: The links to display in the footer of the app. Accepts a list, where each element of the list must be one of "api", "gradio", or "settings" corresponding to the API docs, "built with Gradio", and settings pages respectively. If None, all three links will be shown in the footer. An empty list means that no footer is shown. + footer_links: The links to display in the footer of the app. Accepts a list, where each element of the list must be one of "api", "gradio", "settings", or "runs" corresponding to the API docs, "built with Gradio", the settings page, and the run history page respectively. The "runs" link only appears if `run_history` is True and the browser has at least one saved run for this app. If None, all four links will be shown in the footer. An empty list means that no footer is shown. + run_history: If True, each user's browser saves the inputs and outputs of their own calls to this app, which they can review and reload from the run history page at /gradio_api/runs. The runs are kept in that browser's local storage, are scoped to the logged-in user if the app uses `auth`, and are never sent to the server. If False, nothing is recorded, the run history page is disabled, and any runs previously saved by this app are deleted from the browser. If None, will use the GRADIO_RUN_HISTORY environment variable or default to True. allowed_paths: List of complete filepaths or parent directories that gradio is allowed to serve. Must be absolute paths. Warning: if you provide directories, any files in these directories or their subdirectories are accessible to all users of your app. Can be set by comma separated environment variable GRADIO_ALLOWED_PATHS. These files are generally assumed to be secure and will be displayed in the browser when possible. blocked_paths: List of complete filepaths or parent directories that gradio is not allowed to serve (i.e. users of your app are not allowed to access). Must be absolute paths. Warning: takes precedence over `allowed_paths` and all other directories exposed by Gradio by default. Can be set by comma separated environment variable GRADIO_BLOCKED_PATHS. root_path: The root path (or "mount point") of the application, if it's not served from the root ("/") of the domain. Often used when the application is behind a reverse proxy that forwards requests to the application. For example, if the application is served at "https://example.com/myapp", the `root_path` should be set to "/myapp". A full URL beginning with http:// or https:// can be provided, which will be used as the root path in its entirety. Can be set by environment variable GRADIO_ROOT_PATH. Defaults to "". @@ -2820,9 +2826,18 @@ def reverse(text): self.root_path = os.environ.get("GRADIO_ROOT_PATH", "") else: self.root_path = root_path + self.run_history = ( + os.environ.get("GRADIO_RUN_HISTORY", "True").lower() == "true" + if run_history is None + else run_history + ) self.footer_links = ( - footer_links if footer_links is not None else ["api", "gradio", "settings"] + footer_links + if footer_links is not None + else ["api", "gradio", "settings", "runs"] ) + if not self.run_history: + self.footer_links = [link for link in self.footer_links if link != "runs"] if allowed_paths: self.allowed_paths = allowed_paths diff --git a/gradio/data_classes.py b/gradio/data_classes.py index 9dd7912ff84..db26578ec10 100644 --- a/gradio/data_classes.py +++ b/gradio/data_classes.py @@ -421,6 +421,7 @@ class BlocksConfigDict(TypedDict): i18n_translations: NotRequired[dict[str, dict[str, str]] | None] mcp_server: NotRequired[bool] footer_links: list[str | dict[str, str]] + run_history: NotRequired[bool] class MediaStreamChunk(TypedDict): diff --git a/gradio/routes.py b/gradio/routes.py index 918bde7f0b2..87b8665c22b 100644 --- a/gradio/routes.py +++ b/gradio/routes.py @@ -616,9 +616,10 @@ def main( ): mimetypes.add_type("application/javascript", ".js") blocks = app.get_blocks() + is_run_history = request.url.path.rstrip("/").endswith(f"{API_PREFIX}/runs") root = route_utils.get_root_url( request=request, - route_path=f"/{page}", + route_path=f"{API_PREFIX}/runs" if is_run_history else f"/{page}", root_path=app.root_path or request.scope.get("root_path") or blocks.custom_mount_path, @@ -680,6 +681,11 @@ def main( request=request, name=template, context={ + "base_url": ( + "../../" if request.url.path.endswith("/") else "../" + ) + if is_run_history + else "./", "config": config, "gradio_api_info": gradio_api_info, }, @@ -697,6 +703,16 @@ def main( "the frontend by running /scripts/build_frontend.sh" ) from err + @router.get("/runs", response_class=HTMLResponse) + @router.get("/runs/", response_class=HTMLResponse) + def run_history( + request: fastapi.Request, + user: str = Depends(get_current_user), + ): + if not getattr(app.get_blocks(), "run_history", True): + raise HTTPException(status_code=404, detail="Not found") + return main(request, user) + @app.get("/gradio_api/deep_link") def deep_link(session_hash: str): if session_hash in app.state_holder: @@ -2481,8 +2497,9 @@ def mount_gradio_app( server_name: str = "0.0.0.0", server_port: int = 7860, footer_links: ( - list[Literal["api", "gradio", "settings"] | dict[str, str]] | None + list[Literal["api", "gradio", "settings", "runs"] | dict[str, str]] | None ) = None, + run_history: bool | None = None, app_kwargs: dict[str, Any] | None = None, *, auth: Callable | tuple[str, str] | list[tuple[str, str]] | None = None, @@ -2526,7 +2543,8 @@ def mount_gradio_app( favicon_path: If a path to a file (.png, .gif, or .ico) is provided, it will be used as the favicon for this gradio app's page. show_error: If True, any errors in the gradio app will be displayed in an alert modal and printed in the browser console log. Otherwise, errors will only be visible in the terminal session running the Gradio app. max_file_size: The maximum file size in bytes that can be uploaded. Can be a string of the form "", where value is any positive integer and unit is one of "b", "kb", "mb", "gb", "tb". If None, no limit is set. - footer_links: The links to display in the footer of the app. Accepts a list, where each element of the list must be one of "api", "gradio", or "settings" corresponding to the API docs, "built with Gradio", and settings pages respectively. If None, all three links will be shown in the footer. An empty list means that no footer is shown. + footer_links: The links to display in the footer of the app. Accepts a list, where each element of the list must be one of "api", "gradio", "settings", or "runs" corresponding to the API docs, "built with Gradio", the settings page, and the run history page respectively. The "runs" link only appears if `run_history` is True and the browser has at least one saved run for this app. If None, all four links will be shown in the footer. An empty list means that no footer is shown. + run_history: If True, each user's browser saves the inputs and outputs of their own calls to this app, which they can review and reload from the run history page at /gradio_api/runs. The runs are kept in that browser's local storage, are scoped to the logged-in user if the app uses `auth`, and are never sent to the server. If False, nothing is recorded, the run history page is disabled, and any runs previously saved by this app are deleted from the browser. If None, will use the GRADIO_RUN_HISTORY environment variable or default to True. ssr_mode: If True, the Gradio app will be rendered using server-side rendering mode, which is typically more performant and provides better SEO, but this requires Node 20+ to be installed on the system. If False, the app will be rendered using client-side rendering mode. If None, will use GRADIO_SSR_MODE environment variable or default to False. node_server_name: The name of the Node server to use for SSR. If None, will use GRADIO_NODE_SERVER_NAME environment variable or search for a node binary in the system. i18n: If provided, the i18n instance to use for this gradio app. @@ -2556,8 +2574,15 @@ def read_main(): ) blocks.dev_mode = False + blocks.run_history = ( + os.environ.get("GRADIO_RUN_HISTORY", "True").lower() == "true" + if run_history is None + else run_history + ) if footer_links is None: - footer_links = ["api", "gradio", "settings"] + footer_links = ["api", "gradio", "settings", "runs"] + if not blocks.run_history: + footer_links = [link for link in footer_links if link != "runs"] blocks.footer_links = footer_links blocks.max_file_size = utils._parse_file_size(max_file_size) blocks.config = blocks.get_config_file() diff --git a/gradio/server.py b/gradio/server.py index c8c2e7134b4..0334ff9151f 100644 --- a/gradio/server.py +++ b/gradio/server.py @@ -242,8 +242,11 @@ def launch( ssl_keyfile_password: str | None = None, ssl_verify: bool = True, quiet: bool = False, - footer_links: list[Literal["api", "gradio", "settings"] | dict[str, str]] + footer_links: list[ + Literal["api", "gradio", "settings", "runs"] | dict[str, str] + ] | None = None, + run_history: bool | None = None, allowed_paths: list[str] | None = None, blocked_paths: list[str] | None = None, root_path: str | None = None, @@ -309,6 +312,7 @@ def launch( ssl_verify=ssl_verify, quiet=quiet, footer_links=footer_links, + run_history=run_history, allowed_paths=allowed_paths, blocked_paths=blocked_paths, root_path=root_path, diff --git a/guides/04_additional-features/14_view-api-page.md b/guides/04_additional-features/14_view-api-page.md index f1f3b3476d3..29897f1a353 100644 --- a/guides/04_additional-features/14_view-api-page.md +++ b/guides/04_additional-features/14_view-api-page.md @@ -88,6 +88,38 @@ Instead of reading through the view API page, you can also use Gradio's built-in ![](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/gradio-guides/api-recorder.gif) +## Run History + +Next to the "Use via API" link, the footer has a **Runs** link, which opens a page at `/gradio_api/runs` listing the runs made from this browser, grouped by endpoint. Each run shows its inputs, its outputs, how long the function took, and whether it succeeded. Clicking **Load run** puts a saved run's values back onto the page without calling the function again, which is a quick way to get back to an input you liked or to compare two results side by side. + +The run history covers the same endpoints as this API page. An event listener with `api_visibility="undocumented"` or `"private"` is not recorded, and neither is anything Gradio wires up on your behalf, such as loading an example. + +Runs are saved in the browser's local storage and are never sent to the server, so each visitor only ever sees their own, and nothing is stored alongside your app. If your app uses `auth`, the history is scoped to the logged-in user as well, so signing in as someone else on a shared browser will not surface the previous user's runs. The most recent 100 runs are kept per running app, and starting your app again begins a fresh history. Values held in `gr.State` live on the server, so they are neither shown nor restored. + +The link appears once the browser has saved its first run. To hide the link but keep recording, list the footer links you do want: + +```py +demo.launch(footer_links=["api", "gradio", "settings"]) +``` + +To turn the feature off completely, set `run_history=False`. Nothing is recorded, the run history page returns a 404, and any runs this app had already saved are cleared from the browser the next time someone opens it: + +```py +demo.launch(run_history=False) +``` + +This can also be set with the `GRADIO_RUN_HISTORY` environment variable, which is handy for a Space whose code you would rather not edit. + +### Runs made through the clients + +Calls made with the JavaScript client are recorded in the same way whenever that client runs in a browser, which is how a `gr.Server` app builds up a run history despite having no UI of its own. Pass `record_history: false` to opt a single client out: + +```js +const app = await Client.connect("abidlabs/my-app", { record_history: false }); +``` + +Nothing is recorded when the JavaScript client runs in Node, since there is no local storage to write to, and the Python client does not record runs at all. `run_history=False` on the app takes precedence over either client. + ## MCP Server The API page also includes instructions on how to use the Gradio app as an Model Context Protocol (MCP) server, which is a standardized way to expose functions as tools so that they can be used by LLMs. diff --git a/js/core/package.json b/js/core/package.json index fa22e59f4c5..49e285eb156 100644 --- a/js/core/package.json +++ b/js/core/package.json @@ -79,6 +79,11 @@ "svelte": "./dist/src/Login.svelte", "types": "./dist/src/Login.svelte.d.ts" }, + "./page_footer": { + "gradio": "./src/PageFooter.svelte", + "svelte": "./dist/src/PageFooter.svelte", + "types": "./dist/src/PageFooter.svelte.d.ts" + }, "./navbar_store": { "gradio": "./src/navbar_store.ts", "import": "./src/navbar_store.ts", diff --git a/js/core/src/Blocks.svelte b/js/core/src/Blocks.svelte index 7abb05f877f..61709218e6c 100644 --- a/js/core/src/Blocks.svelte +++ b/js/core/src/Blocks.svelte @@ -2,7 +2,12 @@ import { tick, onMount, setContext, settled, untrack } from "svelte"; import type { Component } from "svelte"; import { _ } from "svelte-i18n"; - import { Client } from "@gradio/client"; + import { + Client, + on_run_history_change, + read_run_history, + run_history_url + } from "@gradio/client"; import { writable } from "svelte/store"; import type { @@ -22,6 +27,7 @@ import logo from "./images/logo.svg"; import api_logo from "./api_docs/img/api-logo.svg"; import settings_logo from "./api_docs/img/settings-logo.svg"; + import history_logo from "./api_docs/img/history-logo.svg"; import record_stop from "./api_docs/img/record-stop.svg"; import { AppTree } from "./init.svelte"; @@ -61,6 +67,7 @@ js, fill_height, username, + run_history = true, api_prefix, max_file_size, initial_layout, @@ -89,6 +96,7 @@ js: string | null; fill_height: boolean; username: string | null; + run_history?: boolean; api_prefix: string; max_file_size: number | undefined; initial_layout: ComponentMeta | undefined; @@ -206,6 +214,12 @@ api_calls = [...api_calls, last_api_call]; }; + let run_count = $state(0); + + function refresh_run_count(): void { + run_count = read_run_history(app.config).length; + } + function handle_connection_lost(): void { messages = messages.filter((m) => m.type !== "error"); @@ -454,6 +468,9 @@ navigator.userAgent ); + refresh_run_count(); + const unsubscribe_run_history = on_run_history_change(refresh_run_count); + mutation_observer = new MutationObserver(handle_resize); const res = new ResizeObserver(handle_resize); @@ -483,6 +500,7 @@ mutation_observer?.disconnect(); mutation_observer = null; res.disconnect(); + unsubscribe_run_history(); if (reconnect_interval) clearInterval(reconnect_interval); }; }); @@ -516,6 +534,17 @@ bind:clientHeight={footer_height} aria-label="Gradio footer navigation" > + {#if run_history && footer_links.includes("runs") && run_count > 0} + + {$reactive_formatter("common.runs")} + {$reactive_formatter("common.runs")} + +
·
+ {/if} {#if footer_links.includes("api")} +{#if run_history_enabled} + +{/if} diff --git a/js/spa/src/RunValue.svelte b/js/spa/src/RunValue.svelte new file mode 100644 index 00000000000..8673bd94003 --- /dev/null +++ b/js/spa/src/RunValue.svelte @@ -0,0 +1,128 @@ + + +{#if unpreviewable} + {summarize(value)} +{:else} +
+{/if} + + diff --git a/js/spa/src/run_value.ts b/js/spa/src/run_value.ts new file mode 100644 index 00000000000..a47e1996efe --- /dev/null +++ b/js/spa/src/run_value.ts @@ -0,0 +1,108 @@ +/** + * Helpers for previewing a saved run's values. + * + * The run history reuses Gradio's compact Example renderers, but those were + * written for the values in an `Examples` dataset, which are not always shaped + * like the live values a run actually produces. Rather than give up and print + * JSON, reshape the value into what the renderer expects. + */ + +interface FileLike { + orig_name?: string; + path?: string; + url?: string; + meta?: { _type?: string }; +} + +/** + * The renderers that draw a file rather than describe it, by reading `url` off + * the value. Every other renderer prints what it is handed, so a `FileData` + * would come out as "[object Object]" and wants a file name instead. Keeping + * this as an allowlist means an unrecognised component degrades to its file + * name rather than to that. + */ +const RENDERS_FILES = new Set([ + "image", + "simpleimage", + "video", + "gallery", + "imageeditor" +]); + +/** + * Both the backend and the JS client stamp `FileData` with this discriminator, + * and it survives being saved. Matching on `path` or `url` instead would claim + * ordinary JSON such as `{ "url": "https://example.com" }` as a file and show + * it as a file name. + */ +function is_file_like(value: unknown): boolean { + if (Array.isArray(value)) return value.some(is_file_like); + if (!value || typeof value !== "object") return false; + return (value as FileLike).meta?._type === "gradio.FileData"; +} + +function file_label(value: unknown): string { + if (Array.isArray(value)) return value.map(file_label).join(", "); + if (value && typeof value === "object") { + const file = value as FileLike; + const name = file.orig_name || file.path || file.url; + if (typeof name === "string") return name.split("/").pop() || name; + } + return typeof value === "string" ? value : ""; +} + +/** + * Reshapes a live component value into the shape that component's Example + * renderer understands. Values it does not recognise are passed through. + */ +export function to_example_value(type: string, value: unknown): unknown { + if (value === null || value === undefined) return value; + + // The renderer draws rows of cells; a live dataframe is `{headers, data}`. + if (type === "dataframe") { + const frame = value as { headers?: unknown[]; data?: unknown[][] }; + if (Array.isArray(frame.data)) { + return Array.isArray(frame.headers) + ? [frame.headers, ...frame.data] + : frame.data; + } + return value; + } + + if (!RENDERS_FILES.has(type) && is_file_like(value)) { + return file_label(value) || value; + } + + return value; +} + +/** A short, human-readable stand-in for a value with no usable preview. */ +export function summarize(value: unknown): string { + if (value === null || value === undefined) return "No value"; + if (typeof value === "string") return value || "Empty value"; + if (typeof value !== "object") return String(value); + + // `gr.Label`: report the winning label rather than every confidence. + const label = (value as { label?: unknown }).label; + if (typeof label === "string") return label; + + // `gr.HighlightedText`: read as the sentence it highlights. + if ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => item && typeof item === "object" && "token" in item) + ) { + return value.map((item) => (item as { token: string }).token).join(""); + } + + if (is_file_like(value)) { + const file = file_label(value); + if (file) return file; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/test/test_routes.py b/test/test_routes.py index 65efb636844..252e3141785 100644 --- a/test/test_routes.py +++ b/test/test_routes.py @@ -65,6 +65,23 @@ def test_get_main_route(self, test_client): response = test_client.get("/") assert response.status_code == 200 + def test_get_run_history_route(self, test_client): + response = test_client.get(f"{API_PREFIX}/runs") + assert response.status_code == 200 + assert "