Skip to content

Commit

Permalink
feat: ai tip chat
Browse files Browse the repository at this point in the history
  • Loading branch information
joelhooks committed Dec 21, 2023
1 parent 0308afc commit 6b3a6c4
Show file tree
Hide file tree
Showing 13 changed files with 417 additions and 138 deletions.
3 changes: 2 additions & 1 deletion apps/course-builder-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@codemirror/lang-markdown": "^6.2.3",
"@codemirror/language-data": "^6.3.1",
"@codemirror/state": "^6.3.3",
"@codemirror/view": "^6.22.3",
"@coursebuilder/ui": "^1.0.1",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.3.2",
Expand Down Expand Up @@ -87,7 +88,7 @@
"@types/shortid": "^0.0.31",
"@types/uuid": "^9.0.6",
"@uploadthing/react": "5.7.1-canary.65ab2fd",
"ai": "^2.2.20",
"ai": "^2.2.22",
"aws-sdk": "^2.1483.0",
"axios": "^1.5.1",
"base-64": "^1.0.0",
Expand Down
63 changes: 62 additions & 1 deletion apps/course-builder-web/party/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as Party from 'partykit/server'
import {onConnect} from 'y-partykit'
import * as Y from 'yjs'

export default class Server implements Party.Server {
constructor(readonly party: Party.Party) {}
Expand All @@ -13,7 +14,28 @@ export default class Server implements Party.Server {
url: ${new URL(ctx.request.url).pathname}`,
)

return onConnect(conn, this.party, {})
const party = this.party

return onConnect(conn, this.party, {
async load() {
const tip = await sanityQuery<{body: string | null} | null>(
`*[_id == "${party.id}"][0]{body}`,
party.env,
)

const doc = new Y.Doc()
if (tip?.body) {
doc.getText('codemirror').insert(0, tip.body)
}
return doc
},
callback: {
handler: async (doc) => {
// autosave
console.log('STUFF HAPPENED', doc)
},
},
})
}

messages: string[] = []
Expand Down Expand Up @@ -46,3 +68,42 @@ export default class Server implements Party.Server {
}

Server satisfies Party.Worker

export async function sanityQuery<T = any>(
query: string,
env: any,
options: {useCdn?: boolean; revalidate?: number} = {
useCdn: true,
revalidate: 10,
},
): Promise<T> {
return await fetch(
`https://${env.SANITY_STUDIO_PROJECT_ID}.${
options.useCdn ? 'apicdn' : 'api'
}.sanity.io/v${env.SANITY_STUDIO_API_VERSION}/data/query/${
env.SANITY_STUDIO_DATASET
}?query=${encodeURIComponent(query)}&perspective=published`,
{
method: 'get',
headers: {
Authorization: `Bearer ${env.SANITY_API_TOKEN}`,
},
next: {revalidate: options.revalidate}, //seconds
},
)
.then(async (response) => {
if (response.status !== 200) {
throw new Error(
`Sanity Query failed with status ${response.status}: ${
response.statusText
}\n\n\n${JSON.stringify(await response.json(), null, 2)})}`,
)
}
const {result} = await response.json()
return result as T
})
.catch((error) => {
console.error('FAAAAAAIIIILLLLL', error)
throw error
})
}
20 changes: 20 additions & 0 deletions apps/course-builder-web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// ./app/api/chat/route.js
import OpenAI from 'openai'
import {OpenAIStream, StreamingTextResponse} from 'ai'

const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})

export const runtime = 'edge'

export async function POST(req: Request) {
const {messages} = await req.json()
const response = await openai.chat.completions.create({
model: 'gpt-4',
stream: true,
messages,
})
const stream = OpenAIStream(response)
return new StreamingTextResponse(stream)
}
38 changes: 30 additions & 8 deletions apps/course-builder-web/src/app/tips/_components/codemirror.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import {env} from '@/env.mjs'
import * as Y from 'yjs'
import {yCollab} from 'y-codemirror.next'
import {EditorView, basicSetup} from 'codemirror'
import {basicSetup, EditorView} from 'codemirror'
import {EditorState, Extension} from '@codemirror/state'
import {useCallback, useEffect, useState} from 'react'
import {markdown} from '@codemirror/lang-markdown'
import YPartyKitProvider from 'y-partykit/provider'

export const CodemirrorEditor = ({roomName}: {roomName: string}) => {
const {codemirrorElementRef} = useCodemirror({roomName})
export const CodemirrorEditor = ({
roomName,
value,
onChange,
}: {
roomName: string
value: string
onChange: (data: any) => void
}) => {
const {codemirrorElementRef} = useCodemirror({roomName, value, onChange})

return (
<div className="h-full flex-shrink-0 border-t">
Expand Down Expand Up @@ -60,32 +68,47 @@ const styles: Extension[] = [
* @param options
* @constructor
*/
const useCodemirror = ({roomName}: {roomName: string}) => {
const useCodemirror = ({
roomName,
value,
onChange,
}: {
roomName: string
value: string
onChange: (data: any) => void
}) => {
const [element, setElement] = useState<HTMLElement>()
const [yUndoManager, setYUndoManager] = useState<Y.UndoManager>()

useEffect(() => {
let view: EditorView
let yDoc = new Y.Doc()

let provider = new YPartyKitProvider(
env.NEXT_PUBLIC_PARTY_KIT_URL,
roomName,
yDoc,
)

if (!element) {
return
}

const ytext = provider.doc.getText('codemirror')

const undoManager = new Y.UndoManager(ytext)
setYUndoManager(undoManager)

let updateListenerExtension = EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString())
}
})

// Set up CodeMirror and extensions
const state = EditorState.create({
doc: ytext.toString(),
extensions: [
basicSetup,
updateListenerExtension,
markdown(),
yCollab(ytext, provider.awareness, {undoManager}),
...styles,
Expand All @@ -101,9 +124,8 @@ const useCodemirror = ({roomName}: {roomName: string}) => {
return () => {
provider?.destroy()
view?.destroy()
yDoc?.destroy()
}
}, [element, roomName])
}, [element, roomName, value])

return {
codemirrorElementRef: useCallback((node: HTMLElement | null) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,13 @@ export function EditTipForm({tip}: {tip: Tip}) {
<FormDescription className="px-5 pb-3">
Tip content in MDX.
</FormDescription>
<CodemirrorEditor roomName={`tip-edit-${tip._id}`} />
<CodemirrorEditor
roomName={`${tip._id}`}
value={tip.body}
onChange={(data) => {
form.setValue('body', data)
}}
/>
<FormMessage />
</FormItem>
)}
Expand Down
91 changes: 85 additions & 6 deletions apps/course-builder-web/src/app/tips/_components/tip-assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,120 @@ import {Tip} from '@/lib/tips'
import {Button, ScrollArea, Textarea} from '@coursebuilder/ui'
import {LoaderIcon, SparkleIcon} from 'lucide-react'
import {EnterIcon} from '@sanity/icons'
import {inngest} from '@/inngest/inngest.server'
import {
type ChatCompletionRequestMessage,
ChatCompletionRequestMessageRoleEnum,
} from 'openai-edge'

const getSystemMessage = (tip: Tip) => {
const primarySystemWriterPrompt = `
# Instructions
Pause. Take a deep breath and think.
You are serving as a writing assistant for a content creator that is publishing a video based tip for software developers. The content
creator will ask questions and expect concise answers that aren't corny or generic.
Keep responses scoped to the tip and it's direct contents. Do not include additional information. Do not include information that is not
directly related to the tip or the video.
The goal is to build a really good written version of the existing video, not edit the video itself. The video is done.
The transcript is the final representation of the video. The transcript is the source of truth.
Tip Title: ${tip.title}
Tip Transcript: ${tip.transcript}
Tip Body: ${tip.body}
`
return {
role: ChatCompletionRequestMessageRoleEnum.System,
content: primarySystemWriterPrompt,
}
}

export function TipAssistant({tip}: {tip: Tip}) {
const {mutate: sendTipChatMessage} = api.tips.chat.useMutation()
const [messages, setMessages] = React.useState<
ChatCompletionRequestMessage[]
>([getSystemMessage(tip)])

const textareaRef = useRef<HTMLTextAreaElement>(null)

const {mutateAsync: generateTitle, status: generateTitleStatus} =
api.tips.generateTitle.useMutation()

useSocket({
onMessage: (messageEvent) => {
try {
const messageData = JSON.parse(messageEvent.data)

if (
messageData.name === 'tip.chat.completed' &&
messageData.requestId === tip._id
) {
setMessages(messageData.body)
}
} catch (error) {
// noting to do
}
},
})

return (
<div className="flex h-full w-full flex-col justify-start">
<h3 className="inline-flex p-5 pb-3 text-lg font-bold">Assistant</h3>
<ChatResponse requestIds={[tip.videoResourceId]} />
<ChatResponse requestIds={[tip._id]} />
<div className="flex w-full flex-col items-start border-t">
<div className="relative w-full">
<Textarea
ref={textareaRef}
className="w-full rounded-none border-0 border-b px-5 py-4 pr-10"
placeholder="Type a message..."
disabled={generateTitleStatus === 'loading'}
rows={4}
onKeyDown={async (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
// TODO: send message
sendTipChatMessage({
tipId: tip._id,

messages: [
...messages,
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: event.currentTarget.value,
},
],
})
event.currentTarget.value = ''
}
}}
/>
<Button
type="button"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center p-0"
variant="outline"
onClick={() => {
// TODO: send message
onClick={async (event) => {
event.preventDefault()
if (textareaRef.current) {
sendTipChatMessage({
tipId: tip._id,
messages: [
...messages,
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: textareaRef.current?.value,
},
],
})
textareaRef.current.value = ''
}
}}
>
<EnterIcon className="w-4" />
</Button>
</div>

<div className="p-5">
<h3 className="flex pb-3 text-lg font-bold">Actions</h3>
<Button
Expand Down Expand Up @@ -79,10 +159,9 @@ export function ChatResponse({requestIds = []}: {requestIds: string[]}) {
try {
const messageData = JSON.parse(messageEvent.data)

console.log({requestIds, messageData})

if (
messageData.body !== STREAM_COMPLETE &&
messageData.name == 'ai.message' &&
requestIds.includes(messageData.requestId)
) {
setMessages((messages) => [
Expand Down
10 changes: 10 additions & 0 deletions apps/course-builder-web/src/inngest/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,13 @@ export type UserCreated = {
name: typeof USER_CREATED_EVENT
data: {}
}

export const TIP_CHAT_EVENT = 'tip/chat-event'

export type TipChat = {
name: typeof TIP_CHAT_EVENT
data: {
tipId: string
messages: ChatCompletionRequestMessage[]
}
}
Loading

0 comments on commit 6b3a6c4

Please sign in to comment.