gradio-app / gradio

Build and share delightful machine learning apps, all in Python. 🌟 Star to support our work!
http://www.gradio.app
Apache License 2.0
32.43k stars 2.43k forks source link

Declarative and Dynamic APIs for Gradio 4.0 #4689

Closed aliabid94 closed 4 months ago

aliabid94 commented 1 year ago

This issue is to discuss potential API designs of the Gradio 4.0 API, with the goals of:

Suggested API below will introduce:

Example 1: Add Numbers

Let's take a look at a simple demo that adds two numbers:

import gradio as gr

with gr.Blocks() as demo:
    a = gr.Number()
    b = gr.Number()
    btn = gr.Button("Add")
    result = gr.State()

    @gr.render
    def show_result(result: result):
        if result is None:
            gr.Markdown("Click the button to add two numbers.")
        else:
            gr.Number(value=result)

    @gr.on(btn.click)
    def add(a: a, b: b) -> result:
        return a + b

The function show_result will automatically be re-run on any changes to its inputs, which in this case is the result state variable. This state variable is bound to a function argument of the same name.

When a user clicks the add button, the function add is run, using gr.Numbers a and b as inputs, as implied by the type hint. The values of these components are bound to arguments of the same name. The returned value is set to the result State variable, as implied by the function return type hint. The change in result triggers a re-render of show_result.

Example 2: Todo List

Let's take a look at a Todo list, which involves dynamically generating an arbitrary number of components.

import gradio as gr

with gr.Blocks() as demo:
    todos_var = gr.State([])

    @gr.render
    def todo_list(todos: todos_var):
        for i, todo in enumerate(todos):
            with gr.Row():
                complete = gr.Checkbox(bind=(todos_var[i]["complete"]))
                item_name = gr.Textbox(bind=(todos_var[i]["name"]))
                delete_btn = gr.Button("Delete")

            @gr.on(delete_btn.click)
            def remove_element(todos: todos_var) -> todos_var:
                del todos[i]
                return todos

    add_btn = gr.Button("Add Item")
    @gr.on(add_btn.click)
    def add_item(todos: todos_var) -> todos_var:
        todos.append({"name": "", "complete": False})
        return todos

The todo list is rendered in todo_list by looping through the State variable todos_var, and generating a checkbox, textbox and delete button for each item. Initially todo_list is an empty list so there is nothing to render.

When a user clicks the add button, we add a new dictionary item to the todo state variable. That triggers a re-render of todo_list, which is listening to changes to that state variable.

Now that the list is not empty, we create a Row with a couple components. These components use the bind= keyword argument. The purpose of this argument is to ensure two-way binding - if todos_var is used as input or output to any event, we will first gather these values from (or set these values to) the frontend to make sure they are in sync.

The reason we use bind=todos_var[i]["key"] instead of bind=todos[i]["key"] or bind=todo["key"] is because we need a reference to the state variable to track, not just its value. Internally, we override gr.State __getitem__ method to pass references instead of the actual values at those indices. If you are using binding, you will need to use different names for the gr.State object and the corresponding function argument so you can have access to both (here, todos_var and todos).

A component can be bound to a gr.State, or a (potentially nested) index of a gr.State. When this is done, any function that uses that gr.State as input will gather the value of the component before running, and any function that uses that gr.State as output will update the component after running. The difference between bind= used in example 2 and value= used in example 1 is that value= is only one way flow - changes to gr.State are reflected in the gr.Number, but not vice versa.

Note that every item has a delete button with an event listener generated for each. When a user clicks the delete button, it calls the designated event listener created for each row. Under the hood, this is what happens step by step:

  1. Since remove_element has todos_var as an input, and there are components bound to todos_var that we have tracked from a previous render of todo_list, we gather the values of all these components from the frontend and store them into the backend. This is important because we are about to re-render the list, and if the user has made any changes to the list, they need to be "saved"
  2. We then remove the designated element from todos in remove_element
  3. Since remove_element outputs to todos_var, it triggers a re-render of todo_list
  4. We now go through the loop and re-render all the rows corresponding to each item.

Example 3: Multipage App

Below is an example of a multipage app:

import gradio as gr
from start_page import start_blocks
from about_page import about_blocks
from contact_page import contact_blocks

