1. MCP
  2. 使用 Gradio 构建 ChatGPT 应用

使用 Gradio 和 Apps SDK 构建 ChatGPT 应用

ChatGPT 中的应用是一种很好的方式,让用户可以通过在熟悉的聊天应用中聊天来试用您的机器学习模型或其他类型的应用。OpenAI 已经发布了 Apps SDK 供开发者构建完整的应用,但您可以利用 Gradio MCP 服务器,非常快速地使用 Gradio 来构建 ChatGPT 应用。我们还将看到 Gradio 内置的分享链接如何使您的 ChatGPT 应用的迭代变得格外容易!

简介

构建 ChatGPT 应用需要完成两件事情:

  • 构建一个 Gradio MCP 服务器,并暴露至少一个工具。如果您还不熟悉如何构建 Gradio MCP 服务器,我们建议您先阅读本指南

  • 使用 HTML、JavaScript 和 CSS 构建一个自定义 UI,当您的工具被调用时将显示该 UI,并将其作为 MCP 资源暴露。

我们将在下面详细介绍这些步骤。

先决条件

  • 您需要在 ChatGPT 的“设置”→“应用和连接器”→“高级设置”中启用“开发者模式”。目前这需要付费的 ChatGPT 账户。
  • 您需要安装带有 mcp 附加组件的 gradio>=6.0
pip install --upgrade gradio[mcp]

现在,让我们通过两个例子来了解如何使用 Gradio 构建 ChatGPT 应用。

示例 1:字母计数器应用

第一个示例是一个 ChatGPT 应用,用于计算单词中字母的出现次数,并显示一张卡片,其中包含单词和高亮显示的指定字母,如下所示:

那么我们如何构建它呢?您可以在这里找到字母计数器应用的完整代码,也可以按照以下步骤操作:

  1. 首先编写您的 Python 函数。在我们的例子中,这个函数只是一个字母计数器:
def letter_counter(word: str, letter: str) -> int:
    """
    Count the number of letters in a word or phrase.

    Parameters:
        word (str): The word or phrase to count the letters of.
        letter (str): The letter to count the occurrences of.
    """
    return word.count(letter)
  1. 然后,用 Gradio UI 封装您的 Python 函数,大致如下所示:
with gr.Blocks() as demo:
    with gr.Row():
        with gr.Column():
            word = gr.Textbox(label="Word")
            letter = gr.Textbox(label="Letter")
            btn = gr.Button("Count Letters")
        with gr.Column():
            count = gr.Number(label="Count")

    btn.click(letter_counter, inputs=[word, letter], outputs=count)
  1. 现在,通过启用 MCP 服务器启动您的 Gradio 应用,即设置 mcp_server=True
    demo.launch(mcp_server=True)

早期指南所述,您现在可以使用任何 MCP 客户端(例如 MCP Inspector 工具)来测试该工具。进行测试并确认其行为符合您的预期。

  1. 为您的 ChatGPT 应用创建一个 UI 并将其作为资源暴露。这部分需要编写一些前端代码,一开始可能不太熟悉,但一些示例将帮助您创建一个适用于您的用例的应用。在我们的例子中,我们将使用 HTML、JavaScript 和 CSS 创建一张卡片。在卡片内,我们将显示用户提供的单词,并高亮显示指定字母的每次出现。请注意,我们使用 window.openai?.toolInput?.wordwindow.openai?.toolInput?.letter 来访问用户的工具输入。window.openai 对象由 ChatGPT 自动插入,其中包含来自用户工具调用的数据。这是完整的函数代码:
