| <script lang="ts"> | |
| import { tick } from "svelte"; | |
| import { _ } from "svelte-i18n"; | |
| import { Client } from "@gradio/client"; | |
| import type { LoadingStatus, LoadingStatusCollection } from "./stores"; | |
| import type { ComponentMeta, Dependency, LayoutNode } from "./types"; | |
| import type { UpdateTransaction } from "./init"; | |
| import { setupi18n } from "./i18n"; | |
| import { ApiDocs, ApiRecorder } from "./api_docs/"; | |
| import type { ThemeMode, Payload } from "./types"; | |
| import { Toast } from "@gradio/statustracker"; | |
| import type { ToastMessage } from "@gradio/statustracker"; | |
| import type { ShareData } from "@gradio/utils"; | |
| import MountComponents from "./MountComponents.svelte"; | |
| import logo from "./images/logo.svg"; | |
| import api_logo from "./api_docs/img/api-logo.svg"; | |
| import { create_components, AsyncFunction } from "./init"; | |
| import type { | |
| LogMessage, | |
| RenderMessage, | |
| StatusMessage | |
| } from "@gradio/client"; | |
| setupi18n(); | |
| export let root: string; | |
| export let components: ComponentMeta[]; | |
| export let layout: LayoutNode; | |
| export let dependencies: Dependency[]; | |
| export let title = "Gradio"; | |
| export let target: HTMLElement; | |
| export let autoscroll: boolean; | |
| export let show_api = true; | |
| export let show_footer = true; | |
| export let control_page_title = false; | |
| export let app_mode: boolean; | |
| export let theme_mode: ThemeMode; | |
| export let app: Awaited<ReturnType<typeof Client.connect>>; | |
| export let space_id: string | null; | |
| export let version: string; | |
| export let js: string | null; | |
| export let fill_height = false; | |
| export let ready: boolean; | |
| export let username: string | null; | |
| const { | |
| layout: _layout, | |
| targets, | |
| update_value, | |
| get_data, | |
| loading_status, | |
| scheduled_updates, | |
| create_layout, | |
| rerender_layout | |
| } = create_components(); | |
| $: create_layout({ | |
| components, | |
| layout, | |
| dependencies, | |
| root, | |
| app, | |
| options: { | |
| fill_height | |
| } | |
| }); | |
| $: { | |
| ready = !!$_layout; | |
| } | |
| let params = new URLSearchParams(window.location.search); | |
| let api_docs_visible = params.get("view") === "api" && show_api; | |
| let api_recorder_visible = params.get("view") === "api-recorder" && show_api; | |
| function set_api_docs_visible(visible: boolean): void { | |
| api_recorder_visible = false; | |
| api_docs_visible = visible; | |
| let params = new URLSearchParams(window.location.search); | |
| if (visible) { | |
| params.set("view", "api"); | |
| } else { | |
| params.delete("view"); | |
| } | |
| history.replaceState(null, "", "?" + params.toString()); | |
| } | |
| let api_calls: Payload[] = []; | |
| export let render_complete = false; | |
| async function handle_update(data: any, fn_index: number): Promise<void> { | |
| const outputs = dependencies.find((dep) => dep.id == fn_index)!.outputs; | |
| const meta_updates = data?.map((value: any, i: number) => { | |
| return { | |
| id: outputs[i], | |
| prop: "value_is_output", | |
| value: true | |
| }; | |
| }); | |
| update_value(meta_updates); | |
| await tick(); | |
| const updates: UpdateTransaction[] = []; | |
| data?.forEach((value: any, i: number) => { | |
| if ( | |
| typeof value === "object" && | |
| value !== null && | |
| value.__type__ === "update" | |
| ) { | |
| for (const [update_key, update_value] of Object.entries(value)) { | |
| if (update_key === "__type__") { | |
| continue; | |
| } else { | |
| updates.push({ | |
| id: outputs[i], | |
| prop: update_key, | |
| value: update_value | |
| }); | |
| } | |
| } | |
| } else { | |
| updates.push({ | |
| id: outputs[i], | |
| prop: "value", | |
| value | |
| }); | |
| } | |
| }); | |
| update_value(updates); | |
| await tick(); | |
| } | |
| let submit_map: Map<number, ReturnType<typeof app.submit>> = new Map(); | |
| let messages: (ToastMessage & { fn_index: number })[] = []; | |
| function new_message( | |
| message: string, | |
| fn_index: number, | |
| type: ToastMessage["type"], | |
| duration: number | null = 10, | |
| visible = true | |
| ): ToastMessage & { fn_index: number } { | |
| return { | |
| message, | |
| fn_index, | |
| type, | |
| id: ++_error_id, | |
| duration, | |
| visible | |
| }; | |
| } | |
| export function add_new_message( | |
| message: string, | |
| type: ToastMessage["type"] | |
| ): void { | |
| messages = [new_message(message, -1, type), ...messages]; | |
| } | |
| let _error_id = -1; | |
| let user_left_page = false; | |
| document.addEventListener("visibilitychange", function () { | |
| if (document.visibilityState === "hidden") { | |
| user_left_page = true; | |
| } | |
| }); | |
| const MESSAGE_QUOTE_RE = /^'([^]+)'$/; | |
| const DUPLICATE_MESSAGE = $_("blocks.long_requests_queue"); | |
| const MOBILE_QUEUE_WARNING = $_("blocks.connection_can_break"); | |
| const MOBILE_RECONNECT_MESSAGE = $_("blocks.lost_connection"); | |
| const SHOW_DUPLICATE_MESSAGE_ON_ETA = 15; | |
| const SHOW_MOBILE_QUEUE_WARNING_ON_ETA = 10; | |
| const is_mobile_device = | |
| /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( | |
| navigator.userAgent | |
| ); | |
| let showed_duplicate_message = false; | |
| let showed_mobile_warning = false; | |
| // as state updates are not synchronous, we need to ensure updates are flushed before triggering any requests | |
| function wait_then_trigger_api_call( | |
| dep_index: number, | |
| trigger_id: number | null = null, | |
| event_data: unknown = null | |
| ): void { | |
| let _unsub = (): void => {}; | |
| function unsub(): void { | |
| _unsub(); | |
| } | |
| if ($scheduled_updates) { | |
| _unsub = scheduled_updates.subscribe((updating) => { | |
| if (!updating) { | |
| trigger_api_call(dep_index, trigger_id, event_data); | |
| unsub(); | |
| } | |
| }); | |
| } else { | |
| trigger_api_call(dep_index, trigger_id, event_data); | |
| } | |
| } | |
| async function trigger_api_call( | |
| dep_index: number, | |
| trigger_id: number | null = null, | |
| event_data: unknown = null | |
| ): Promise<void> { | |
| let dep = dependencies.find((dep) => dep.id === dep_index)!; | |
| const current_status = loading_status.get_status_for_fn(dep_index); | |
| messages = messages.filter(({ fn_index }) => fn_index !== dep_index); | |
| if (current_status === "pending" || current_status === "generating") { | |
| dep.pending_request = true; | |
| } | |
| let payload: Payload = { | |
| fn_index: dep_index, | |
| data: await Promise.all(dep.inputs.map((id) => get_data(id))), | |
| event_data: dep.collects_event_data ? event_data : null, | |
| trigger_id: trigger_id | |
| }; | |
| if (dep.frontend_fn) { | |
| dep | |
| .frontend_fn( | |
| payload.data.concat( | |
| await Promise.all(dep.outputs.map((id) => get_data(id))) | |
| ) | |
| ) | |
| .then((v: unknown[]) => { | |
| if (dep.backend_fn) { | |
| payload.data = v; | |
| trigger_prediction(dep, payload); | |
| } else { | |
| handle_update(v, dep_index); | |
| } | |
| }); | |
| } else if (dep.types.cancel && dep.cancels) { | |
| await Promise.all( | |
| dep.cancels.map(async (fn_index) => { | |
| const submission = submit_map.get(fn_index); | |
| submission?.cancel(); | |
| return submission; | |
| }) | |
| ); | |
| } else { | |
| if (dep.backend_fn) { | |
| trigger_prediction(dep, payload); | |
| } | |
| } | |
| function trigger_prediction(dep: Dependency, payload: Payload): void { | |
| if (dep.trigger_mode === "once") { | |
| if (!dep.pending_request) make_prediction(payload); | |
| } else if (dep.trigger_mode === "multiple") { | |
| make_prediction(payload); | |
| } else if (dep.trigger_mode === "always_last") { | |
| if (!dep.pending_request) { | |
| make_prediction(payload); | |
| } else { | |
| dep.final_event = payload; | |
| } | |
| } | |
| } | |
| async function make_prediction(payload: Payload): Promise<void> { | |
| if (api_recorder_visible) { | |
| api_calls = [...api_calls, JSON.parse(JSON.stringify(payload))]; | |
| } | |
| let submission: ReturnType<typeof app.submit>; | |
| try { | |
| submission = app.submit( | |
| payload.fn_index, | |
| payload.data as unknown[], | |
| payload.event_data, | |
| payload.trigger_id | |
| ); | |
| } catch (e) { | |
| const fn_index = 0; // Mock value for fn_index | |
| messages = [new_message(String(e), fn_index, "error"), ...messages]; | |
| loading_status.update({ | |
| status: "error", | |
| fn_index, | |
| eta: 0, | |
| queue: false, | |
| queue_position: null | |
| }); | |
| set_status($loading_status); | |
| return; | |
| } | |
| submit_map.set(dep_index, submission); | |
| for await (const message of submission) { | |
| if (message.type === "data") { | |
| handle_data(message); | |
| } else if (message.type === "render") { | |
| handle_render(message); | |
| } else if (message.type === "status") { | |
| handle_status_update(message); | |
| } else if (message.type === "log") { | |
| handle_log(message); | |
| } | |
| } | |
| function handle_data(message: Payload): void { | |
| const { data, fn_index } = message; | |
| if (dep.pending_request && dep.final_event) { | |
| dep.pending_request = false; | |
| make_prediction(dep.final_event); | |
| } | |
| dep.pending_request = false; | |
| handle_update(data, fn_index); | |
| set_status($loading_status); | |
| } | |
| function handle_render(message: RenderMessage): void { | |
| const { data } = message; | |
| let _components: ComponentMeta[] = data.components; | |
| let render_layout: LayoutNode = data.layout; | |
| let _dependencies: Dependency[] = data.dependencies; | |
| let render_id = data.render_id; | |
| let deps_to_remove: number[] = []; | |
| dependencies.forEach((dep, i) => { | |
| if (dep.rendered_in === render_id) { | |
| deps_to_remove.push(i); | |
| } | |
| }); | |
| deps_to_remove.reverse().forEach((i) => { | |
| dependencies.splice(i, 1); | |
| }); | |
| _dependencies.forEach((dep) => { | |
| dependencies.push(dep); | |
| }); | |
| rerender_layout({ | |
| components: _components, | |
| layout: render_layout, | |
| root: root, | |
| dependencies: dependencies, | |
| render_id: render_id | |
| }); | |
| } | |
| function handle_log(msg: LogMessage): void { | |
| const { log, fn_index, level, duration, visible } = msg; | |
| messages = [ | |
| new_message(log, fn_index, level, duration, visible), | |
| ...messages | |
| ]; | |
| } | |
| function handle_status_update(message: StatusMessage): void { | |
| const { fn_index, ...status } = message; | |
| //@ts-ignore | |
| loading_status.update({ | |
| ...status, | |
| status: status.stage, | |
| progress: status.progress_data, | |
| fn_index | |
| }); | |
| set_status($loading_status); | |
| if ( | |
| !showed_duplicate_message && | |
| space_id !== null && | |
| status.position !== undefined && | |
| status.position >= 2 && | |
| status.eta !== undefined && | |
| status.eta > SHOW_DUPLICATE_MESSAGE_ON_ETA | |
| ) { | |
| showed_duplicate_message = true; | |
| messages = [ | |
| new_message(DUPLICATE_MESSAGE, fn_index, "warning"), | |
| ...messages | |
| ]; | |
| } | |
| if ( | |
| !showed_mobile_warning && | |
| is_mobile_device && | |
| status.eta !== undefined && | |
| status.eta > SHOW_MOBILE_QUEUE_WARNING_ON_ETA | |
| ) { | |
| showed_mobile_warning = true; | |
| messages = [ | |
| new_message(MOBILE_QUEUE_WARNING, fn_index, "warning"), | |
| ...messages | |
| ]; | |
| } | |
| if (status.stage === "complete") { | |
| status.changed_state_ids?.forEach((id) => { | |
| dependencies | |
| .filter((dep) => dep.targets.some(([_id, _]) => _id === id)) | |
| .forEach((dep) => { | |
| wait_then_trigger_api_call(dep.id, payload.trigger_id); | |
| }); | |
| }); | |
| dependencies.forEach(async (dep) => { | |
| if (dep.trigger_after === fn_index) { | |
| wait_then_trigger_api_call(dep.id, payload.trigger_id); | |
| } | |
| }); | |
| // submission.destroy(); | |
| } | |
| if (status.broken && is_mobile_device && user_left_page) { | |
| window.setTimeout(() => { | |
| messages = [ | |
| new_message(MOBILE_RECONNECT_MESSAGE, fn_index, "error"), | |
| ...messages | |
| ]; | |
| }, 0); | |
| wait_then_trigger_api_call(dep.id, payload.trigger_id, event_data); | |
| user_left_page = false; | |
| } else if (status.stage === "error") { | |
| if (status.message) { | |
| const _message = status.message.replace( | |
| MESSAGE_QUOTE_RE, | |
| (_, b) => b | |
| ); | |
| messages = [ | |
| new_message( | |
| _message, | |
| fn_index, | |
| "error", | |
| status.duration, | |
| status.visible | |
| ), | |
| ...messages | |
| ]; | |
| } | |
| dependencies.map(async (dep) => { | |
| if ( | |
| dep.trigger_after === fn_index && | |
| !dep.trigger_only_on_success | |
| ) { | |
| wait_then_trigger_api_call(dep.id, payload.trigger_id); | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| function trigger_share(title: string | undefined, description: string): void { | |
| if (space_id === null) { | |
| return; | |
| } | |
| const discussion_url = new URL( | |
| `https://huggingface.co/spaces/${space_id}/discussions/new` | |
| ); | |
| if (title !== undefined && title.length > 0) { | |
| discussion_url.searchParams.set("title", title); | |
| } | |
| discussion_url.searchParams.set("description", description); | |
| window.open(discussion_url.toString(), "_blank"); | |
| } | |
| function handle_error_close(e: Event & { detail: number }): void { | |
| const _id = e.detail; | |
| messages = messages.filter((m) => m.id !== _id); | |
| } | |
| const is_external_url = (link: string | null): boolean => | |
| !!(link && new URL(link, location.href).origin !== location.origin); | |
| async function handle_mount(): Promise<void> { | |
| if (js) { | |
| let blocks_frontend_fn = new AsyncFunction( | |
| `let result = await (${js})(); | |
| return (!Array.isArray(result)) ? [result] : result;` | |
| ); | |
| await blocks_frontend_fn(); | |
| } | |
| await tick(); | |
| var a = target.getElementsByTagName("a"); | |
| for (var i = 0; i < a.length; i++) { | |
| const _target = a[i].getAttribute("target"); | |
| const _link = a[i].getAttribute("href"); | |
| // only target anchor tags with external links | |
| if (is_external_url(_link) && _target !== "_blank") | |
| a[i].setAttribute("target", "_blank"); | |
| } | |
| // handle load triggers | |
| dependencies.forEach((dep) => { | |
| if (dep.targets.some((dep) => dep[1] === "load")) { | |
| wait_then_trigger_api_call(dep.id); | |
| } | |
| }); | |
| if (render_complete) return; | |
| target.addEventListener("prop_change", (e: Event) => { | |
| if (!isCustomEvent(e)) throw new Error("not a custom event"); | |
| const { id, prop, value } = e.detail; | |
| update_value([{ id, prop, value }]); | |
| }); | |
| target.addEventListener("gradio", (e: Event) => { | |
| if (!isCustomEvent(e)) throw new Error("not a custom event"); | |
| const { id, event, data } = e.detail; | |
| if (event === "share") { | |
| const { title, description } = data as ShareData; | |
| trigger_share(title, description); | |
| } else if (event === "error" || event === "warning") { | |
| messages = [new_message(data, -1, event), ...messages]; | |
| } else if (event == "clear_status") { | |
| update_status(id, "complete", data); | |
| } else { | |
| const deps = $targets[id]?.[event]; | |
| deps?.forEach((dep_id) => { | |
| requestAnimationFrame(() => { | |
| wait_then_trigger_api_call(dep_id, id, data); | |
| }); | |
| }); | |
| } | |
| }); | |
| render_complete = true; | |
| } | |
| $: set_status($loading_status); | |
| function update_status( | |
| id: number, | |
| status: "error" | "complete" | "pending", | |
| data: LoadingStatus | |
| ): void { | |
| data.status = status; | |
| update_value([ | |
| { | |
| id, | |
| prop: "loading_status", | |
| value: data | |
| } | |
| ]); | |
| } | |
| function set_status(statuses: LoadingStatusCollection): void { | |
| let updates: { | |
| id: number; | |
| prop: string; | |
| value: LoadingStatus; | |
| }[] = []; | |
| Object.entries(statuses).forEach(([id, loading_status]) => { | |
| let dependency = dependencies.find( | |
| (dep) => dep.id == loading_status.fn_index | |
| ); | |
| if (dependency === undefined) { | |
| return; | |
| } | |
| loading_status.scroll_to_output = dependency.scroll_to_output; | |
| loading_status.show_progress = dependency.show_progress; | |
| updates.push({ | |
| id: parseInt(id), | |
| prop: "loading_status", | |
| value: loading_status | |
| }); | |
| }); | |
| const inputs_to_update = loading_status.get_inputs_to_update(); | |
| const additional_updates = Array.from(inputs_to_update).map( | |
| ([id, pending_status]) => { | |
| return { | |
| id, | |
| prop: "pending", | |
| value: pending_status === "pending" | |
| }; | |
| } | |
| ); | |
| update_value([...updates, ...additional_updates]); | |
| } | |
| function isCustomEvent(event: Event): event is CustomEvent { | |
| return "detail" in event; | |
| } | |
| </script> | |
| <svelte:head> | |
| {#if control_page_title} | |
| <title>{title}</title> | |
| {/if} | |
| </svelte:head> | |
| <div class="wrap" style:min-height={app_mode ? "100%" : "auto"}> | |
| <div class="contain" style:flex-grow={app_mode ? "1" : "auto"}> | |
| {#if $_layout && app.config} | |
| <MountComponents | |
| rootNode={$_layout} | |
| {root} | |
| {target} | |
| {theme_mode} | |
| on:mount={handle_mount} | |
| {version} | |
| {autoscroll} | |
| max_file_size={app.config.max_file_size} | |
| client={app} | |
| /> | |
| {/if} | |
| </div> | |
| {#if show_footer} | |
| <footer> | |
| {#if show_api} | |
| <button | |
| on:click={() => { | |
| set_api_docs_visible(!api_docs_visible); | |
| }} | |
| class="show-api" | |
| > | |
| {$_("errors.use_via_api")} | |
| <img src={api_logo} alt={$_("common.logo")} /> | |
| </button> | |
| <div>·</div> | |
| {/if} | |
| <a | |
| href="https://gradio.app" | |
| class="built-with" | |
| target="_blank" | |
| rel="noreferrer" | |
| > | |
| {$_("common.built_with_gradio")} | |
| <img src={logo} alt={$_("common.logo")} /> | |
| </a> | |
| </footer> | |
| {/if} | |
| </div> | |
| {#if api_recorder_visible} | |
| <!-- TODO: fix --> | |
| <!-- svelte-ignore a11y-click-events-have-key-events--> | |
| <!-- svelte-ignore a11y-no-static-element-interactions--> | |
| <div | |
| id="api-recorder-container" | |
| on:click={() => { | |
| set_api_docs_visible(true); | |
| api_recorder_visible = false; | |
| }} | |
| > | |
| <ApiRecorder {api_calls} {dependencies} /> | |
| </div> | |
| {/if} | |
| {#if api_docs_visible && $_layout} | |
| <div class="api-docs"> | |
| <!-- TODO: fix --> | |
| <!-- svelte-ignore a11y-click-events-have-key-events--> | |
| <!-- svelte-ignore a11y-no-static-element-interactions--> | |
| <div | |
| class="backdrop" | |
| on:click={() => { | |
| set_api_docs_visible(false); | |
| }} | |
| /> | |
| <div class="api-docs-wrap"> | |
| <ApiDocs | |
| root_node={$_layout} | |
| on:close={(event) => { | |
| set_api_docs_visible(false); | |
| api_calls = []; | |
| api_recorder_visible = event.detail.api_recorder_visible; | |
| }} | |
| {dependencies} | |
| {root} | |
| {app} | |
| {space_id} | |
| {api_calls} | |
| {username} | |
| /> | |
| </div> | |
| </div> | |
| {/if} | |
| {#if messages} | |
| <Toast {messages} on:close={handle_error_close} /> | |
| {/if} | |
| <style> | |
| .wrap { | |
| display: flex; | |
| flex-grow: 1; | |
| flex-direction: column; | |
| width: var(--size-full); | |
| font-weight: var(--body-text-weight); | |
| font-size: var(--body-text-size); | |
| } | |
| .contain { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| footer { | |
| display: flex; | |
| justify-content: center; | |
| margin-top: var(--size-4); | |
| color: var(--body-text-color-subdued); | |
| } | |
| footer > * + * { | |
| margin-left: var(--size-2); | |
| } | |
| .show-api { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .show-api:hover { | |
| color: var(--body-text-color); | |
| } | |
| .show-api img { | |
| margin-right: var(--size-1); | |
| margin-left: var(--size-2); | |
| width: var(--size-3); | |
| } | |
| .built-with { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .built-with:hover { | |
| color: var(--body-text-color); | |
| } | |
| .built-with img { | |
| margin-right: var(--size-1); | |
| margin-left: var(--size-1); | |
| margin-bottom: 1px; | |
| width: var(--size-4); | |
| } | |
| .api-docs { | |
| display: flex; | |
| position: fixed; | |
| top: 0; | |
| right: 0; | |
| z-index: var(--layer-top); | |
| background: rgba(0, 0, 0, 0.5); | |
| width: var(--size-screen); | |
| height: var(--size-screen-h); | |
| } | |
| .backdrop { | |
| flex: 1 1 0%; | |
| -webkit-backdrop-filter: blur(4px); | |
| backdrop-filter: blur(4px); | |
| } | |
| .api-docs-wrap { | |
| box-shadow: var(--shadow-drop-lg); | |
| background: var(--background-fill-primary); | |
| overflow-x: hidden; | |
| overflow-y: auto; | |
| } | |
| @media (--screen-md) { | |
| .api-docs-wrap { | |
| border-top-left-radius: var(--radius-lg); | |
| border-bottom-left-radius: var(--radius-lg); | |
| width: 950px; | |
| } | |
| } | |
| @media (--screen-xxl) { | |
| .api-docs-wrap { | |
| width: 1150px; | |
| } | |
| } | |
| #api-recorder-container { | |
| position: fixed; | |
| left: 10px; | |
| bottom: 10px; | |
| z-index: 1000; | |
| } | |
| </style> | |