with gr.Blocks() as demo:
    page = gr.State("Start", url_param="page")
    expanded_view = gr.State(False, url_param="expanded")

    with gr.Tabs(bind=page):
      with gr.Tab("Start"):
        start_blocks.render()
      with gr.Tab("About"):
        about_blocks.render()
      with gr.Tab("Contact"):
        contact_blocks.render()

    expand = gr.Checkbox(label="Expand", bind=expanded_view)
    @gr.render
    def expansion(expand: expand):
      if expand:
        # more content here

In this example, we have a navigation panel via gr.Tabs, which is bound to gr.State page. The url_param="page" means that when the value of this state changes, a URL parameter called "page" updates to reflect to new value. It also means that when the app first loads, it checks if there is a URL parameter called "page" and if so, loads its value. This allows users to pass stateful deep links within the app. These gr.State's don't have to be related to navigation but are in this example. The checkbox for "Expanded View" is also deep linked via a URL param.

We organize the pages into different Blocks in separate files. We can import them into out main app here and call .render to render them within this gr.render call.

When a user clicks the "About" gr.Tab: 1) The bound gr.State page updates its value to "About". 2) The URL param "page" is updated to "About" 3) function render is called because input state var page has changed. 4) about_blocks is rendered.


This API suggestion is open to public suggestions. I believe everything in this API is possible with the default python interpreter (a hard requirement). As you make suggestions to simplify this API, keep in mind whether it is possible to implement with the default python interpreter.

abidlabs commented 1 year ago

Very cool @aliabid94! I like this declarative API quite a bit, particularly as it manages to use the built-in python control statements to re-render layouts