@gr.mcp.resource("ui://widget/app.html", mime_type="text/html+skybridge")
def app_html():
    visual = """
    <div id="letter-card-container"></div>
    <script>
        const container = document.getElementById('letter-card-container');

        function render() {
            const word = window.openai?.toolInput?.word || "strawberry";
            const letter = window.openai?.toolInput?.letter || "r";

            let letterHTML = '';
            for (let i = 0; i < word.length; i++) {
                const char = word[i];
                const color = char.toLowerCase() === letter.toLowerCase() ? '#b8860b' : '#000000';
                letterHTML += `<span style="color: ${color};">${char}</span>`;
            }

            container.innerHTML = `
                <div style="
                    background: linear-gradient(135deg, #f5f5dc 0%, #e8e4d0 100%);
                    background-image:
                        repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(139, 121, 94, 0.03) 2px, rgba(139, 121, 94, 0.03) 4px),
                        linear-gradient(135deg, #f5f5dc 0%, #e8e4d0 100%);
                    border-radius: 16px;
                    padding: 40px;
                    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
                    max-width: 600px;
                    margin: 20px auto;
                    font-family: 'Georgia', serif;
                    text-align: center;
                ">
                    <div style="
                        font-size: 48px;
                        font-weight: bold;
                        letter-spacing: 8px;
                        line-height: 1.5;
                    ">
                        ${letterHTML}
                    </div>
                </div>
            `;
        }
        render();
        window.addEventListener("openai:set_globals", (event) => {
            if (event.detail?.globals?.toolInput) {
                render();
            }
        }, { passive: true });
    </script>
    """
    return visual

请注意,我们为 gr.mcp.resource 提供了 URI ui://widget/app.html。这是任意的,但我们稍后需要使用相同的 URI。我们还需要将资源的 mimetype 指定为 mime_type="text/html+skybridge"。最后,请注意,我们在 JavaScript 中为 "openai:set_globals" 添加了一个事件监听器,这通常是一个好习惯,因为它允许小部件在触发新的工具调用时更新。

  1. 在 Gradio 应用中创建与资源函数对应的事件。这是必要的,因为您的 Gradio 应用只有在 MCP 工具、资源、提示等与 Gradio 事件相关联时才会接收到它们。通常,惯例是简单地在 gr.Code 组件中显示 MCP 资源的代码,例如这样:
    html = gr.Code(language="html", max_lines=20)
    
    # ... the rest of your Gradio app

    btn.click(app_html, outputs=html)
  1. 为您的 MCP 工具添加 _meta 属性。我们需要将我们创建的 MCP 工具与我们为应用创建的 UI 连接起来。我们可以通过将此装饰器添加到我们的 MCP 工具函数中来实现:
@gr.mcp.tool(
    _meta={
        "openai/outputTemplate": "ui://widget/app.html",
        "openai/resultCanProduceWidget": True,
        "openai/widgetAccessible": True,
    }
)

需要注意的关键是 "openai/outputTemplate" 必须与我们之前创建的 MCP 资源的 URI 匹配。

  1. 使用 share=True 重新启动您的 Gradio 应用。这将使在 ChatGPT 中进行测试变得非常容易。请注意打印到终端的 MCP 服务器 URL,例如 https://2e879c6066d729b11b.gradio.live/gradio_api/mcp/
    demo.launch(share=True, mcp_server=True)

