From a4b4245355b8620ef41bc2701ff86aab96d4e950 Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 15 Dec 2023 16:13:55 +0300 Subject: [PATCH] feat: stream openai responses --- app/api/ai/summarize/route.ts | 22 +++++++ .../episode-ai-summary-stream.tsx | 13 ----- .../episode-ai-summary-streamer.tsx | 57 +++++++++++++++++++ .../episode-ai-thingy-generator.tsx | 53 ++++++++++------- components/ui/panel.tsx | 21 +++++++ lib/services/ai/openai.ts | 26 +-------- lib/services/episode-content.ts | 31 ++++++++++ 7 files changed, 164 insertions(+), 59 deletions(-) create mode 100644 app/api/ai/summarize/route.ts delete mode 100644 components/episode-ai-thingy/episode-ai-summary-stream.tsx create mode 100644 components/episode-ai-thingy/episode-ai-summary-streamer.tsx create mode 100644 components/ui/panel.tsx create mode 100644 lib/services/episode-content.ts diff --git a/app/api/ai/summarize/route.ts b/app/api/ai/summarize/route.ts new file mode 100644 index 0000000..f1bc531 --- /dev/null +++ b/app/api/ai/summarize/route.ts @@ -0,0 +1,22 @@ +import type { OpenAI } from 'openai'; + +import { openai } from '@/lib/services/ai/openai'; +import { OpenAIStream, StreamingTextResponse } from 'ai'; + +export const runtime = 'edge'; + +export async function POST(req: Request) { + const { messages } = (await req.json()) as { + messages: OpenAI.Chat.ChatCompletionCreateParamsStreaming['messages']; + }; + + const response = await openai.chat.completions.create({ + messages, + model: 'gpt-4-1106-preview', + stream: true, + }); + + const stream = OpenAIStream(response); + + return new StreamingTextResponse(stream); +} diff --git a/components/episode-ai-thingy/episode-ai-summary-stream.tsx b/components/episode-ai-thingy/episode-ai-summary-stream.tsx deleted file mode 100644 index 432761e..0000000 --- a/components/episode-ai-thingy/episode-ai-summary-stream.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Tables } from '@/types/supabase/database'; - -type Props = { - id: Tables<'episode'>['id']; - title: Tables<'episode'>['title']; - transcription: string; -}; - -export function EpisodeAISummaryStream(props: Props) { - console.log(props); - - return
hi how are you
; -} diff --git a/components/episode-ai-thingy/episode-ai-summary-streamer.tsx b/components/episode-ai-thingy/episode-ai-summary-streamer.tsx new file mode 100644 index 0000000..554174e --- /dev/null +++ b/components/episode-ai-thingy/episode-ai-summary-streamer.tsx @@ -0,0 +1,57 @@ +import type { Tables } from '@/types/supabase/database'; + +import { saveEpisodeContentSummary } from '@/lib/services/episode-content'; +import { useChat } from 'ai/react'; +import { useEffect, useRef } from 'react'; + +type Props = { + id: Tables<'episode'>['id']; + title: Tables<'episode'>['title']; + transcription: string; +}; + +export function EpisodeAISummaryStreamer({ id, title, transcription }: Props) { + const startedRef = useRef(false); + + const { messages, reload, setMessages } = useChat({ + api: '/api/ai/summarize', + onFinish: (message) => { + if (message.role === 'system') { + return; + } + + void saveEpisodeContentSummary(id, message.content); + }, + }); + + useEffect(() => { + if (startedRef.current) { + return; + } + + startedRef.current = true; + + setMessages([ + { + content: `Summarize the podcast episode titled '${title}' in a short paragraph.`, + id: 'system-0', + role: 'system', + }, + { + content: transcription, + id: 'system-1', + role: 'system', + }, + ]); + + void reload(); + }, [reload, setMessages, title, transcription]); + + const summaryMessage = messages.find((message) => message.role !== 'system'); + + return ( +
+ {summaryMessage ? summaryMessage.content : 'Summarizing episode...'} +
+ ); +} diff --git a/components/episode-ai-thingy/episode-ai-thingy-generator.tsx b/components/episode-ai-thingy/episode-ai-thingy-generator.tsx index 09f9d5b..f7bafd7 100644 --- a/components/episode-ai-thingy/episode-ai-thingy-generator.tsx +++ b/components/episode-ai-thingy/episode-ai-thingy-generator.tsx @@ -12,7 +12,8 @@ import { useCallback, useState } from 'react'; import { FaExclamationTriangle } from 'react-icons/fa'; import { PiRobotBold } from 'react-icons/pi'; -import { CollapsiblePanel } from '../ui/collapsible-panel'; +import { Panel } from '../ui/panel'; +import { EpisodeAISummaryStreamer } from './episode-ai-summary-streamer'; import { EpisodeAIThingyPlaceholder } from './episode-ai-thingy-placeholder'; type State = @@ -33,6 +34,7 @@ type State = export function EpisodeAIThingyGenerator({ id, + title, }: { id: Tables<'episode'>['id']; title: Tables<'episode'>['title']; @@ -60,11 +62,37 @@ export function EpisodeAIThingyGenerator({ }, [id]); switch (state.status) { + case 'idle': + return ( + + + + ); + + case 'transcribing': + return Transcribing episode...; + case 'summarizing': return ( - - {state.transcription} - + + + + + ); case 'error': @@ -79,22 +107,5 @@ export function EpisodeAIThingyGenerator({ ); - - default: - return ( - - - - ); } } diff --git a/components/ui/panel.tsx b/components/ui/panel.tsx new file mode 100644 index 0000000..fa58ef8 --- /dev/null +++ b/components/ui/panel.tsx @@ -0,0 +1,21 @@ +import type { PropsWithChildren } from 'react'; + +import { Card, Heading, Text } from '@radix-ui/themes'; + +type Props = PropsWithChildren<{ + title: string; +}>; + +export function Panel({ children, title }: Props) { + return ( + + + {title} + + + + {children} + + + ); +} diff --git a/lib/services/ai/openai.ts b/lib/services/ai/openai.ts index 932bd07..0e0e25d 100644 --- a/lib/services/ai/openai.ts +++ b/lib/services/ai/openai.ts @@ -1,28 +1,4 @@ import { env } from '@/env.mjs'; import { OpenAI } from 'openai'; -const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }); - -export const summarizeEpisodeTranscript = async ({ - title, - transcript, -}: { - title: string; - transcript: string; -}) => { - const response = await openai.chat.completions.create({ - messages: [ - { - content: `Summarize the following transcript from a podcast episode titled as ${title}`, - role: 'system', - }, - { - content: transcript, - role: 'system', - }, - ], - model: 'gpt-4-1106-preview', - }); - - return response.choices[0].message.content; -}; +export const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }); diff --git a/lib/services/episode-content.ts b/lib/services/episode-content.ts new file mode 100644 index 0000000..1caaa39 --- /dev/null +++ b/lib/services/episode-content.ts @@ -0,0 +1,31 @@ +'use server'; + +import type { Tables } from '@/types/supabase/database'; + +import { cookies } from 'next/headers'; + +import { DatabaseError } from '../errors'; +import { getAccountId } from './account'; +import { createSupabaseServerClient } from './supabase/server'; + +export const saveEpisodeContentSummary = async ( + id: Tables<'episode'>['id'], + summary: string, +) => { + const supabase = createSupabaseServerClient(cookies()); + + const { error } = await supabase.from('episode_content').upsert( + { + account: await getAccountId(), + episode: id, + text_summary: summary, + }, + { + onConflict: 'episode', + }, + ); + + if (error) { + throw new DatabaseError(error); + } +};