Some thoughts about the first part (gr.render):

  1. I do not think we should use gradio component objects as type hints. Any typing system will complain that we're using objects not classes. There's an existing issue that suggests using the relatively obscure Annotated class for this (#1728) but I find that syntax clunky so I think we should just avoid it altogether.

  2. Also, I feel it would be more natural for the gradio component objects to be passed into the gr.render() method. I.e. we are re-rendering if any of the arguments to render change. I don't know how we could easily link the objects to parameters in the function, however. If we just go with the order of the arguments, we would get this:

import gradio as gr

with gr.Blocks() as demo:
    a = gr.Number()
    b = gr.Number()
    btn = gr.Button("Add")
    result = gr.State()

    @gr.render(result)
    def show(out):
        if out is None:
            gr.Markdown("Click the button to add two numbers.")
        else:
            gr.Number(value=result)

Which is not great if the number of parameters grows but at least it won't throw type errors. Open to other ideas here

aliabid94 commented 1 year ago

I do not think we should use gradio component objects as type hints. Any typing system will complain that we're using objects not classes.

This may be fixed by adding a .t property to every object which refer to their expected type, e.g.

with gr.Blocks() as demo:
  num = gr.Number()
  text = gr.Textbox()

  gr.on(text.submit)
  def fn(num: num.t, text: text.t):
    ...

where num.t is typing.Annotated[float, (reference to self)] etc. Not sure if this would work but would make tracking function args and respectinve components much easier.

EDIT: Seems like this wouldn't work with a static type checker. We could do something like:


num1 = gr.Number()
num2 = gr.Number()
sum = gr.Number()

def add(a: gr.to(num1),  b: gr.to(num2)) -> gr.to(sum):
  return a + b

where gr.to is implemented as:

def to(obj: Any) -> Annotated[Any, Any]:
    return Annotated[Any, obj]
aliabid94 commented 1 year ago

Would love to hear your thoughts on this API design @akx @oobabooga @AUTOMATIC1111 since you work with complex Gradio UIs.

sanbuphy commented 1 year ago

Gradio is a very cool deep learning frontend component that I use frequently. Here are some of my suggestions.

  1. I found an inconvenience in Gradio regarding the usage of the user state component "state". I need to handle and call it correctly every time in the corresponding component's processing function in order to use it effectively. However, it's a bit troublesome because I have to pass it into the processing function each time. Is there a way to make it become a user variable within a certain scope, so that I can easily add, delete, retrieve, and modify it as a global variable? Just like local variables in JavaScript. Thank you very much.

  2. Maybe Gradio needs better abstraction for handling mobile events to support mobile interactions. With mobile events, we could explore more interesting possibilities. Additionally, due to the lack of examples regarding select and change events, people are often unaware of these cool features in Gradio. Having better demos might help users get started faster. If possible, I would like to provide demos in this aspect. Please provide me with the address where I can submit my PR.

Thank you once again to the developers of Gradio for their contributions.

deckar01 commented 1 year ago

Have you considered dependency injection? The only requirement is that the user has to register fields by name in some way. Variable names aren't as easily inspected as definitions (ala pytest fixtures). Something like gr.var.__setattr__() could be used to register input fields.

import gradio as gr
from helper import var, on

with gr.Blocks() as demo:
    var.a = gr.Number()
    var.b = gr.Number()
    btn = gr.Button("Add")
    result = gr.Number()

    @on(btn.click, outputs=result)
    def add(a: float, b: float) -> float:
        return a + b

demo.launch()
helper.py ```py import inspect from gradio.components import IOComponent from gradio.events import EventListenerMethod class FieldRegistry: fields: dict[str, IOComponent] def __init__(self): super().__setattr__('fields', {}) def __setattr__(self, name: str, value: IOComponent) -> None: super().__getattribute__('fields')[name] = value def __getattribute__(self, name: str) -> IOComponent: return super().__getattribute__('fields')[name] var = FieldRegistry() def on(event: EventListenerMethod, **kwargs): def wrapper(fn): sig = inspect.signature(fn) event_options = {} event_options['inputs'] = [ getattr(var, name, None) for name in sig.parameters ] event_options.update(kwargs) return event(fn, **event_options) return wrapper ```

A related API that has been on my mind is return dicts. I find it cumbersome in event handlers with many outputs to have to maintain a dict of updates and keep yielding redundant state information. Since the outputs are known in advance it would be nice if gradio allowed handlers to return/yield a subset of the outputs.

irgolic commented 1 year ago

If the render decorator will let us dynamically populate a list and create components from that (for example, populating a tabs container), that will be a massive buff.

The decorator reminds me of overriding a Qt paint event handler. It would be nice to have the ability to dynamically populate a container without it too.

What's the timeline for prototyping these concepts? Is there a beta branch we could build off? I'd love to try it out.

mMrBun commented 11 months ago

Example 4: Fill in the API configuration information.

In my scenario, I need to fill in the relevant API information on the page and use OpenAI Function Calling for tool invocation. However, I found that I cannot dynamically add group components when writing in the parameter field. image

loganrenaud commented 8 months ago

Is it at least possible to dynamically add components to the Gradio app during the app.load(function=...) function? I'm creating a web page with a list of textboxes, and I want users to be able to add additional textboxes to this list. Currently, I have the next textboxes hidden and initialized using gr.Blocks during deployment. It would be convenient to add hidden textboxes dynamically within the app.load() function, allowing me to define the number of hidden textboxes when the user connects (during app.load()), rather than at the time of deployment. And it wouldn't be really dynamic since it could only be added during the app.load() but it could help a lot

toughlucksmith commented 8 months ago

Hello everyone, regarding the improved session state: may I suggest adding a variable to the "fn" which is a Session ID. This should be a unique ID for the web browser session. Therefore, all chat calls will include a message, history and unique session ID. You could then store information about that session in a global variable, file, database, etc.

aliabid94 commented 7 months ago

Bringing this back, here's my latest iteration of the API. I think this is the most consistent with our existing API.

First example is a hangman game. In this example, we have a couple State Components that act as inputs to the render function. When these inputs get modifed, the function re-runs:

import gradio as gr

with gr.Blocks() as demo:
    secret_word = gr.State("huggingface")
    letters_guessed = gr.State([])
    new_letter = gr.Textbox(placeholder="Guess a letter")

    new_letter.submit(
        lambda letter, guessed: guessed + [letter], inputs=[new_letter, letters_guessed], outputs=letters_guessed
    )

    @gr.render(inputs=[secret_word, letters_guessed])
    def game(secret_word, letters_guessed):
        gr.Textbox(value="".join([letter if letter in letters_guessed else "_" for letter in secret_word]), label="Secret Word")
        gr.Textbox(value=", ".join(letters_guessed), label="Letters Guessed")
        if all([letter in letters_guessed for letter in secret_word]):
            gr.Textbox(value="You win!")

This is a basic example where the render is an "output" of several input components. If secret_word or letters_guessed change, the render code block runs again as a function of them.

Next let's take a look at a GIF maker demo, where users can create a dynamic number of input Image components.

import gradio as gr

with gr.Blocks() as demo:
    num_images = gr.Number(1, 10)

    @gr.render(inputs=[num_images], triggers=[num_images.submit])
    def gif_maker(num_images):
        for i in range(num_images):
            gr.Image(key=str(i), label=f"Image {i}")

    combine_btn = gr.Button("Combine Images")
    final_gif = gr.Image(label="Final GIF")

    def combine_images(num_images, gif_maker):
        imgs = []
        for i in range(num_images):
            imgs.append(gif_maker[str(i)])

        # combine imgs some way and return gif
        pass

    combine_btn.click(combine_images, inputs=[num_images, gif_maker], outputs=final_gif)

Note the key attribute to the Image components. This serves two purposes.

  1. On subsequent re-renders, the component will act as an update to the existing component with the same key, rather than a completely new component. This way, re-renders don't clear the value or any other changes a user may have made to this component on the frontend.
  2. The entire gif_maker render object acts as an input in combine_images. When submitted as input, it becomes a dictionary where the keys are key= attribute of the component, and the values and the values of those components.

There's also the triggers= argument in the render decorator. This defines what should trigger a re-render. If not specified (as it wasn't in the hangman example), it is triggered by any change to any input.


