diff --git a/.changeset/large-beans-retire.md b/.changeset/large-beans-retire.md new file mode 100644 index 0000000000000..099398ea7caa0 --- /dev/null +++ b/.changeset/large-beans-retire.md @@ -0,0 +1,10 @@ +--- +"@gradio/client": minor +"@gradio/core": minor +"@gradio/lite": minor +"@self/app": minor +"@self/spa": minor +"gradio": minor +--- + +feat:Allow building multipage Gradio apps diff --git a/client/js/src/client.ts b/client/js/src/client.ts index aef0684d83162..3b8191e810f5b 100644 --- a/client/js/src/client.ts +++ b/client/js/src/client.ts @@ -65,6 +65,40 @@ export class Client { current_payload: any; ws_map: Record = {}; + get_url_config(url: string | null = null): Config { + if (!this.config) { + throw new Error(CONFIG_ERROR_MSG); + } + if (url === null) { + url = window.location.href; + } + const stripSlashes = (str: string): string => str.replace(/^\/+|\/+$/g, ""); + let root_path = stripSlashes(new URL(this.config.root).pathname); + let url_path = stripSlashes(new URL(url).pathname); + let page = stripSlashes(url_path.substring(root_path.length)); + return this.get_page_config(page); + } + get_page_config(page: string): Config { + if (!this.config) { + throw new Error(CONFIG_ERROR_MSG); + } + let config = this.config; + if (!(page in config.page)) { + throw new Error(`Page ${page} not found`); + } + return { + ...config, + current_page: page, + layout: config.page[page].layout, + components: config.components.filter((c) => + config.page[page].components.includes(c.id) + ), + dependencies: this.config.dependencies.filter((d) => + config.page[page].dependencies.includes(d.id) + ) + }; + } + fetch(input: RequestInfo | URL, init?: RequestInit): Promise { const headers = new Headers(init?.headers || {}); if (this && this.cookies) { diff --git a/client/js/src/types.ts b/client/js/src/types.ts index 95b27f0e86505..f528a58d3ee8f 100644 --- a/client/js/src/types.ts +++ b/client/js/src/types.ts @@ -179,6 +179,16 @@ export interface Config { show_api: boolean; stylesheets: string[]; path: string; + current_page: string; + page: Record< + string, + { + components: number[]; + dependencies: number[]; + layout: any; + } + >; + pages: [string, string][]; protocol: "sse_v3" | "sse_v2.1" | "sse_v2" | "sse_v1" | "sse" | "ws"; max_file_size?: number; theme_hash?: number; diff --git a/demo/multipage/run.ipynb b/demo/multipage/run.ipynb new file mode 100644 index 0000000000000..bc44246e2c8b3 --- /dev/null +++ b/demo/multipage/run.ipynb @@ -0,0 +1 @@ +{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: multipage"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import random\n", "import time\n", "\n", "with gr.Blocks() as demo:\n", " name = gr.Textbox(label=\"Name\")\n", " output = gr.Textbox(label=\"Output Box\")\n", " greet_btn = gr.Button(\"Greet\")\n", " @gr.on([greet_btn.click, name.submit], inputs=name, outputs=output)\n", " def greet(name):\n", " return \"Hello \" + name + \"!\"\n", " \n", " @gr.render(inputs=name, triggers=[output.change])\n", " def spell_out(name):\n", " with gr.Row():\n", " for letter in name:\n", " gr.Textbox(letter)\n", "\n", "with demo.route(\"Up\") as incrementer_demo:\n", " num = gr.Number()\n", " incrementer_demo.load(lambda: time.sleep(1) or random.randint(10, 40), None, num)\n", "\n", " with gr.Row():\n", " inc_btn = gr.Button(\"Increase\")\n", " dec_btn = gr.Button(\"Decrease\")\n", " inc_btn.click(fn=lambda x: x + 1, inputs=num, outputs=num, api_name=\"increment\")\n", " dec_btn.click(fn=lambda x: x - 1, inputs=num, outputs=num, api_name=\"decrement\")\n", " for i in range(100):\n", " gr.Textbox()\n", "\n", "def wait(x):\n", " time.sleep(2)\n", " return x\n", "\n", "identity_iface = gr.Interface(wait, \"image\", \"image\")\n", "\n", "with demo.route(\"Interface\") as incrementer_demo:\n", " identity_iface.render()\n", " gr.Interface(lambda x, y: x * y, [\"number\", \"number\"], \"number\")\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} \ No newline at end of file diff --git a/demo/multipage/run.py b/demo/multipage/run.py new file mode 100644 index 0000000000000..1ec2362a4b810 --- /dev/null +++ b/demo/multipage/run.py @@ -0,0 +1,42 @@ +import gradio as gr +import random +import time + +with gr.Blocks() as demo: + name = gr.Textbox(label="Name") + output = gr.Textbox(label="Output Box") + greet_btn = gr.Button("Greet") + @gr.on([greet_btn.click, name.submit], inputs=name, outputs=output) + def greet(name): + return "Hello " + name + "!" + + @gr.render(inputs=name, triggers=[output.change]) + def spell_out(name): + with gr.Row(): + for letter in name: + gr.Textbox(letter) + +with demo.route("Up") as incrementer_demo: + num = gr.Number() + incrementer_demo.load(lambda: time.sleep(1) or random.randint(10, 40), None, num) + + with gr.Row(): + inc_btn = gr.Button("Increase") + dec_btn = gr.Button("Decrease") + inc_btn.click(fn=lambda x: x + 1, inputs=num, outputs=num, api_name="increment") + dec_btn.click(fn=lambda x: x - 1, inputs=num, outputs=num, api_name="decrement") + for i in range(100): + gr.Textbox() + +def wait(x): + time.sleep(2) + return x + +identity_iface = gr.Interface(wait, "image", "image") + +with demo.route("Interface") as incrementer_demo: + identity_iface.render() + gr.Interface(lambda x, y: x * y, ["number", "number"], "number") + +if __name__ == "__main__": + demo.launch() diff --git a/gradio/blocks.py b/gradio/blocks.py index d17442ba07ad8..278661c3526ff 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -8,6 +8,7 @@ import json import os import random +import re import secrets import string import sys @@ -54,6 +55,7 @@ FileData, GradioModel, GradioRootModel, + Layout, ) from gradio.events import ( EventData, @@ -69,7 +71,7 @@ from gradio.helpers import create_tracker, skip, special_args from gradio.node_server import start_node_server from gradio.route_utils import API_PREFIX, MediaStream -from gradio.routes import VERSION, App, Request +from gradio.routes import INTERNAL_ROUTES, VERSION, App, Request from gradio.state_holder import SessionState, StateHolder from gradio.themes import Default as DefaultTheme from gradio.themes import ThemeClass as Theme @@ -137,6 +139,7 @@ def __init__( self.share_token = secrets.token_urlsafe(32) self.parent: BlockContext | None = None self.rendered_in: Renderable | None = None + self.page: str self.is_rendered: bool = False self._constructor_args: list[dict] self.state_session_capacity = 10000 @@ -187,6 +190,8 @@ def render(self): f"A block with id: {self._id} has already been rendered in the current Blocks." ) if render_context is not None: + if root_context: + self.page = root_context.root_block.current_page render_context.add(self) if root_context is not None: root_context.blocks[self._id] = self @@ -467,6 +472,7 @@ def fill_expected_parents(self): pseudo_parent.parent = self children.append(pseudo_parent) pseudo_parent.add_child(child) + pseudo_parent.page = child.page if root_context: root_context.blocks[pseudo_parent._id] = pseudo_parent child.parent = pseudo_parent @@ -521,6 +527,7 @@ def __init__( stream_every: float = 0.5, like_user_message: bool = False, event_specific_args: list[str] | None = None, + page: str = "", ): self.fn = fn self._id = _id @@ -554,6 +561,7 @@ def __init__( ) or inspect.isasyncgenfunction(self.fn) self.renderable = renderable self.rendered_in = rendered_in + self.page = page # We need to keep track of which events are cancel events # so that the client can call the /cancel route directly @@ -871,6 +879,7 @@ def set_event_trigger( stream_every=stream_every, like_user_message=like_user_message, event_specific_args=event_specific_args, + page=self.root_block.current_page, ) self.fns[self.fn_id] = block_fn @@ -878,12 +887,24 @@ def set_event_trigger( return block_fn, block_fn._id def get_config(self, renderable: Renderable | None = None): - config = {} + config = { + "page": {}, + "components": [], + "dependencies": [], + } + + for page, _ in self.root_block.pages: + if page not in config["page"]: + config["page"][page] = { + "layout": {"id": self.root_block._id, "children": []}, + "components": [], + "dependencies": [], + } rendered_ids = [] sidebar_count = [0] - def get_layout(block: Block): + def get_layout(block: Block) -> Layout: rendered_ids.append(block._id) if block.get_block_name() == "sidebar": sidebar_count[0] += 1 @@ -895,16 +916,22 @@ def get_layout(block: Block): return {"id": block._id} children_layout = [] for child in block.children: - children_layout.append(get_layout(child)) + layout = get_layout(child) + children_layout.append(layout) return {"id": block._id, "children": children_layout} if renderable: root_block = self.blocks[renderable.container_id] else: root_block = self.root_block - config["layout"] = get_layout(root_block) + layout = get_layout(root_block) + config["layout"] = layout + + for root_child in layout.get("children", []): + if isinstance(root_child, dict) and root_child["id"] in self.blocks: + block = self.blocks[root_child["id"]] + config["page"][block.page]["layout"]["children"].append(root_child) - config["components"] = [] blocks_items = list( self.blocks.items() ) # freeze as list to prevent concurrent re-renders from changing the dict during loop, see https://github.com/gradio-app/gradio/issues/9991 @@ -937,11 +964,15 @@ def get_layout(block: Block): block_config["api_info_as_output"] = block.api_info() # type: ignore block_config["example_inputs"] = block.example_inputs() # type: ignore config["components"].append(block_config) + config["page"][block.page]["components"].append(block._id) dependencies = [] for fn in self.fns.values(): if renderable is None or fn.rendered_in == renderable: - dependencies.append(fn.get_config()) + dependency_config = fn.get_config() + dependencies.append(dependency_config) + config["page"][fn.page]["dependencies"].append(dependency_config["id"]) + config["dependencies"] = dependencies return config @@ -1143,6 +1174,9 @@ def __init__( self.root_path = os.environ.get("GRADIO_ROOT_PATH", "") self.proxy_urls = set() + self.pages: list[tuple[str, str]] = [("", "Home")] + self.current_page = "" + if self.analytics_enabled: is_custom_theme = not any( self.theme.to_dict() == built_in_theme.to_dict() @@ -1263,7 +1297,7 @@ def iterate_over_children(children_list): original_mapping[0] = root_block = Context.root_block or blocks if "layout" in config: - iterate_over_children(config["layout"]["children"]) + iterate_over_children(config["layout"].get("children", [])) first_dependency = None @@ -1427,6 +1461,8 @@ def render(self): "At least one block in this Blocks has already been rendered." ) + for block in self.blocks.values(): + block.page = Context.root_block.current_page root_context.blocks.update(self.blocks) dependency_offset = max(root_context.fns.keys(), default=-1) + 1 existing_api_names = [ @@ -1435,6 +1471,7 @@ def render(self): if isinstance(dep.api_name, str) ] for dependency in self.fns.values(): + dependency.page = Context.root_block.current_page dependency._id += dependency_offset # Any event -- e.g. Blocks.load() -- that is triggered by this Blocks # should now be triggered by the root Blocks instead. @@ -2179,6 +2216,8 @@ def get_config_file(self) -> BlocksConfigDict: "fill_width": self.fill_width, "theme_hash": self.theme_hash, "pwa": self.pwa, + "pages": self.pages, + "page": {}, } config.update(self.default_config.get_config()) # type: ignore config["connect_heartbeat"] = utils.connect_heartbeat( @@ -2213,6 +2252,7 @@ def __exit__(self, exc_type: type[BaseException] | None = None, *args): self.progress_tracking = any( block_fn.tracks_progress for block_fn in self.fns.values() ) + self.page = "" self.exited = True def clear(self): @@ -2261,7 +2301,6 @@ def queue( blocks=self, default_concurrency_limit=default_concurrency_limit, ) - self.config = self.get_config_file() self.app = App.create_app(self) return self @@ -3039,3 +3078,43 @@ def get_event_targets( event = getattr(block, event_name) target_events.append(event) return target_events + + @document() + def route(self, name: str, path: str | None = None) -> Blocks: + """ + Adds a new page to the Blocks app. + Parameters: + name: The name of the page as it appears in the nav bar. + path: The URL suffix appended after your Gradio app's root URL to access this page (e.g. if path="/test", the page may be accessible e.g. at http://localhost:7860/test). If not provided, the path is generated from the name by converting to lowercase and replacing spaces with hyphens. Any leading or trailing forward slashes are stripped. + Example: + with gr.Blocks() as demo: + name = gr.Textbox(label="Name") + ... + with demo.route("Test", "/test"): + num = gr.Number() + ... + """ + if get_blocks_context(): + raise ValueError( + "You cannot create a route while inside a Blocks() context. Call route() outside the Blocks() context (unindent this line)." + ) + + if path: + path = path.strip("/") + valid_path_regex = re.compile(r"^[a-zA-Z0-9-._~!$&'()*+,;=:@\[\]]+$") + if not valid_path_regex.match(path): + raise ValueError( + f"Path '{path}' contains invalid characters. Paths can only contain alphanumeric characters and the following special characters: -._~!$&'()*+,;=:@[]" + ) + if path in INTERNAL_ROUTES: + raise ValueError(f"Route with path '{path}' already exists") + if path is None: + path = name.lower().replace(" ", "-") + path = "".join( + [letter for letter in path if letter.isalnum() or letter == "-"] + ) + while path in INTERNAL_ROUTES or path in [page[0] for page in self.pages]: + path = "_" + path + self.pages.append((path, name)) + self.current_page = path + return self diff --git a/gradio/data_classes.py b/gradio/data_classes.py index c324be300190e..fa88cf61be7e2 100644 --- a/gradio/data_classes.py +++ b/gradio/data_classes.py @@ -352,7 +352,13 @@ class BodyCSS(TypedDict): class Layout(TypedDict): id: int - children: list[int | Layout] + children: NotRequired[list[int | Layout]] + + +class Page(TypedDict): + components: list[int] + dependencies: list[int] + layout: Layout class BlocksConfigDict(TypedDict): @@ -386,6 +392,9 @@ class BlocksConfigDict(TypedDict): username: NotRequired[str | None] api_prefix: str pwa: NotRequired[bool] + page: dict[str, Page] + pages: list[tuple[str, str]] + current_page: NotRequired[str] class MediaStreamChunk(TypedDict): diff --git a/gradio/renderable.py b/gradio/renderable.py index 47cb4729e7ad4..b84886a5ab90f 100644 --- a/gradio/renderable.py +++ b/gradio/renderable.py @@ -38,6 +38,7 @@ def __init__( self.fn = fn self.inputs = inputs self.triggers: list[EventListenerMethod] = [] + self.page = Context.root_block.current_page self.triggers = [EventListenerMethod(*t) for t in triggers] Context.root_block.default_config.set_event_trigger( @@ -68,6 +69,7 @@ def apply(self, *args, **kwargs): container_copy = self.ContainerClass(render=False, show_progress=True) container_copy._id = self.container_id + container_copy.page = self.page LocalContext.renderable.set(self) try: diff --git a/gradio/routes.py b/gradio/routes.py index 2c11548060838..5e3de34e7a3a9 100644 --- a/gradio/routes.py +++ b/gradio/routes.py @@ -365,15 +365,7 @@ async def conditional_routing_middleware( if ( getattr(blocks, "node_process", None) is not None and blocks.node_port is not None - and not path.startswith("/gradio_api") - and path not in ["/config", "/favicon.ico"] - and not path.startswith("/theme") - and not path.startswith("/svelte") - and not path.startswith("/static") - and not path.startswith("/login") - and not path.startswith("/logout") - and not path.startswith("/manifest.json") - and not path.startswith("/pwa_icon") + and not any(path.startswith(f"/{url}") for url in INTERNAL_ROUTES) ): if App.app_port is None: App.app_port = request.url.port or int( @@ -524,18 +516,50 @@ def _(path: str): ) ) + def attach_page(page): + @app.get(f"/{page}", response_class=HTMLResponse) + @app.get(f"/{page}/", response_class=HTMLResponse) + def page_route( + request: fastapi.Request, + user: str = Depends(get_current_user), + ): + return main(request, user, page) + + for pageset in blocks.pages: + page = pageset[0] + if page != "": + attach_page(page) + @app.head("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse) - def main(request: fastapi.Request, user: str = Depends(get_current_user)): + def main( + request: fastapi.Request, + user: str = Depends(get_current_user), + page: str = "", + ): mimetypes.add_type("application/javascript", ".js") blocks = app.get_blocks() root = route_utils.get_root_url( - request=request, route_path="/", root_path=app.root_path + request=request, + route_path=f"/{page}", + root_path=app.root_path, ) if (app.auth is None and app.auth_dependency is None) or user is not None: config = utils.safe_deepcopy(blocks.config) config = route_utils.update_root_in_config(config, root) config["username"] = user + config["components"] = [ + component + for component in config["components"] + if component["id"] in config["page"][page]["components"] + ] + config["dependencies"] = [ + dependency + for dependency in config.get("dependencies", []) + if dependency["id"] in config["page"][page]["dependencies"] + ] + config["layout"] = config["page"][page]["layout"] + config["current_page"] = page elif app.auth_dependency: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated" @@ -553,7 +577,7 @@ def main(request: fastapi.Request, user: str = Depends(get_current_user)): "frontend/share.html" if blocks.share else "frontend/index.html" ) gradio_api_info = api_info(request) - return templates.TemplateResponse( + resp = templates.TemplateResponse( request=request, name=template, context={ @@ -561,6 +585,7 @@ def main(request: fastapi.Request, user: str = Depends(get_current_user)): "gradio_api_info": gradio_api_info, }, ) + return resp except TemplateNotFound as err: if blocks.share: raise ValueError( @@ -1741,3 +1766,19 @@ async def new_lifespan(app: FastAPI): app.mount(path, gradio_app) return app + + +INTERNAL_ROUTES = [ + "theme.css", + "robots.txt", + "pwa_icon", + "manifest.json", + "login", + "logout", + "svelte", + "config", + "static", + "assets", + "favicon.ico", + "gradio_api", +] diff --git a/gradio/utils.py b/gradio/utils.py index e67d60be52c75..a1e47efe5ebb9 100644 --- a/gradio/utils.py +++ b/gradio/utils.py @@ -570,8 +570,8 @@ def same_children_recursive(children1, chidren2): raise ValueError( "The first config has a layout key, but the second does not" ) - children1 = config1["layout"]["children"] - children2 = config2["layout"]["children"] + children1 = config1["layout"].get("children", []) + children2 = config2["layout"].get("children", []) same_children_recursive(children1, children2) if "dependencies" in config1: diff --git a/guides/04_additional-features/09_multipage-apps.md b/guides/04_additional-features/09_multipage-apps.md new file mode 100644 index 0000000000000..bb1167743058a --- /dev/null +++ b/guides/04_additional-features/09_multipage-apps.md @@ -0,0 +1,85 @@ +# Multipage Apps + +Your Gradio app can support multiple pages with the `Blocks.route()` method. Here's what a multipage Gradio app generally looks like: + +```python +with gr.Blocks() as demo: + name = gr.Textbox(label="Name") + ... +with demo.route("Test", "/test"): + num = gr.Number() + ... + +demo.launch() +``` + +This allows you to define links to separate pages, each with a separate URL, which are linked to the top of the Gradio app in an automatically-generated navbar. + +Here's a complete example: + +$code_multipage + +All of these pages will share the same backend, including the same queue. + +Note: multipage apps do not support interactions between pages, e.g. an event listener on one page cannot output to a component on another page. Use `gr.Tabs()` for this type of functionality instead of pages. + +**Separate Files** + +For maintainability, you may want to write the code for different pages in different files. Because any Gradio Blocks can be imported and rendered inside another Blocks using the `.render()` method, you can do this as follows. + +Create one main file, say `app.py` and create separate Python files for each page: + +``` +- app.py +- main_page.py +- second_page.py +``` + +The Python file corresponding to each page should consist of a regular Gradio Blocks, Interface, or ChatInterface application, e.g. + +`main_page.py` + +```py +import gradio as gr + +with gr.Blocks() as demo: + gr.Image() + +if __name__ == "__main__": + demo.launch() +``` + +`second_page.py` + +```py +import gradio as gr + +with gr.Blocks() as demo: + t = gr.Textbox() + demo.load(lambda : "Loaded", None, t) + +if __name__ == "__main__": + demo.launch() +``` + +In your main `app.py` file, simply import the Gradio demos from the page files and `.render()` them: + +`app.py` + +```py +import gradio as gr + +import main_page, second_page + +with gr.Blocks() as demo: + main_page.demo.render() +with demo.route("Second Page"): + second_page.demo.render() + +if __name__ == "__main__": + demo.launch() +``` + +This allows you to run each page as an independent Gradio app for testing, while also creating a single file `app.py` that serves as the entrypoint for the complete multipage app. + + diff --git a/guides/04_additional-features/09_environment-variables.md b/guides/04_additional-features/10_environment-variables.md similarity index 100% rename from guides/04_additional-features/09_environment-variables.md rename to guides/04_additional-features/10_environment-variables.md diff --git a/guides/04_additional-features/10_resource-cleanup.md b/guides/04_additional-features/11_resource-cleanup.md similarity index 100% rename from guides/04_additional-features/10_resource-cleanup.md rename to guides/04_additional-features/11_resource-cleanup.md diff --git a/js/app/src/routes/+layout.server.ts b/js/app/src/routes/+layout.server.ts index a4e816fa45d65..129bd8d19c5c4 100644 --- a/js/app/src/routes/+layout.server.ts +++ b/js/app/src/routes/+layout.server.ts @@ -7,7 +7,4 @@ export function load({ url }): void { if (dev && url.pathname.startsWith("/theme")) { redirect(308, `http://127.0.0.1:7860${pathname}${search}`); } - if (url.pathname !== "/") { - redirect(308, "/"); - } } diff --git a/js/app/src/routes/[...catchall]/+page.svelte b/js/app/src/routes/[...catchall]/+page.svelte index 9f83f4a92d037..5382f44c631df 100644 --- a/js/app/src/routes/[...catchall]/+page.svelte +++ b/js/app/src/routes/[...catchall]/+page.svelte @@ -34,6 +34,16 @@ fill_width?: boolean; theme_hash?: number; username: string | null; + pages: [string, string][]; + current_page: string; + page: Record< + string, + { + components: number[]; + dependencies: number[]; + layout: any; + } + >; } let id = -1; @@ -297,53 +307,6 @@ onDestroy(() => { spaceheader?.remove(); }); - - // function handle_theme_mode(target: HTMLDivElement): "light" | "dark" { - // let new_theme_mode: ThemeMode; - - // const url = new URL(window.location.toString()); - // const url_color_mode: ThemeMode | null = url.searchParams.get( - // "__theme" - // ) as ThemeMode | null; - // new_theme_mode = theme_mode || url_color_mode || "system"; - - // if (new_theme_mode === "dark" || new_theme_mode === "light") { - // apply_theme(target, new_theme_mode); - // } else { - // new_theme_mode = sync_system_theme(target); - // } - // return new_theme_mode; - // } - - // function sync_system_theme(target: HTMLDivElement): "light" | "dark" { - // const theme = update_scheme(); - // window - // ?.matchMedia("(prefers-color-scheme: dark)") - // ?.addEventListener("change", update_scheme); - - // function update_scheme(): "light" | "dark" { - // let _theme: "light" | "dark" = window?.matchMedia?.( - // "(prefers-color-scheme: dark)" - // ).matches - // ? "dark" - // : "light"; - - // apply_theme(target, _theme); - // return _theme; - // } - // return theme; - // } - - // function apply_theme(target: HTMLDivElement, theme: "dark" | "light"): void { - // const dark_class_element = is_embed ? target.parentElement! : document.body; - // const bg_element = is_embed ? target : target.parentElement!; - // bg_element.style.background = "var(--body-background-fill)"; - // if (theme === "dark") { - // dark_class_element.classList.add("dark"); - // } else { - // dark_class_element.classList.remove("dark"); - // } - // } @@ -362,6 +325,9 @@ {version} {initial_height} {space} + pages={config.pages} + current_page={config.current_page} + root={config.root} loaded={loader_status === "complete"} fill_width={config?.fill_width || false} bind:wrapper diff --git a/js/app/src/routes/[...catchall]/+page.ts b/js/app/src/routes/[...catchall]/+page.ts index 4c65f4ce6817c..3862a8ea8aaf7 100644 --- a/js/app/src/routes/[...catchall]/+page.ts +++ b/js/app/src/routes/[...catchall]/+page.ts @@ -2,7 +2,11 @@ import { browser } from "$app/environment"; import { Client } from "@gradio/client"; -import { create_components } from "@gradio/core"; +import { + create_components, + type ComponentMeta, + type Dependency +} from "@gradio/core"; import { get } from "svelte/store"; import type { Config } from "@gradio/client"; @@ -21,7 +25,6 @@ export async function load({ }> { const api_url = browser && !local_dev_mode ? new URL(".", location.href).href : server; - // console.log("API URL", api_url, "-", location.href, "-"); const app = await Client.connect(api_url, { with_null_state: true, events: ["data", "log", "status", "render"] @@ -31,13 +34,15 @@ export async function load({ throw new Error("No config found"); } + let page_config = app.get_url_config(url); + const { create_layout, layout } = create_components(undefined); await create_layout({ app, - components: app.config.components, - dependencies: app.config.dependencies, - layout: app.config.layout, + components: page_config.components, + dependencies: page_config.dependencies, + layout: page_config.layout, root: app.config.root + app.config.api_prefix, options: { fill_height: app.config.fill_height @@ -48,7 +53,7 @@ export async function load({ return { Render: app.config?.auth_required ? Login : Blocks, - config: app.config, + config: page_config, api_url, layout: layouts, app diff --git a/js/core/src/Blocks.svelte b/js/core/src/Blocks.svelte index 4eb64e6f88835..8deb056f6af33 100644 --- a/js/core/src/Blocks.svelte +++ b/js/core/src/Blocks.svelte @@ -51,6 +51,7 @@ export let max_file_size: number | undefined = undefined; export let initial_layout: ComponentMeta | undefined = undefined; export let css: string | null | undefined = null; + let { layout: _layout, targets, @@ -71,6 +72,13 @@ ready = !!$_layout; } + let old_dependencies = dependencies; + $: if (dependencies !== old_dependencies && render_complete) { + // re-run load triggers in SSR mode when page changes + handle_load_triggers(); + old_dependencies = dependencies; + } + async function run(): Promise { await create_layout({ components, @@ -364,6 +372,7 @@ ); } catch (e) { const fn_index = 0; // Mock value for fn_index + if (!app.stream_status.open) return; // when a user navigates away in multipage app. messages = [ new_message("Error", String(e), fn_index, "error"), ...messages @@ -607,12 +616,7 @@ 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); - } - }); + handle_load_triggers(); if (!target || render_complete) return; @@ -665,6 +669,14 @@ render_complete = true; } + const handle_load_triggers = (): void => { + dependencies.forEach((dep) => { + if (dep.targets.some((dep) => dep[1] === "load")) { + wait_then_trigger_api_call(dep.id); + } + }); + }; + $: set_status($loading_status); function update_status( @@ -689,6 +701,10 @@ value: LoadingStatus; }[] = []; Object.entries(statuses).forEach(([id, loading_status]) => { + if (!app.stream_status.open && loading_status.status === "error") { + // when a user navigates away in multipage app. + return; + } let dependency = dependencies.find( (dep) => dep.id == loading_status.fn_index ); diff --git a/js/core/src/Embed.svelte b/js/core/src/Embed.svelte index 5104d805f1958..5b417371cfa06 100644 --- a/js/core/src/Embed.svelte +++ b/js/core/src/Embed.svelte @@ -11,11 +11,13 @@ export let display: boolean; export let info: boolean; export let loaded: boolean; + export let pages: [string, string][] = []; + export let current_page = ""; + export let root: string;
-
- -
- {#if display && space && info} -
- - {space} - - - {$_("common.built_with")} - Gradio. - - - {$_("common.hosted_on")} - Spaces - + {#if pages.length > 1} + {/if} +
+ +
+ {#if display && space && info} +
+ + {space} + + + {$_("common.built_with")} + Gradio. + + + {$_("common.hosted_on")} + Spaces + +
+ {/if} +
+
diff --git a/js/lite/src/ErrorDisplay.svelte b/js/lite/src/ErrorDisplay.svelte index a8ac1082e3534..9cf097882c2b7 100644 --- a/js/lite/src/ErrorDisplay.svelte +++ b/js/lite/src/ErrorDisplay.svelte @@ -30,6 +30,7 @@ space={null} fill_width={false} bind:wrapper + root="" > ; } let id = -1; @@ -95,6 +105,9 @@ export let info: boolean; export let eager: boolean; let stream: EventSource; + let pages: [string, string][] = []; + let current_page: string; + let root: string; // These utilities are exported to be injectable for the Wasm version. export let mount_css: typeof default_mount_css = default_mount_css; @@ -291,12 +304,15 @@ with_null_state: true, events: ["data", "log", "status", "render"] }); + window.addEventListener("beforeunload", () => { + app.close(); + }); if (!app.config) { throw new Error("Could not resolve app config"); } - config = app.config; + config = app.get_url_config(); window.__gradio_space__ = config.space_id; status = { @@ -313,6 +329,10 @@ dispatch("loaded"); + pages = config.pages; + current_page = config.current_page; + root = config.root; + if (config.dev_mode) { setTimeout(() => { const { host } = new URL(api_url); @@ -335,7 +355,7 @@ throw new Error("Could not resolve app config"); } - config = app.config; + config = app.get_url_config(); window.__gradio_space__ = config.space_id; await mount_custom_css(config.css); await add_custom_html_head(config.head); @@ -447,6 +467,9 @@ {space} loaded={loader_status === "complete"} fill_width={config?.fill_width || false} + {pages} + {current_page} + {root} bind:wrapper > {#if (loader_status === "pending" || loader_status === "error") && !(config && config?.auth_required)} diff --git a/js/spa/test/multipage.spec.ts b/js/spa/test/multipage.spec.ts new file mode 100644 index 0000000000000..9454542052f2f --- /dev/null +++ b/js/spa/test/multipage.spec.ts @@ -0,0 +1,27 @@ +import { test, expect, is_lite } from "@self/tootils"; + +test("Test multipage navigation and events", async ({ page }) => { + test.fixme(is_lite, "Lite doesn't support multipage gradio apps"); + + await page.getByLabel("Name").fill("asdf"); + await page.getByRole("button", { name: "Greet" }).click(); + await expect(page.getByLabel("Output")).toHaveValue("Hello asdf!"); + await expect(page.getByLabel("Textbox")).toHaveCount(4); + + await page.getByRole("link", { name: "Interface" }).click(); + await page.getByLabel("x").click(); + await page.getByLabel("x").fill("3"); + await page.getByLabel("y", { exact: true }).click(); + await page.getByLabel("y", { exact: true }).fill("4"); + await page.getByText("Submit").last().click(); + await expect(page.getByLabel("output").last()).toHaveValue("12"); + + await page.getByRole("link", { name: "Up" }).click(); + await expect(page.getByLabel("Number")).not.toHaveValue("0"); + await page.getByLabel("Number").click(); + await page.getByLabel("Number").fill("5"); + await page.getByRole("main").click(); + await page.getByRole("button", { name: "Increase" }).click(); + await expect(page.getByLabel("Number")).toHaveValue("6"); + await expect(page.getByLabel("Textbox")).toHaveCount(100); +});