这将打印一个公共 URL,您的 Gradio 应用将在此 URL 上运行。

  1. 现在,导航到 ChatGPT (https://chat.com/)。如前所述,您需要在 ChatGPT 的“设置”→“应用和连接器”→“高级设置”中启用“开发者模式”。然后,导航到“设置”→“应用和连接器”并点击“创建”按钮。为您的连接器命名,添加描述(可选),并粘贴打印到终端的 MCP 服务器 URL。选择“无身份验证”并创建。

就是这样!连接器创建完成后,您可以通过说“使用 @letter-counter 来计算 Gradio 中 r 的数量”之类的话来开始提示它。

示例 2:图像增亮器

接下来,让我们看一个更复杂的 ChatGPT 应用,用于图像增强。这个 ChatGPT 应用包含一个“增亮”按钮,允许用户直接从应用 UI 调用工具。

这是此应用的完整代码:

import gradio as gr
import tempfile
from PIL import Image
import numpy as np


@gr.mcp.tool(
    _meta={
        "openai/outputTemplate": "ui://widget/app.html",
        "openai/resultCanProduceWidget": True,
        "openai/widgetAccessible": True,
    }
)

def power_law_image(input_path: str, gamma: float = 0.5) -> str:
    """
    Applies a power-law (gamma) transformation to an image file and saves
    the result to a temporary file.

    Args:
        input_path (str): Path to the input image.
        gamma (float): Power-law exponent. <1 brightens, >1 darkens.

    Returns:
        str: Path to the saved temporary output image.
    """
    img = Image.open(input_path).convert("RGB")
    arr = np.array(img, dtype=np.float32) / 255.0
    arr = np.power(arr, gamma)
    arr = np.clip(arr * 255, 0, 255).astype(np.uint8)
    out_img = Image.fromarray(arr)

    tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    out_img.save(tmp_file.name)
    tmp_file.close()

    return tmp_file.name


@gr.mcp.resource("ui://widget/app.html", mime_type="text/html+skybridge")
def app_html():
    visual = """
    <style>
        #image-container {
            position: relative;
            display: inline-block;
            max-width: 100%;
        }
        #image-display {
            max-width: 100%;
            height: auto;
            display: block;
            border-radius: 8px;
        }
        #brighten-btn {
            position: absolute;
            bottom: 16px;
            right: 26px;
            padding: 12px 24px;
            background: #1a1a1a;
            color: white;
            border: none;
            border-radius: 8px;
            font-weight: 600;
            cursor: pointer;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
        }
        #brighten-btn:hover {
            background: #000000;
        }
    </style>
    <div id="image-container">
        <img id="image-display" alt="Processed image" />
        <button id="brighten-btn">Brighten</button>
    </div>
    <script>
        const imageEl = document.getElementById('image-display');
        const btnEl = document.getElementById('brighten-btn');

        function extractImageUrl(data) {
            if (data?.text?.startsWith('Image URL: ')) {
                return data.text.substring('Image URL: '.length).trim();
            }
            if (data?.content) {
                for (const item of data.content) {
                    if (item.type === 'text' && item.text?.startsWith('Image URL: ')) {
                        return item.text.substring('Image URL: '.length).trim();
                    }
                }
            }
        }

        function render() {
            const url = extractImageUrl(window.openai?.toolOutput);
            if (url) imageEl.src = url;
        }

        async function brightenImage() {
            btnEl.disabled = true;
            btnEl.textContent = 'Brightening...';
            const result = await window.openai.callTool('power_law_image', {
                input_path: imageEl.src
            });
            const newUrl = extractImageUrl(result);
            if (newUrl) imageEl.src = newUrl;
            btnEl.disabled = false;
            btnEl.textContent = 'Brighten';
        }

        btnEl.addEventListener('click', brightenImage);
        window.addEventListener("openai:set_globals", (event) => {
            if (event.detail?.globals?.toolOutput) render();
        }, { passive: true });

        render();
    </script>
    """
    return visual


with gr.Blocks() as demo:
    with gr.Row():
        with gr.Column():
            original_image = gr.Image(label="Original Image", type="filepath")
            btn = gr.Button("Brighten Image")
        with gr.Column():
            output_image = gr.Image(label="Output Image", type="filepath")
            html = gr.Code(language="html", max_lines=20)

    btn.click(power_law_image, inputs=original_image, outputs=original_image)
    btn.click(app_html, outputs=html)

if __name__ == "__main__":
    demo.launch(mcp_server=True, share=True)

我们不会像之前那样详细分解代码,因为许多部分是相同的。但请注意以下与上一个示例的不同之处:

  • 从小部件调用工具:应用使用 window.openai.callTool() 直接从按钮点击调用 MCP 工具,而无需 ChatGPT 调用它。
const result = await window.openai.callTool('power_law_image', {
    input_path: imageEl.src
});
  • 解析工具调用结果:来自 callTool() 的结果包含一个 content 数组,需要解析以提取数据。
function extractImageUrl(data) {
    if (data?.content) {
        for (const item of data.content) {
            if (item.type === 'text' && item.text?.startsWith('Image URL: ')) {
                return item.text.substring('Image URL: '.length).trim();
            }
        }
    }
}
  • 根据工具结果更新 UI:调用工具后,应用会立即使用新结果更新显示的图像。
const newUrl = extractImageUrl(result);
if (newUrl) imageEl.src = newUrl;

通过这些示例,您已经了解了如何构建简单的反应式小部件和更高级的交互式应用,这些应用可以直接从 UI 调用工具。通过将 Gradio 的 MCP 服务器功能与 OpenAI Apps SDK 相结合,是时候开始创建更丰富的 ChatGPT 集成,通过自定义可视化和用户交互来增强对话体验了!

gradio