In the hangman example, we saw where the render object was:

In the gif_maker example, we saw where the render object was:

This next example is a music track mixer, we will demonstrate all possible interaction, where the render object is:

import gradio as gr

with gr.Blocks() as demo:
    track_count = gr.State(1)

    @gr.render(inputs=track_count)
    def music_mixer(track_count):
        with gr.Row():
            for i in range(track_count):
                with gr.Column():
                    gr.Audio(label=f"Track {i}", key=f"track-{i}")
                    volume = gr.Slider(0, 1, label="Volume", key=f"volume-{i}")
                    quieter_btn = gr.Button("Quieter")
                    solo_btn = gr.Button("Solo")

                    quieter_btn.click(lambda volume: volume * 0.8, inputs=volume, outputs=volume)

                    def solo_track(tracks):
                        for j, track in enumerate(tracks):
                            if i == j:
                                track[f"volume-{j}"] = 1
                            else:
                                track[f"volume-{j}"] = 0
                        return tracks
                    solo_btn.click(solo_track, inputs=music_mixer, outputs=music_mixer)

    final_mix = gr.Audio(label="Final Mix")

    with gr.Row():
        add_track_btn = gr.Button("Add Track")
        add_track_btn.click(lambda track_count: track_count + 1, inputs=track_count, outputs=track_count)

        mute_all_btn = gr.Button("Mute All")

        @mute_all_btn.click(inputs=[track_count, music_mixer], outputs=music_mixer)
        def mute_all(track_count, music_mixer):
            for i in range(track_count):
                music_mixer[f"volume-{i}"] = 0
            return music_mixer

        mix_btn = gr.Button("Mix")
        @mix_btn.click(inputs=music_mixer, outputs=final_mix)
        def mix(music_mixer):
            final_audio = ...
            for i, track in enumerate(music_mixer):
                final_audio += track[f"track-{i}"] * track[f"volume-{i}"]
            return final_audio
abidlabs commented 7 months ago

Very very cool @aliabid94! Huge fan of the key parameter and being able to reuse those components inside of other functions. I do find the syntax of passing in a function to the outputs parameter of an event a bit confusing. I'm also wondering whether this will always make sense -- what's the behavior if you run combine_images() before gif_maker() has run?

The alternative might be to save the generated components to a different dictionary (e.g. belonging to the parent Blocks object, for example, or one that is passed as a parameter to gr.render), again keyed by the key parameter.

A few minor points:

This is going to open up a lot of great use cases!

akx commented 7 months ago

In the above examples, inputs occasionally seems to be a list, sometimes not. I guess those are just typos?

In the mixer example, it's not really clear (at first read at least) why music_mixer would be enumerable (as solo_track seems to do?). Also, if you run the mixer example code through Ruff with (e.g.) the B series enabled, it already uncovers a subtle bug with inner functions and if i == j: 😉

I'm also not the hugest fan of having to repeat myself and having to be sure to retain the order of arguments correctly e.g. in

@gr.render(inputs=[secret_word, letters_guessed])
def game(secret_word, letters_guessed):
aliabid94 commented 5 months ago

Updated syntax, to not use keys in event listeners.

First example is a hangman game. In this example, we have a couple State Components that act as inputs to the render function. When these inputs get modifed, the function re-runs:

import gradio as gr

