Chào mừng bạn đến với hướng dẫn chuyên sâu này, nơi chúng ta sẽ khám phá cách xây dựng một ứng dụng ChatGPT tương tác mạnh mẽ. Sử dụng OpenAI Apps SDK và nền tảng phát triển nhanh Gadget, bạn có thể biến các ý tưởng ứng dụng AI của mình thành hiện thực, mang lại trải nghiệm người dùng vượt trội và khả năng tương tác chưa từng có.
Mục lục
Sức Mạnh Của OpenAI Apps SDK: Biến ChatGPT Thành Nền Tảng Ứng Dụng
OpenAI Apps SDK mở ra một kỷ nguyên mới cho các nhà phát triển, cho phép họ nâng tầm khả năng tương tác của ChatGPT. Không chỉ đơn thuần là một công cụ trò chuyện, ChatGPT giờ đây có thể gọi API, lưu trữ dữ liệu người dùng và hiển thị giao diện người dùng (UI) tùy chỉnh. Điều này tạo ra vô số cơ hội để xây dựng các ứng dụng đa dạng, từ công cụ năng suất đến giải trí.
Tổng Quan Dự Án: Ứng Dụng Phim Ảnh Tương Tác Với The Movie Database (TMDB)
Trong hướng dẫn này, chúng ta sẽ cùng nhau xây dựng một ứng dụng phim ảnh độc đáo. Ứng dụng này sẽ kết nối với **The Movie Database (TMDB)**, một nguồn dữ liệu phong phú về phim ảnh, để tìm kiếm và hiển thị thông tin về các bộ phim phổ biến và sắp ra mắt. Điểm đặc biệt là người dùng có thể tạo và quản lý **danh sách xem phim (watchlist)** của riêng mình ngay trong giao diện ChatGPT, mang lại trải nghiệm cá nhân hóa và tiện lợi.
Những Kỹ Năng Bạn Sẽ Nắm Vững Sau Hướng Dẫn Này
Hoàn thành dự án này, bạn sẽ trang bị cho mình những kiến thức và kỹ năng quan trọng trong việc phát triển ứng dụng tích hợp AI:
- Xây dựng và đăng ký các công cụ (tools) tùy chỉnh bằng cách sử dụng **Giao thức Ngữ cảnh Mô hình (Model Context Protocol – MCP)**.
- Tạo các widget frontend dựa trên React, có khả năng hiển thị trực tiếp bên trong ChatGPT, mang lại trải nghiệm UI phong phú.
- Thực hiện các lệnh gọi API đến backend của ứng dụng từ bên trong môi trường ChatGPT, đảm bảo luồng dữ liệu mượt mà và tương tác động.
Yêu Cầu Tiên Quyết Để Bắt Đầu
Để xây dựng các ứng dụng ChatGPT, bạn sẽ cần một gói ChatGPT trả phí và phải kích hoạt chế độ nhà phát triển.
Ngoài ra, bạn cũng cần có một tài khoản tại The Movie Database (TMDB) để tạo khóa API, phục vụ cho việc truy cập dữ liệu phim.
Cơ Chế Hoạt Động Đằng Sau Ứng Dụng ChatGPT
Các ứng dụng ChatGPT hoạt động trong môi trường **iframe** được nhúng bên trong ChatGPT. Chúng được xây dựng với OpenAI Apps SDK và giao tiếp với ChatGPT thông qua **MCP**. MCP là một giao thức cốt lõi để định nghĩa các **công cụ (tools)** – các hàm phía server mà ChatGPT có thể gọi, và **tài nguyên (resources)** – các widget UI.
Mỗi ứng dụng đều được thiết kế với các thành phần chính sau:
- Iframe Sandboxing: Mỗi ứng dụng chạy trong môi trường sandbox iframe ba lớp, giúp cách ly UI của bạn khỏi ChatGPT và bảo vệ dữ liệu người dùng.
- MCP – Cầu Nối Quan Trọng: MCP định nghĩa các công cụ và tài nguyên của ứng dụng, giúp ChatGPT biết được những chức năng nào có thể gọi và những widget nào cần hiển thị.
- Gọi Công Cụ (Tool Calls): Khi người dùng đưa ra yêu cầu mà ứng dụng của bạn có thể xử lý, ChatGPT sẽ gọi một công cụ đã đăng ký trên server MCP của bạn với đầu vào JSON có cấu trúc.
- Tài Nguyên và Widget: Các công cụ trả về nội dung có cấu trúc và trỏ đến một tài nguyên (ví dụ: một thành phần React) mà ChatGPT sẽ hiển thị nội tuyến trong cuộc trò chuyện.
- Môi Trường window.openai: Bên trong iframe đó, widget của bạn có thể đọc đầu ra của công cụ, duy trì trạng thái, gửi các tin nhắn tiếp theo hoặc thực hiện các lệnh gọi API đã xác thực.
Vai Trò Của Gadget Trong Dự Án Này
Dự án này sử dụng nền tảng **Gadget** để xử lý các tác vụ phức tạp như OAuth, mô hình cơ sở dữ liệu và thiết lập MCP. Điều này cho phép bạn tập trung hoàn toàn vào việc xây dựng logic và trải nghiệm của ứng dụng, thay vì phải lo lắng về cơ sở hạ tầng.
Bước 1: Khởi Tạo Dự Án Mới Với Gadget
Để bắt đầu, bạn hãy fork template ứng dụng ChatGPT của Gadget. Đặt tên cho dự án của bạn, ví dụ: `chatgpt-movies`. Template này đã bao gồm:
- Tính năng **OAuth** tích hợp.
- Một server MCP đã được cấu hình sẵn (`api/mcp.ts`).
- Thư mục widget dựa trên React (`web/chatgpt-widgets`).
- Thiết lập frontend **Vite** sử dụng `vite-plugin-chatgpt-widgets`.
Khi bạn xây dựng và chạy ứng dụng của mình trong Gadget, bạn sẽ có cả:
- Môi trường phát triển không giới hạn (ví dụ: `myapp–development.gadget.app`).
- Môi trường sản xuất (sẽ được triển khai sau).
Ứng dụng Gadget đi kèm với tất cả cơ sở hạ tầng cấp độ sản xuất cần thiết để xây dựng và chạy các ứng dụng ChatGPT. Mỗi ứng dụng Gadget đều được hỗ trợ bởi cơ sở dữ liệu Postgres với nhiều bản sao đọc, backend Node + Fastify và frontend React được Vite phục vụ và đóng gói, đồng thời bao gồm Elasticsearch tích hợp vào các API được tự động tạo và một hệ thống công việc nền (background jobs) tích hợp. Mọi thứ được kết nối với nhau để bạn có thể tập trung vào việc viết code để tạo ra ứng dụng ChatGPT độc đáo của mình.
Tìm Hiểu Về MCP Server
**MCP server** của bạn là nơi hiển thị các tool calls và frontend widgets cho ChatGPT.
Trong template, nó được định nghĩa trong `api/mcp.ts` và được tạo ra và gọi bằng cách sử dụng các tuyến HTTP `/mcp` đã được định nghĩa trước (`api/routes/mcp/*`). Một phiên bản MCP server mới được tạo và tất cả các widget React (từ thư mục `chatgpt-widgets`) được đăng ký làm **resources**.
Mỗi resource nhận được một **URI** (ví dụ: `ui://widget/HelloWorld.html`), và các công cụ tham chiếu các URI này trong định nghĩa meta của các template đầu ra của chúng:
_meta: {
"openai/outputTemplate": "ui://widget/HelloWorld.html",
}
Đây là cách một tool call biết nên render dữ liệu của nó trong widget nào.
Công cụ `__getGadgetAuthTokenV1` được bao gồm theo mặc định cho phép các widget thực hiện **yêu cầu API đã xác thực** trực tiếp bằng cách sử dụng client API của ứng dụng Gadget của bạn (`web/api.ts`), vì vậy bạn không cần định nghĩa một tool call mới cho mỗi yêu cầu bạn muốn thực hiện từ widget của mình.
Bước 2: Kết Nối Ứng Dụng Của Bạn Với ChatGPT
Bây giờ bạn có thể cài đặt ứng dụng của mình trong ChatGPT bằng cách thiết lập một kết nối mới.
- Sao chép URL phát triển trực tiếp của ứng dụng của bạn (ví dụ: `https://chatgpt-movies–development.gadget.app`).
- Trong ChatGPT, đi tới **Settings > Apps & Connectors > Create**.
- Đặt tên cho ứng dụng, ví dụ: “My Movies App.”
- Đặt **MCP endpoint** thành URL của ứng dụng Gadget của bạn (kết thúc bằng `/mcp`, ví dụ: `https://chatgpt-movies–development.gadget.app/mcp`).
- Hoàn tất quy trình OAuth. ChatGPT sẽ tự động xử lý xác thực.
Khi đã kết nối, bạn sẽ thấy hai hành động trong ChatGPT:
- `helloWorld`
- `__getGadgetAuthTokenV1`
Những hành động này đến từ MCP server của template và xác nhận rằng mọi thứ đang hoạt động.
Nếu bạn muốn kiểm tra ứng dụng của mình, bạn có thể bắt đầu một cuộc trò chuyện mới, thêm ứng dụng của mình làm ngữ cảnh và yêu cầu ChatGPT “Use my app to say hello”. Một widget “Hello, World” sẽ xuất hiện trong cuộc trò chuyện!
Mô hình người dùng trong ứng dụng của bạn (`api/models/user`) chứa bản ghi cho người dùng đã đăng nhập. Trong ứng dụng này, nó sẽ được sử dụng để liên kết danh sách xem phim với một người dùng, như một ví dụ về đa thuê (multi-tenancy) trong ứng dụng ChatGPT.
Bước 3: Thêm Mô Hình Dữ Liệu `watchlist`
Trước khi bạn bắt đầu sửa đổi MCP server của mình, bạn sẽ cần một nơi để lưu trữ ID của danh sách được tạo trong The Movie Database. Thay vì lưu trữ từng bộ phim và danh sách xem trong Gadget, bạn có thể sử dụng API `/list` của The Movie Database để tạo danh sách xem, và lưu trữ ID danh sách tham chiếu trong Gadget.
- Trong Gadget, tạo một mô hình mới có tên `watchlist`.
- Thêm trường `tmdbId` với kiểu `number`.
- Mô hình sẽ tự động có dấu thời gian, một ID, và được liên kết với mô hình `user`.
Vậy là đủ cho hiện tại. Gadget sẽ tự động tạo một API CRUD cho mô hình này (`create`, `read`, `update`, `delete`), mà bạn sẽ sử dụng sau này từ frontend của mình.
Bước 4: Thêm Hành Động `saveToList`
Bây giờ bạn sẽ tạo một hành động để thêm phim vào danh sách xem của người dùng. Một hành động toàn cục được sử dụng vì bạn cần upsert một ID danh sách xem từ The Movie Database – nếu một bản ghi danh sách xem không tồn tại cho người dùng này, hãy tạo một bản ghi trên The Movie Database, sau đó lưu ID vào Gadget. Nếu bản ghi danh sách xem đã tồn tại, chúng ta sẽ sử dụng nó trong các lệnh gọi tiếp theo của `saveToList` để thêm phim vào danh sách.
- Tạo một hành động mới `api/actions/saveToList.ts`.
- Dán đoạn mã sau:
// in api/actions/saveToList.ts
import { tmdbPost, type TMDBCreateListResponse } from "../tmdb";
export const run: ActionRun = async ({ params, logger, api, session }) => {
const { movieId } = params;
let watchlist = await api.watchlist.maybeFindFirst();
if (!watchlist) {
// create watchlist if it doesn't exist
const user = await api.user.findFirst();
const data = await tmdbPost<TMDBCreateListResponse>("/list", {
name: `This is ${user.firstName}'s custom ChatGPT watchlist.`,
description: "A list of movies added using ChatGPT.",
language: "en",
});
if (data) {
watchlist = await api.watchlist.create({
tmdbId: data.list_id,
user: {
_link: session!.get("user"),
},
});
}
}
const listId = watchlist?.tmdbId;
const response = await tmdbPost<{
status_code: number;
status_message: string;
}>(`/list/${listId}/add_item`, {
media_id: movieId,
});
if (response.status_code !== 12 && response.status_code !== 1) {
throw new Error(
`Failed to add movie to watchlist: ${response.status_message}`,
);
}
return { listId };
};
// define the incoming movie ID as a param
export const params = {
movieId: { type: "number" }
};
Vì các yêu cầu widget của bạn bao gồm một bearer token (nhờ công cụ `auth`), chúng ta có thể liên kết danh sách xem với tài khoản ChatGPT của người dùng, cho phép **đa thuê (multi-tenancy)**.
Bước 5: Kết Nối Với The Movie Database (TMDB)
Sau khi backend của bạn có thể lưu dữ liệu, hãy kết nối với API TMDB để lấy dữ liệu phim.
- Thêm biến môi trường `TMDB_API_KEY` vào Gadget (**Settings > Environment variables**) và dán khóa của bạn vào đó.
- Tạo một tệp trợ giúp tại `api/tmdb.ts`.
- Dán đoạn mã sau vào tệp trợ giúp:
// in api/tmdb.ts
// TMDB API configuration
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
export const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p";
export interface TMDBMovie {
id: number;
title: string;
original_title: string;
overview: string;
release_date: string;
poster_path: string | null;
backdrop_path: string | null;
vote_average: number;
vote_count: number;
popularity: number;
genre_ids: number[];
adult: boolean;
original_language: string;
}
export interface TMDBMovieDetails extends TMDBMovie {
runtime: number;
genres: Array<{ id: number; name: string }>;
budget: number;
revenue: number;
tagline: string;
status: string;
production_companies: Array<{
id: number;
name: string;
logo_path: string | null;
}>;
}
export interface TMDBResponse {
page: number;
results: TMDBMovie[];
total_pages: number;
total_results: number;
}
// Helper function to fetch from TMDB API
export async function tmdbFetch<T>(
endpoint: string,
params: Record<string, string> = {},
): Promise<T> {
const url = new URL(`${TMDB_BASE_URL}${endpoint}`);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${process.env.TMDB_API_KEY!}`,
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`TMDB API error: ${response.statusText}`);
}
return response.json();
}
export interface TMDBCreateListResponse {
status_message: string;
success: boolean;
status_code: number;
list_id: number;
}
export interface TMDBList {
created_by: string;
description: string;
favorite_count: number;
id: string;
items: TMDBMovie[];
item_count: number;
iso_639_1: string;
name: string;
poster_path: string;
total_results: number;
}
// Helper function to post to TMDB API
export async function tmdbPost<T>(
endpoint: string,
body: Record<string, any> = {},
): Promise<T> {
const url = new URL(`${TMDB_BASE_URL}${endpoint}`);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TMDB_API_KEY!}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`TMDB API error: ${response.statusText}`);
}
return response.json();
}
// Helper to format movie data for display
export function formatMovieInfo(movie: TMDBMovie | TMDBMovieDetails): string {
const posterUrl = movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: "No poster available";
let info = `**${movie.title}** (${movie.release_date?.split("-")[0] || "N/A"})\n`;
info += `Rating: ${movie.vote_average.toFixed(1)}/10 (${movie.vote_count} votes)\n`;
info += `Overview: ${movie.overview}\n`;
info += `Poster: ${posterUrl}\n`;
if ("runtime" in movie && movie.runtime) {
info += `Runtime: ${movie.runtime} minutes\n`;
}
if ("genres" in movie && movie.genres) {
info += `Genres: ${movie.genres.map((g) => g.name).join(", ")}\n`;
}
return info;
}
Tệp trợ giúp này giúp chúng ta dễ dàng gọi các điểm cuối của TMDB và định nghĩa một số kiểu dữ liệu cho ứng dụng của chúng ta.
Bước 6: Đăng Ký Các Công Cụ MCP Cho Các Điểm Cuối Phim
Đã đến lúc sửa đổi MCP server của bạn. Mở `mcp.ts` một lần nữa và bắt đầu đăng ký các công cụ cho các điểm cuối TMDB khác nhau.
- Dán đoạn mã sau vào `api/mcp.ts`:
// in api/mcp.ts
import { getWidgets } from "vite-plugin-chatgpt-widgets";
import { FastifyRequest } from "fastify";
import type { Server } from "gadget-server";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import path from "path";
import {
tmdbFetch,
tmdbPost,
formatMovieInfo,
TMDB_IMAGE_BASE_URL,
type TMDBResponse,
type TMDBMovieDetails,
type TMDBCreateListResponse,
TMDBList,
} from "./tmdb";
export const createMCPServer = async (request: FastifyRequest) => {
const mcpServer = new McpServer({
name: "movie-demo",
version: "1.0.0",
});
const api = request.api.actAsSession;
// helper functions to fetch watchlist
const getMyWatchlist = async (request: FastifyRequest) => {
const watchlist = await api.watchlist.maybeFindFirst();
if (!watchlist) {
return;
}
const data = await tmdbFetch<TMDBList>(`/list/${watchlist.tmdbId}`);
const output = {
movies: data.items.map((movie) => ({
id: movie.id,
title: movie.title,
release_date: movie.release_date,
rating: movie.vote_average,
overview: movie.overview,
poster_url: movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: null,
in_watchlist: true,
})),
total_results: data.total_results,
};
return output;
};
const getMyWatchlistMovieIds = async (request: FastifyRequest) => {
const watchlist = await getMyWatchlist(request);
const ids = new Set(watchlist?.movies.map((m) => m.id) || []);
return ids;
};
// Tool 1: Get now playing movies (recently released in theaters)
mcpServer.registerTool(
"getNowPlayingMovies",
{
title: "Get Now Playing Movies",
description:
"Get a list of movies currently playing in theaters. These are recently released movies that are available to watch right now.",
// give an input schema to the ChatGPT LLM, allowing it to invoke this tool with different parameters as needed
inputSchema: {
page: z
.number()
.optional()
.describe("Page number for pagination (default: 1)"),
region: z
.string()
.optional()
.describe(
"ISO 3166-1 code to filter release dates (e.g., 'US', 'GB')",
),
},
annotations: {
readOnlyHint: true,
},
_meta: {
// render the MovieList React widget in the ChatGPT UI when this tool is invoked
"openai/outputTemplate": "ui://widget/MovieList.html",
"openai/toolInvocation/invoking":
"Getting movies currently playing in theaters",
"openai/toolInvocation/invoked": "Found currently playing movies",
},
},
async ({ page = 1, region = "US" }) => {
const data = await tmdbFetch<TMDBResponse>("/movie/now_playing?sort_by=popularity.desc", {
page: page.toString(),
region,
});
const myWatchlistIds = await getMyWatchlistMovieIds(request);
const output = {
movies: data.results.map((movie) => ({
id: movie.id,
title: movie.title,
release_date: movie.release_date,
rating: movie.vote_average,
overview: movie.overview,
poster_url: movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: null,
in_watchlist: myWatchlistIds.has(movie.id),
})),
total_results: data.total_results,
};
return {
content: [
{
type: "text",
text: `Found ${data.total_results} movies now playing in theaters`,
},
],
structuredContent: output,
};
},
);
// Tool 2: Search for movies
mcpServer.registerTool(
"searchMovies",
{
title: "Search Movies",
description: "Search for movies by title or keywords",
inputSchema: {
query: z.string().describe("Search query (movie title or keywords)"),
page: z
.number()
.optional()
.describe("Page number for pagination (default: 1)"),
year: z.number().optional().describe("Filter by release year"),
},
annotations: {
readOnlyHint: true,
},
_meta: {
"openai/toolInvocation/invoking": "Searching for movies",
"openai/toolInvocation/invoked": "Found matching movies",
"openai/outputTemplate": "ui://widget/MovieList.html",
},
},
async ({ query, page = 1, year }) => {
const params: Record<string, string> = {
query,
page: page.toString(),
};
if (year) {
params.year = year.toString();
}
const data = await tmdbFetch<TMDBResponse>("/search/movie", params);
const output = {
movies: data.results.map((movie) => ({
id: movie.id,
title: movie.title,
release_date: movie.release_date,
rating: movie.vote_average,
overview: movie.overview,
poster_url: movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: null,
})),
total_results: data.total_results,
};
return {
content: [
{
type: "text",
text: `Found ${data.total_results} movies matching "${query}"`,
},
],
structuredContent: output,
};
},
);
// Tool 3: Get popular movies
mcpServer.registerTool(
"getPopularMovies",
{
title: "Get Popular Movies",
description: "Get a list of popular movies, ordered by popularity",
inputSchema: {
page: z
.number()
.optional()
.describe("Page number for pagination (default: 1)"),
},
annotations: {
readOnlyHint: true,
},
_meta: {
"openai/toolInvocation/invoking": "Getting popular movies",
"openai/toolInvocation/invoked": "Found popular movies",
"openai/outputTemplate": "ui://widget/MovieList.html",
},
},
async ({ page = 1 }) => {
const data = await tmdbFetch<TMDBResponse>("/movie/popular?sort_by=popularity.desc", {
page: page.toString(),
});
const myWatchlistIds = await getMyWatchlistMovieIds(request);
const output = {
movies: data.results.map((movie) => ({
id: movie.id,
title: movie.title,
release_date: movie.release_date,
rating: movie.vote_average,
overview: movie.overview,
poster_url: movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: null,
in_watchlist: myWatchlistIds.has(movie.id),
})),
total_results: data.total_results,
};
return {
content: [
{
type: "text",
text: `Found ${data.total_results} popular movies`,
},
],
structuredContent: output,
};
},
);
// Tool 4: Get movie details
mcpServer.registerTool(
"getMovieDetails",
{
title: "Get Movie Details",
description: "Get detailed information about a specific movie by its ID",
inputSchema: {
movieId: z.number().describe("The TMDB movie ID"),
},
annotations: {
readOnlyHint: true,
},
_meta: {
"openai/toolInvocation/invoking": "Getting movie details",
"openai/toolInvocation/invoked": "Retrieved movie details",
"openai/outputTemplate": "ui://widget/MovieList.html",
},
},
async ({ movieId }) => {
const movie = await tmdbFetch<TMDBMovieDetails>(`/movie/${movieId}`);
let details = formatMovieInfo(movie);
details += `Tagline: ${movie.tagline || "N/A"}\n`;
details += `Status: ${movie.status}\n`;
details += `Budget: $${movie.budget.toLocaleString()}\n`;
details += `Revenue: $${movie.revenue.toLocaleString()}\n`;
if (movie.production_companies.length > 0) {
details += `Production: ${movie.production_companies.map((c) => c.name).join(", ")}\n`;
}
const output = {
id: movie.id,
title: movie.title,
release_date: movie.release_date,
rating: movie.vote_average,
overview: movie.overview,
runtime: movie.runtime,
genres: movie.genres,
tagline: movie.tagline,
budget: movie.budget,
revenue: movie.revenue,
poster_url: movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: null,
backdrop_url: movie.backdrop_path
? `${TMDB_IMAGE_BASE_URL}/original${movie.backdrop_path}`
: null,
};
return {
content: [{ type: "text", text: details }],
structuredContent: output,
};
},
);
// Tool 5: Get upcoming movies
mcpServer.registerTool(
"getUpcomingMovies",
{
title: "Get Upcoming Movies",
description: "Get a list of upcoming movies that will be released soon",
inputSchema: {
page: z
.number()
.optional()
.describe("Page number for pagination (default: 1)"),
region: z
.string()
.optional()
.describe(
"ISO 3166-1 code to filter release dates (e.g., 'US', 'GB')",
),
},
annotations: {
readOnlyHint: true,
},
_meta: {
"openai/toolInvocation/invoking": "Getting upcoming movies",
"openai/toolInvocation/invoked": "Found upcoming movies",
"openai/outputTemplate": "ui://widget/MovieList.html",
},
},
async ({ page = 1, region = "US" }) => {
const data = await tmdbFetch<TMDBResponse>("/movie/upcoming?sort_by=popularity.desc", {
page: page.toString(),
region,
});
const myWatchlistIds = await getMyWatchlistMovieIds(request);
const output = {
movies: data.results.map((movie) => ({
id: movie.id,
title: movie.title,
release_date: movie.release_date,
rating: movie.vote_average,
overview: movie.overview,
poster_url: movie.poster_path
? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
: null,
in_watchlist: myWatchlistIds.has(movie.id),
})),
total_results: data.total_results,
};
return {
content: [
{
type: "text",
text: `Found ${data.total_results} upcoming movies`,
},
],
structuredContent: output,
};
},
);
// Tool 6: Get watchlist
mcpServer.registerTool(
"getMyWatchlist",
{
title: "Get Movies Saved To My Watchlist",
description:
"Get a list of movies that the user has saved to their watchlist",
annotations: {
readOnlyHint: true,
},
_meta: {
"openai/toolInvocation/invoking": "Getting movies on your watchlist",
"openai/toolInvocation/invoked": "Found your watchlist of movies",
"openai/outputTemplate": "ui://widget/MovieList.html",
},
},
async () => {
const output = await getMyWatchlist(request);
if (!output) {
return {
content: [
{
type: "text",
text: `You don't have a watchlist yet.`,
},
],
structuredContent: {},
};
}
return {
content: [
{
type: "text",
text: `Found ${output.total_results} movies on your watchlist`,
},
],
structuredContent: output,
};
},
);
const devServer = await (
request.server as any
).frontendServerManager?.devServerManager?.getServer();
const viteHandle =
devServer && process.env.NODE_ENV != "production"
? { devServer }
: {
manifestPath: path.resolve(
process.cwd(),
".gadget/remix-dist/build/client/.vite/manifest.json",
),
};
// Pass the Vite dev server instance from wherever you can get it
const widgets = await getWidgets("web/chatgpt-widgets", viteHandle);
// Register each widget on an MCP server as a resource for exposure to ChatGPT
for (const widget of widgets) {
const resourceName = `widget-${widget.name.toLowerCase()}`;
const resourceUri = `ui://widget/${widget.name}.html`;
mcpServer.registerResource(
resourceName,
resourceUri,
{
title: widget.name,
description: `ChatGPT widget for ${widget.name}`,
},
async () => {
return {
contents: [
{
uri: resourceUri,
mimeType: "text/html+skybridge",
text: widget.content,
},
],
};
},
);
}
mcpServer.registerTool(
"__getGadgetAuthTokenV1",
{
title: "Get the gadget auth token",
description:
"Gets the gadget auth token. Should never be called by LLMs or ChatGPT -- only used for internal auth machinery.",
_meta: {
// ensure widgets can invoke this tool to get the token
"openai/widgetAccessible": true,
},
},
async () => {
if (!request.headers["authorization"]) {
return {
structuredContent: {
token: null,
error: "no token found",
},
content: [],
};
}
const [scheme, token] = request.headers["authorization"].split(" ", 2);
if (scheme !== "Bearer") {
return {
structuredContent: {
token: null,
error: "incorrect token scheme",
},
content: [],
};
}
return {
structuredContent: {
token,
scheme,
},
content: [],
};
}
);
return mcpServer;
};
Mỗi công cụ cần:
- Sử dụng **Zod** để định nghĩa schema đầu vào (cho phân trang, khu vực, v.v.).
- Lấy dữ liệu bằng helper TMDB của bạn.
- Định dạng kết quả thành **nội dung có cấu trúc**.
- Đăng ký một URI đầu ra widget (`MovieList.html`).
`structuredContent` này sẽ được truyền vào React widget thông qua `window.openai.toolOutput` bên trong iframe của ChatGPT.
Bước 7: Xây Dựng Widget React
Bạn có nhận thấy rằng không có tool calls nào để thêm phim vào danh sách xem không? Chúng ta có thể sử dụng trực tiếp Gadget API trong widget của mình (bên trong môi trường iframe của ChatGPT) nhờ tool call `__getGadgetAuthTokenV1` và `Provider` trong `web/chatgpt-widgets/root.tsx`.
`Provider` thực hiện một tool call duy nhất đến `__getGadgetAuthTokenV1` và thêm token OAuth làm `Authorization: Bearer
Kho lưu trữ mẫu OpenAI Apps bao gồm các hook có thể tái sử dụng để truy cập `toolOutput` và `widgetState`, đã được bao gồm trong template gốc mà bạn đã fork. `toolOutput` là cách bạn truyền danh sách phim từ backend vào widget. `widgetState` cho phép bạn duy trì trạng thái hiện tại của frontend. Nếu người dùng làm mới tab trình duyệt hoặc rời đi và quay lại cuộc trò chuyện, ứng dụng sẽ ở trạng thái tương tự như trước.
- Tạo một tệp mới trong thư mục `web/chatgpt-widgets` của bạn có tên `MovieList.tsx`.
- Dán đoạn mã sau vào `MovieList.tsx`:
// in web/chatgpt-widgets/MovieList.tsx
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../components/ui/tabs";
import {
Star,
Calendar,
DollarSign,
Clock,
Film,
Bookmark,
} from "lucide-react";
import { useWidgetProps, useWidgetState } from "./utils/hooks";
import { UnknownObject } from "./utils/types";
import { useEffect, useState } from "react";
import { api } from "../api";
interface Movie {
id: number;
title: string;
release_date?: string;
rating?: number;
overview?: string;
poster_url?: string | null;
backdrop_url?: string | null;
runtime?: number;
genres?: Array<{ id: number; name: string; }>;
tagline?: string;
budget?: number;
revenue?: number;
in_watchlist?: boolean;
}
interface ToolOutput extends UnknownObject {
movies?: Movie[];
id?: number;
title?: string;
release_date?: string;
rating?: number;
overview?: string;
poster_url?: string | null;
backdrop_url?: string | null;
runtime?: number;
genres?: Array<{ id: number; name: string; }>;
tagline?: string;
budget?: number;
revenue?: number;
total_results?: number;
}
const MovieCard = ({
movie,
onWatchlistAdd,
}: {
movie: Movie;
onWatchlistAdd?: (movieId: number) => void;
}) => {
return (
<Card className="group overflow-hidden hover:shadow-2xl transition-all duration-300 border-0 bg-white hover:scale-[1.02]">
<div className="relative">
{/* Poster Image */}
{movie.poster_url ? (
<div className="relative h-96 bg-gray-100 overflow-hidden">
<img
src={movie.poster_url}
alt={movie.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-90" />
{/* Rating Badge - Positioned on poster */}
{movie.rating && (
<div className="absolute top-4 right-4 bg-black/80 backdrop-blur-sm rounded-full px-3 py-1.5 flex items-center gap-1.5 border border-yellow-400/30">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="text-white font-bold text-sm">
{movie.rating.toFixed(1)}
</span>
</div>
)}
{/* Watchlist Button */}
<button
className="absolute top-4 left-4 bg-black/80 backdrop-blur-sm rounded-full p-2.5 hover:bg-black/90 transition-colors border border-white/30 hover:border-white/50"
onClick={async (e) => {
e.stopPropagation();
await api.saveToList({ movieId: movie.id });
onWatchlistAdd?.(movie.id);
}}
aria-label="Add to watchlist"
disabled={movie.in_watchlist}
>
<Bookmark
className={`w-5 h-5 text-white ${movie.in_watchlist ? "fill-white" : ""}`}
/>
</button>
{/* Content Overlay at Bottom */}
<div className="absolute bottom-0 left-0 right-0 p-5 text-white">
<h3 className="text-xl font-bold leading-tight mb-2 line-clamp-2 drop-shadow-lg">
{movie.title}
</h3>
{movie.release_date && (
<div className="flex items-center gap-1.5 text-sm text-gray-200 mb-3">
<Calendar className="w-4 h-4" />
<span>
{new Date(movie.release_date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
</div>
)}
{movie.overview && (
<p className="text-sm text-gray-200 line-clamp-3 leading-relaxed">
{movie.overview}
</p>
)}
{/* Genres */}
{movie.genres && movie.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{movie.genres.slice(0, 3).map((genre) => (
<Badge
key={genre.id}
variant="secondary"
className="text-xs bg-white/20 hover:bg-white/30 backdrop-blur-sm border-white/30 text-white"
>
{genre.name}
</Badge>
))}
</div>
)}
</div>
</div>
) : (
<div className="h-96 bg-gradient-to-br from-purple-500 via-purple-600 to-indigo-700 flex flex-col items-center justify-center relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10">
<div
className="absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle, white 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
</div>
<Film className="w-24 h-24 text-white opacity-40 mb-4" />
<div className="absolute bottom-0 left-0 right-0 p-5 text-white">
<h3 className="text-xl font-bold leading-tight mb-2">
{movie.title}
</h3>
{movie.release_date && (
<div className="flex items-center gap-1.5 text-sm text-gray-200 mb-3">
<Calendar className="w-4 h-4" />
<span>
{new Date(movie.release_date).toLocaleDateString()}
</span>
</div>
)}
{movie.overview && (
<p className="text-sm text-gray-200 line-clamp-3">
{movie.overview}
</p>
)}
</div>
{movie.rating && (
<div className="absolute top-4 right-4 bg-black/50 backdrop-blur-sm rounded-full px-3 py-1.5 flex items-center gap-1.5">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="text-white font-bold text-sm">
{movie.rating.toFixed(1)}
</span>
</div>
)}
{/* Watchlist Button */}
<button
className="absolute top-4 left-4 bg-black/50 backdrop-blur-sm rounded-full p-2.5 hover:bg-black/70 transition-colors border border-white/30 hover:border-white/50"
onClick={async (e) => {
e.stopPropagation();
await api.saveToList({ movieId: movie.id });
onWatchlistAdd?.(movie.id);
}}
aria-label="Add to watchlist"
disabled={movie.in_watchlist}
>
<Bookmark
className={`w-5 h-5 text-white ${movie.in_watchlist ? "fill-white" : ""}`}
/>
</button>
</div>
)}
</div>
</Card>
);
};
const MovieDetailView = ({ movie }: { movie: Movie; }) => {
return (
<div className="space-y-6">
{/* Hero Section with Backdrop */}
{movie.backdrop_url && (
<div className="relative h-64 rounded-lg overflow-hidden">
<img
src={movie.backdrop_url}
alt={movie.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
<div className="absolute bottom-4 left-4 right-4">
<h1 className="text-3xl font-bold text-white mb-2">
{movie.title}
</h1>
{movie.tagline && (
<p className="text-gray-200 italic">{movie.tagline}</p>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Poster and Quick Stats */}
<div className="space-y-4">
{movie.poster_url ? (
<Card className="overflow-hidden">
<img
src={movie.poster_url}
alt={movie.title}
className="w-full h-auto"
/>
</Card>
) : (
<Card className="aspect-[2/3] bg-gradient-to-br from-purple-400 to-indigo-600 flex items-center justify-center">
<Film className="w-24 h-24 text-white opacity-50" />
</Card>
)}
{/* Quick Stats */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Quick Stats</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{movie.rating && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 flex items-center gap-1">
<Star className="w-4 h-4" />
Rating
</span>
<Badge
variant="secondary"
className="flex items-center gap-1"
>
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
{movie.rating.toFixed(1)}
</Badge>
</div>
)}
{movie.release_date && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 flex items-center gap-1">
<Calendar className="w-4 h-4" />
Release Date
</span>
<span className="text-sm font-medium">
{new Date(movie.release_date).toLocaleDateString()}
</span>
</div>
)}
{movie.runtime && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 flex items-center gap-1">
<Clock className="w-4 h-4" />
Runtime
</span>
<span className="text-sm font-medium">
{movie.runtime} min
</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* Main Content */}
<div className="md:col-span-2 space-y-4">
{!movie.backdrop_url && (
<div>
<h1 className="text-3xl font-bold mb-2">{movie.title}</h1>
{movie.tagline && (
<p className="text-gray-600 italic mb-4">{movie.tagline}</p>
)}
</div>
)}
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
{(movie.budget || movie.revenue) && (
<TabsTrigger value="financials">Financials</TabsTrigger>
)}
{movie.genres && movie.genres.length > 0 && (
<TabsTrigger value="details">Details</TabsTrigger>
)}
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Synopsis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 leading-relaxed">
{movie.overview || "No overview available."}
</p>
</CardContent>
</Card>
</TabsContent>
{(movie.budget || movie.revenue) && (
<TabsContent value="financials" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Box Office</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{movie.budget && movie.budget > 0 && (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-600 flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Budget
</span>
<span className="text-lg font-semibold">
${movie.budget.toLocaleString()}
</span>
</div>
)}
{movie.revenue && movie.revenue > 0 && (
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<span className="text-sm text-gray-600 flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Revenue
</span>
<span className="text-lg font-semibold text-green-700">
${movie.revenue.toLocaleString()}
</span>
</div>
)}
{movie.budget &&
movie.revenue &&
movie.budget > 0 &&
movie.revenue > 0 && (
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<span className="text-sm text-gray-600 flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Profit
</span>
<span
className={`text-lg font-semibold ${movie.revenue - movie.budget >= 0 ? "text-blue-700" : "text-red-700"}`}
>
${(movie.revenue - movie.budget).toLocaleString()}
</span>
</div>
)}
</CardContent>
</Card>
</TabsContent>
)}
{movie.genres && movie.genres.length > 0 && (
<TabsContent value="details" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Genres</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{movie.genres.map((genre) => (
<Badge key={genre.id} variant="outline">
{genre.name}
</Badge>
))}
</div>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
</div>
</div>
);
};
const LoadingCard = ({
title,
description,
}: {
title: string;
description: string;
}) => {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 p-6 flex items-center justify-center">
<Card className="border-0 shadow-xl max-w-md w-full">
<CardHeader className="text-center py-12">
<div className="w-20 h-20 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Film className="w-10 h-10 text-purple-600" />
</div>
<CardTitle className="text-2xl mb-3">{title}</CardTitle>
<CardDescription className="text-base">{description}</CardDescription>
</CardHeader>
</Card>
</div>
);
};
const MovieList = () => {
const [state, setState] = useWidgetState<ToolOutput>();
const [loading, setLoading] = useState(true);
const toolOutput: ToolOutput = useWidgetProps();
useEffect(() => {
if (toolOutput && !state) {
// Update state with movie info (in whatever form it takes) from tool output
setState(toolOutput);
setLoading(false);
}
}, [toolOutput, setState]);
const handleWatchlistAdd = (movieId: number) => {
setState((prevState) => {
if (!prevState) return prevState;
// Update movies array if it exists
if (prevState.movies) {
return {
...prevState,
movies: prevState.movies.map((movie) =>
movie.id === movieId ? { ...movie, in_watchlist: true } : movie,
),
};
}
return prevState;
});
};
if (!state) {
if (loading) {
return (
<LoadingCard
title="Loading movie data"
description="Loading data from The Movie Database"
/>
);
} else {
return (
<LoadingCard
title="No data available"
description="No movie data was found. Try searching for movies or browsing popular titles."
/>
);
}
}
// Check if this is a detail view (single movie with id)
const isDetailView = !state.movies && state.id && state.title;
if (isDetailView) {
const movie: Movie = {
id: state.id!,
title: state.title!,
release_date: state.release_date,
rating: state.rating,
overview: state.overview,
poster_url: state.poster_url,
backdrop_url: state.backdrop_url,
runtime: state.runtime,
genres: state.genres,
tagline: state.tagline,
budget: state.budget,
revenue: state.revenue,
};
return (
<div className="p-6">
<MovieDetailView movie={movie} />
</div>
);
}
// List view
const movies = state.movies || [];
const totalResults = state.total_results;
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
<div className="p-6 md:p-8 lg:p-10 space-y-8">
{/* Header Section */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-1 h-10 bg-gradient-to-b from-purple-600 to-indigo-600 rounded-full" />
<div>
<h2 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">
MEGA Cool Movies
</h2>
{totalResults && (
<p className="text-sm text-gray-500 mt-1">
Showing{" "}
<span className="font-semibold text-gray-700">
{movies.length}
</span>{" "}
of{" "}
<span className="font-semibold text-gray-700">
{totalResults.toLocaleString()}
</span>{" "}
results
</p>
)}
</div>
</div>
</div>
{movies.length === 0 ? (
<Card className="border-0 shadow-lg">
<CardHeader className="text-center py-12">
<div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Film className="w-8 h-8 text-purple-600" />
</div>
<CardTitle className="text-2xl">No Movies Found</CardTitle>
<CardDescription className="text-base mt-2">
Try a different search or browse other categories.
</CardDescription>
</CardHeader>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{movies.map((movie) => (
<MovieCard
key={movie.id}
movie={movie}
onWatchlistAdd={handleWatchlistAdd}
/>
))}
</div>
)}
</div>
</div>
);
};
export default MovieList;
Widget này chỉ là React + Tailwind, vì vậy bạn có thể tùy chỉnh và mở rộng nó theo ý muốn.
Client `api` được sử dụng để lưu phim vào danh sách xem của người dùng.
Bước 8: Chạy và Kiểm Thử Ứng Dụng
Bạn đã hoàn thành việc xây dựng!
Bây giờ là lúc kiểm tra ứng dụng. Đầu tiên, bạn cần **làm mới trình kết nối ChatGPT của mình** để tải các tool calls mới được thêm vào MCP server, sau đó bạn có thể thử hỏi ChatGPT các câu hỏi như:
- “Show me upcoming movies.”
- “What movies are in theatres near me?”
- “Show my watch list.”
ChatGPT sẽ gọi công cụ MCP của bạn (ví dụ: `getUpcomingMovies`), lấy dữ liệu từ TMDB, trả về nội dung có cấu trúc cho widget của bạn, sau đó hiển thị nó bên trong ChatGPT với widget React của bạn.
Nếu bạn mở Gadget, bạn sẽ thấy mô hình `watchlist` của mình được điền với các ID danh sách TMDB được liên kết với bản ghi `user` của bạn.
Tổng Kết và Các Bước Tiếp Theo
Bạn vừa xây dựng một ứng dụng ChatGPT hoàn toàn tương tác với:
- Các tool calls tùy chỉnh thông qua **MCP**.
- Các **widget React** được nhúng.
- Lưu trữ dữ liệu đa thuê (multi-tenant) thông qua **Gadget**.
- Dữ liệu phim thực từ **TMDB**.
Nền tảng này có thể hỗ trợ bất kỳ ứng dụng nào được kết nối với ChatGPT: bảng điều khiển tin tức, cửa hàng thương mại điện tử hoặc công cụ năng suất.
Gadget đang xây dựng một kết nối ChatGPT để giúp tăng tốc việc xây dựng các ứng dụng ChatGPT. Hãy theo dõi blog của chúng tôi để biết thông báo.
Nếu bạn có bất kỳ câu hỏi nào, đừng ngần ngại liên hệ trên Discord của nhà phát triển Gadget!
Cần thêm thông tin?
Bắt Đầu Xây Dựng Ứng Dụng ChatGPT Trên Gadget Ngay Hôm Nay!
Bắt đầu tại gadget.new hoặc fork template của chúng tôi.



