From 41eb83048b2caa9c123f2d8e41da369ad4a8515d Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 18 Mar 2024 23:10:57 +0100 Subject: [PATCH] Colocate data fetching; use proper server components Performing all image search requests upfront, before rendering any image components, wasn't really in line with how server components should work. This approach was initially chosen to obtain a single `function` AI state that contains data from all searches. With the introduction of the `onDataFetched` prop, we can achieve the same outcome while moving data fetching directly into the server components, where it belongs. This change also makes it easier to add loading skeletons later on. --- src/app/google-image-search.ts | 1 + ...search-utils.ts => image-search-result.ts} | 10 +- src/app/images.tsx | 65 ++++++++++++ src/app/submit-user-message.tsx | 100 ++++++++---------- 4 files changed, 112 insertions(+), 64 deletions(-) rename src/app/{image-search-utils.ts => image-search-result.ts} (85%) create mode 100644 src/app/images.tsx diff --git a/src/app/google-image-search.ts b/src/app/google-image-search.ts index d90494d..977b23d 100644 --- a/src/app/google-image-search.ts +++ b/src/app/google-image-search.ts @@ -2,6 +2,7 @@ import {z} from 'zod'; export type Image = z.TypeOf; export type ImageSearchResponse = z.TypeOf; +export type ImageSearchParams = z.TypeOf; const apiKey = process.env.GOOGLE_SEARCH_API_KEY!; const searchEngineId = process.env.GOOGLE_SEARCH_SEARCH_ENGINE_ID!; diff --git a/src/app/image-search-utils.ts b/src/app/image-search-result.ts similarity index 85% rename from src/app/image-search-utils.ts rename to src/app/image-search-result.ts index 5301348..9c907ef 100644 --- a/src/app/image-search-utils.ts +++ b/src/app/image-search-result.ts @@ -37,13 +37,9 @@ export function createImageSearchResult( : {status: `found`, images: response, title}; } -export function serializeImageSearchResults( - imageSearchResults: ImageSearchResult[], -): string { - return JSON.stringify(imageSearchResults.map(reduceImageSarchResult)); -} - -function reduceImageSarchResult(imageSearchResult: ImageSearchResult) { +export function prepareImageSearchResultForAiState( + imageSearchResult: ImageSearchResult, +): unknown { const {status, title} = imageSearchResult; if (imageSearchResult.status === `found`) { diff --git a/src/app/images.tsx b/src/app/images.tsx new file mode 100644 index 0000000..5264c72 --- /dev/null +++ b/src/app/images.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import {type ImageSearchParams, searchImages} from './google-image-search.js'; +import { + createImageSearchResult, + prepareImageSearchResultForAiState, +} from './image-search-result.js'; +import {ImageSelector} from './image-selector.js'; +import {ProgressiveImage} from './progressive-image.js'; + +export interface ImagesProps { + readonly title: string; + readonly notFoundMessage: string; + readonly errorMessage: string; + readonly searchParams: ImageSearchParams; + readonly onDataFetched: (dataForAiState: unknown) => void; +} + +export async function Images({ + title, + notFoundMessage, + errorMessage, + searchParams, + onDataFetched, +}: ImagesProps): Promise { + const response = await searchImages(searchParams); + + const result = createImageSearchResult({ + response, + title, + notFoundMessage, + errorMessage, + }); + + onDataFetched(prepareImageSearchResultForAiState(result)); + + if (result.status !== `found`) { + const message = + result.status === `not-found` + ? result.notFoundMessage + : result.errorMessage; + + return ( +

+ {message} +

+ ); + } + + return ( +
+

{result.title}

+ {result.images.map(({thumbnailUrl, url, width, height}) => ( + + + + ))} +
+ ); +} diff --git a/src/app/submit-user-message.tsx b/src/app/submit-user-message.tsx index 906fe1d..f1c2d7c 100644 --- a/src/app/submit-user-message.tsx +++ b/src/app/submit-user-message.tsx @@ -6,15 +6,10 @@ import * as React from 'react'; import {z} from 'zod'; import {type UserInput, fromUserInput} from './ai-state.js'; import type {AI, UIStateItem} from './ai.js'; -import {imageSearchParams, searchImages} from './google-image-search.js'; -import { - createImageSearchResult, - serializeImageSearchResults, -} from './image-search-utils.js'; -import {ImageSelector} from './image-selector.js'; +import {imageSearchParams} from './google-image-search.js'; +import {Images} from './images.js'; import {LoadingIndicator} from './loading-indicator.js'; import {Markdown} from './markdown.js'; -import {ProgressiveImage} from './progressive-image.js'; import {UserChoiceButton} from './user-choice-button.js'; const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY}); @@ -173,8 +168,8 @@ export async function submitUserMessage( ) .describe(`Use multiple sets of search parameters if needed.`), }), - async *render({loadingText, searches: searchParamsList}) { - console.log(`search_and_show_images`, searchParamsList); + async *render({loadingText, searches}) { + console.log(`search_and_show_images`, searches); const text = lastTextContent ? (
@@ -189,18 +184,6 @@ export async function submitUserMessage(
); - const imageSearchResults = await Promise.all( - searchParamsList.map( - async ({searchParams, title, notFoundMessage, errorMessage}) => - createImageSearchResult({ - response: await searchImages(searchParams), - title, - notFoundMessage, - errorMessage, - }), - ), - ); - if (lastTextContent) { aiState.update((prevAiState) => [ ...prevAiState, @@ -208,50 +191,53 @@ export async function submitUserMessage( ]); } - aiState.done([ - ...aiState.get(), - { - role: `function`, - name: `search_and_show_images`, - content: serializeImageSearchResults(imageSearchResults), + const elementsWithData = searches.map( + ({title, notFoundMessage, errorMessage, searchParams}) => { + let resolveDataPromise: (data: unknown) => void; + + return { + element: ( + resolveDataPromise(data)} + /> + ), + dataPromise: new Promise( + (resolve) => (resolveDataPromise = resolve), + ), + }; }, - ]); + ); - return ( + const finalUi = (
{text}
- {imageSearchResults.map((result) => ( - -

{result.title}

- {result.status === `found` ? ( - result.images.map( - ({thumbnailUrl, url, width, height}) => ( - - - - ), - ) - ) : ( -

- - {result.status === `not-found` - ? result.notFoundMessage - : result.errorMessage} - -

- )} -
- ))} + {elementsWithData.map(({element}) => element)}
); + + yield finalUi; + + const dataItems = await Promise.all( + elementsWithData.map(async ({dataPromise}) => dataPromise), + ); + + aiState.done([ + ...aiState.get(), + { + role: `function`, + name: `search_and_show_images`, + content: JSON.stringify(dataItems), + }, + ]); + + return finalUi; }, }, },