with gr.Blocks() as demo:
    secret_word = gr.State("huggingface")
    letters_guessed = gr.State([])
    new_letter = gr.Textbox(placeholder="Guess a letter")

    new_letter.submit(
        lambda letter, guessed: guessed + [letter], inputs=[new_letter, letters_guessed], outputs=letters_guessed
    )

    @gr.render(inputs=[secret_word, letters_guessed])
    def game(secret_word, letters_guessed):
        gr.Textbox(value="".join([letter if letter in letters_guessed else "_" for letter in secret_word]), label="Secret Word")
        gr.Textbox(value=", ".join(letters_guessed), label="Letters Guessed")
        if all([letter in letters_guessed for letter in secret_word]):
            gr.Textbox(value="You win!")

This is a basic example where the render is an "output" of several input components. If secret_word or letters_guessed change, the render code block runs again as a function of them.

Next let's take a look at a GIF maker demo, where users can create a dynamic number of input Image components.

import gradio as gr

with gr.Blocks() as demo:
    num_images = gr.Number(1, 10)

    @gr.render(inputs=[num_images], triggers=[num_images.submit])
    def gif_maker(num_images):
        imgs = []
        for i in range(num_images):
            img = gr.Image(key=str(i), label=f"Image {i}")
            imgs.append(img)

        def combine_images(*imgs):
            # combine imgs some way and return gif
            pass

        combine_btn.click(combine_images, inputs=[imgs], outputs=final_gif)

    combine_btn  = gr.Button("Combine Images")
    final_gif = gr.Image(label="Final GIF")

Note the key attribute to the Image components.
On subsequent re-renders, the component will act as an update to the existing component with the same key, rather than a completely new component. This way, re-renders don't clear the value or any other changes a user may have made to this component on the frontend.

There's also the triggers= argument in the render decorator. This defines what should trigger a re-render. If not specified (as it wasn't in the hangman example), it is triggered by any change to any input.


In the hangman example, we saw where the render object was:

In the gif_maker example, we saw where the render object was:

This next example is a music track mixer, we will demonstrate all possible interaction, where the render object is:

import gradio as gr

with gr.Blocks() as demo:
    track_count = gr.State(1)

    @gr.render(inputs=track_count)
    def music_mixer(track_count):
        with gr.Row():
            tracks = []
            volumes = []
            solo_btns = []
            for i in range(track_count):
                with gr.Column():
                    track = gr.Audio(label=f"Track {i}", key=f"track-{i}")
                    volume = gr.Slider(0, 1, label="Volume", key=f"volume-{i}")
                    quieter_btn = gr.Button("Quieter")
                    solo_btn = gr.Button("Solo")
                    tracks.append(track)
                    volumes.append(volume)
                    solo_btns.append(quieter_btn)

                    quieter_btn.click(lambda volume: volume * 0.8, inputs=volume, outputs=volume)

            for i in range(track_count):
                def solo_track():
                    output = [0] * track_count
                    output[i] = 1
                    return output
                solo_btn.click(solo_track, inputs=None, outputs=volumes)

            @mute_all_btn.click(inputs=None, outputs=volumes)
            def mute_all():
                return [0] * track_count

            @mix_btn.click(inputs=tracks + volumes, outputs=final_mix)
            def mix(*args):
                tracks = args[:track_count]
                volumes = args[track_count:]
                final_audio = ...
                for i in range(track_count):
                    final_audio += tracks[i] * volumes[i]
                return final_audio

    final_mix = gr.Audio(label="Final Mix")

    with gr.Row():
        add_track_btn = gr.Button("Add Track")
        add_track_btn.click(lambda track_count: track_count + 1, inputs=track_count, outputs=track_count)

        mute_all_btn = gr.Button("Mute All")
        mix_btn = gr.Button("Mix")
Leekunlock commented 5 months ago

Updated syntax, to not use keys in event listeners.

First example is a hangman game. In this example, we have a couple State Components that act as inputs to the render function. When these inputs get modifed, the function re-runs:

import gradio as gr

with gr.Blocks() as demo:
    secret_word = gr.State("huggingface")
    letters_guessed = gr.State([])
    new_letter = gr.Textbox(placeholder="Guess a letter")

    new_letter.submit(
        lambda letter, guessed: guessed + [letter], inputs=[new_letter, letters_guessed], outputs=letters_guessed
    )

    @gr.render(inputs=[secret_word, letters_guessed])
    def game(secret_word, letters_guessed):
        gr.Textbox(value="".join([letter if letter in letters_guessed else "_" for letter in secret_word]), label="Secret Word")
        gr.Textbox(value=", ".join(letters_guessed), label="Letters Guessed")
        if all([letter in letters_guessed for letter in secret_word]):
            gr.Textbox(value="You win!")

This is a basic example where the render is an "output" of several input components. If secret_word or letters_guessed change, the render code block runs again as a function of them.

Next let's take a look at a GIF maker demo, where users can create a dynamic number of input Image components.

import gradio as gr

with gr.Blocks() as demo:
    num_images = gr.Number(1, 10)

    @gr.render(inputs=[num_images], triggers=[num_images.submit])
    def gif_maker(num_images):
        imgs = []
        for i in range(num_images):
            img = gr.Image(key=str(i), label=f"Image {i}")
            imgs.append(img)

        def combine_images(imgs):
            # combine imgs some way and return gif
            pass

        combine_btn.click(combine_images, inputs=[imgs], outputs=final_gif)

    combine_btn  = gr.Button("Combine Images")
    final_gif = gr.Image(label="Final GIF")

Note the key attribute to the Image components. On subsequent re-renders, the component will act as an update to the existing component with the same key, rather than a completely new component. This way, re-renders don't clear the value or any other changes a user may have made to this component on the frontend.

There's also the triggers= argument in the render decorator. This defines what should trigger a re-render. If not specified (as it wasn't in the hangman example), it is triggered by any change to any input.

In the hangman example, we saw where the render object was:

  • the output of several input Components outside the render block (secret_word, letters_guessed) triggered by events outside the render block

In the gif_maker example, we saw where the render object was:

  • the output to an input Component outside the render block (num_images_state) triggered by events outside the render block
  • the input to an output Component outside the render block (final_gif) triggered by events outside the render block

This next example is a music track mixer, we will demonstrate all possible interaction, where the render object is:

  • the output to an input Component outside the render block, triggered by events outside the render block
  • the input to an output Component outside the render block, triggered by events outside the render block
  • the input to an output Component inside the render block, triggered by events inside the render block
  • the output to an input Component inside the render block, triggered by events inside the render block
import gradio as gr

with gr.Blocks() as demo:
    track_count = gr.State(1)

    @gr.render(inputs=track_count)
    def music_mixer(track_count):
        with gr.Row():
            tracks = []
            volumes = []
            solo_btns = []
            for i in range(track_count):
                with gr.Column():
                    track = gr.Audio(label=f"Track {i}", key=f"track-{i}")
                    volume = gr.Slider(0, 1, label="Volume", key=f"volume-{i}")
                    quieter_btn = gr.Button("Quieter")
                    solo_btn = gr.Button("Solo")
                    tracks.append(track)
                    volumes.append(volume)
                    solo_btns.append(quieter_btn)

                    quieter_btn.click(lambda volume: volume * 0.8, inputs=volume, outputs=volume)

            for i in range(track_count):
                def solo_track():
                    output = [0] * track_count
                    output[i] = 1
                    return output
                solo_btn.click(solo_track, inputs=None, outputs=volumes)

            @mute_all_btn.click(inputs=None, outputs=volumes)
            def mute_all():
                return [0] * track_count

            @mix_btn.click(inputs=tracks + volumes, outputs=final_mix)
            def mix(*args):
                tracks = args[:track_count]
                volumes = args[track_count:]
                final_audio = ...
                for i in range(track_count):
                    final_audio += tracks[i] * volumes[i]
                return final_audio

    final_mix = gr.Audio(label="Final Mix")

    with gr.Row():
        add_track_btn = gr.Button("Add Track")
        add_track_btn.click(lambda track_count: track_count + 1, inputs=track_count, outputs=track_count)

        mute_all_btn = gr.Button("Mute All")
        mix_btn = gr.Button("Mix")

I ran the codes above on my own PC, all of them have the error "AttributeError: module 'gradio' has no attribute 'render'". So does the function has changed? The Gradio version on my PC is 4.26.0. It would be better if you can provide the codes of Example 2 that can work with the Gradio Version 4.26.0. Thanks a lot.

Paillat-dev commented 5 months ago

@Leekunlock This code is not implemented yet. This is a pull request which means it is a proposal for a change to the project. It will probably be implemented in a further release.

Paillat-dev commented 4 months ago

yeeeeee