diff --git a/apps/course-builder-web/package.json b/apps/course-builder-web/package.json index d0d0b8b34..e60f2575b 100644 --- a/apps/course-builder-web/package.json +++ b/apps/course-builder-web/package.json @@ -40,11 +40,13 @@ "@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", "@mdx-js/loader": "^3.0.0", "@mdx-js/react": "^3.0.0", + "@msgpack/msgpack": "3.0.0-beta2", "@mux/mux-player-react": "^2.2.0", "@next/mdx": "14.0.1", "@planetscale/database": "^1.11.0", @@ -87,7 +89,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", @@ -143,7 +145,8 @@ "y-prosemirror": "^1.2.1", "y-protocols": "^1.0.6", "yjs": "^13.6.10", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.4.7" }, "devDependencies": { "@mux/mux-node": "^7.3.2", diff --git a/apps/course-builder-web/party/index.ts b/apps/course-builder-web/party/index.ts index 52e0e6f19..6f0561374 100644 --- a/apps/course-builder-web/party/index.ts +++ b/apps/course-builder-web/party/index.ts @@ -1,5 +1,15 @@ import type * as Party from 'partykit/server' import {onConnect} from 'y-partykit' +import * as Y from 'yjs' + +const BROADCAST_INTERVAL = 1000 / 60 // 60fps + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': + 'Origin, X-Requested-With, Content-Type, Accept', +} export default class Server implements Party.Server { constructor(readonly party: Party.Party) {} @@ -13,7 +23,27 @@ 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 + }, + }, + }) } messages: string[] = [] @@ -22,15 +52,33 @@ export default class Server implements Party.Server { this.messages = (await this.party.storage.get('messages')) ?? [] } - async onRequest(_req: Party.Request) { - const messageBody: {requestId: string; body: string; name: string} = - await _req.json() + async onRequest(req: Party.Request) { + if (req.method === 'GET') { + // For SSR, return the current presence of all connections + // const users = [...this.party.getConnections()].reduce( + // (acc, user) => ({...acc, [user.id]: this.getUser(user)}), + // {}, + // ) + return Response.json({users: []}, {status: 200, headers: CORS}) + } - this.party.broadcast(JSON.stringify(messageBody)) + // respond to cors preflight requests + if (req.method === 'OPTIONS') { + return Response.json({ok: true}, {status: 200, headers: CORS}) + } - return new Response( - `Party ${this.party.id} has received ${this.messages.length} messages`, - ) + if (req.method === 'POST') { + const messageBody: {requestId: string; body: string; name: string} = + await req.json() + + this.party.broadcast(JSON.stringify(messageBody)) + + return new Response( + `Party ${this.party.id} has received ${this.messages.length} messages`, + ) + } + + return new Response('Method Not Allowed', {status: 405}) } onMessage(message: string, sender: Party.Connection) { @@ -46,3 +94,42 @@ export default class Server implements Party.Server { } Server satisfies Party.Worker + +export async function sanityQuery( + query: string, + env: any, + options: {useCdn?: boolean; revalidate?: number} = { + useCdn: true, + revalidate: 10, + }, +): Promise { + 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 || 'production' + }?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 + }) +} diff --git a/apps/course-builder-web/sanity/schema.ts b/apps/course-builder-web/sanity/schema.ts index 6872df655..3ebc21dd8 100644 --- a/apps/course-builder-web/sanity/schema.ts +++ b/apps/course-builder-web/sanity/schema.ts @@ -19,6 +19,7 @@ import solution from './schemas/objects/solution' import github from './schemas/objects/github' import email from './schemas/documents/email' import imageResource from './schemas/documents/imageResource' +import article from './schemas/documents/article' export const schema: {types: SchemaTypeDefinition[]} = { types: [ @@ -36,6 +37,7 @@ export const schema: {types: SchemaTypeDefinition[]} = { linkResource, email, imageResource, + article, //objects solution, github, diff --git a/apps/course-builder-web/sanity/schemas/documents/article.ts b/apps/course-builder-web/sanity/schemas/documents/article.ts new file mode 100644 index 000000000..ac7468d69 --- /dev/null +++ b/apps/course-builder-web/sanity/schemas/documents/article.ts @@ -0,0 +1,68 @@ +import {MdArticle} from 'react-icons/md' +import {defineField} from 'sanity' + +export default { + name: 'article', + type: 'document', + title: 'Article', + icon: MdArticle, + fields: [ + defineField({ + name: 'title', + title: 'Title', + type: 'string', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'state', + title: 'Current State', + type: 'string', + validation: (Rule) => Rule.required(), + initialValue: 'draft', + options: { + list: [ + {title: 'draft', value: 'draft'}, + {title: 'published', value: 'published'}, + ], + }, + }), + defineField({ + name: 'slug', + title: 'Slug', + type: 'slug', + validation: (Rule) => Rule.required(), + options: { + source: 'title', + maxLength: 96, + }, + }), + { + name: 'body', + title: 'Body', + type: 'markdown', + }, + { + name: 'summary', + title: 'Summary', + type: 'markdown', + }, + { + name: 'concepts', + title: 'Concepts', + type: 'array', + of: [ + { + type: 'reference', + to: [{type: 'concept'}], + }, + ], + }, + defineField({ + name: 'description', + title: 'Short Description', + description: 'Used as a short "SEO" summary on Twitter cards etc.', + type: 'text', + validation: (Rule) => Rule.max(160), + }), + ], +} diff --git a/apps/course-builder-web/src/app/[article]/page.tsx b/apps/course-builder-web/src/app/[article]/page.tsx new file mode 100644 index 000000000..c8e7c03e3 --- /dev/null +++ b/apps/course-builder-web/src/app/[article]/page.tsx @@ -0,0 +1,50 @@ +import Link from 'next/link' +import {Button} from '@coursebuilder/ui' +import * as React from 'react' +import {getServerAuthSession} from '@/server/auth' +import {getAbility} from '@/lib/ability' +import ReactMarkdown from 'react-markdown' +import {getArticle} from '@/lib/articles' +import {notFound} from 'next/navigation' + +export default async function ArticlePage({ + params, +}: { + params: {article: string} +}) { + const session = await getServerAuthSession() + const ability = getAbility({user: session?.user}) + const article = await getArticle(params.article) + + console.log('******', {article}) + + if (!article) { + notFound() + } + + return ( +
+ {ability.can('edit', 'Article') ? ( +
+
+ +
+ ) : null} +
+
+

{article.title}

+
+
+ + {article.body} + + {article.summary} +
+
+
+ ) +} diff --git a/apps/course-builder-web/src/app/_components/chat-response.tsx b/apps/course-builder-web/src/app/_components/chat-response.tsx index 69935648a..b081fee56 100644 --- a/apps/course-builder-web/src/app/_components/chat-response.tsx +++ b/apps/course-builder-web/src/app/_components/chat-response.tsx @@ -19,8 +19,6 @@ export function ChatResponse({requestIds = []}: {requestIds: string[]}) { onMessage: (messageEvent) => { const messageData = JSON.parse(messageEvent.data) - console.log({requestIds, messageData}) - if ( messageData.body !== STREAM_COMPLETE && requestIds.includes(messageData.requestId) diff --git a/apps/course-builder-web/src/app/_components/landing.tsx b/apps/course-builder-web/src/app/_components/landing.tsx index 98b083e21..6ee215037 100644 --- a/apps/course-builder-web/src/app/_components/landing.tsx +++ b/apps/course-builder-web/src/app/_components/landing.tsx @@ -7,7 +7,6 @@ import {Icon} from '@/components/icons' export const Landing = () => { const {data: session, status} = useSession() - console.log(session) return ( <> diff --git a/apps/course-builder-web/src/app/_components/party.tsx b/apps/course-builder-web/src/app/_components/party.tsx index d0d1e6420..1db76b23d 100644 --- a/apps/course-builder-web/src/app/_components/party.tsx +++ b/apps/course-builder-web/src/app/_components/party.tsx @@ -4,10 +4,11 @@ import {useSocket} from '@/hooks/use-socket' import {api} from '@/trpc/react' import {useRouter} from 'next/navigation' -export function Party() { +export function Party({room}: {room?: string}) { const utils = api.useUtils() const router = useRouter() useSocket({ + room, onMessage: async (messageEvent) => { try { const data = JSON.parse(messageEvent.data) @@ -18,8 +19,6 @@ export function Party() { 'ai.tip.draft.completed', ] - console.log(data.name) - if (invalidateOn.includes(data.name)) { await utils.module.invalidate() router.refresh() diff --git a/apps/course-builder-web/src/app/api/chat/route.ts b/apps/course-builder-web/src/app/api/chat/route.ts new file mode 100644 index 000000000..b7b714651 --- /dev/null +++ b/apps/course-builder-web/src/app/api/chat/route.ts @@ -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) +} diff --git a/apps/course-builder-web/src/app/articles/[slug]/edit/page.tsx b/apps/course-builder-web/src/app/articles/[slug]/edit/page.tsx new file mode 100644 index 000000000..d118e2d84 --- /dev/null +++ b/apps/course-builder-web/src/app/articles/[slug]/edit/page.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' +import {getServerAuthSession} from '@/server/auth' +import {getAbility} from '@/lib/ability' +import {EditArticleForm} from '@/app/articles/_components/edit-article-form' +import {getArticle} from '@/lib/articles' + +export default async function ArticleEditPage({ + params, +}: { + params: {slug: string} +}) { + const session = await getServerAuthSession() + const ability = getAbility({user: session?.user}) + const article = await getArticle(params.slug) + + return article && ability.can('upload', 'Media') ? ( +
+ +
+ ) : null +} diff --git a/apps/course-builder-web/src/app/articles/_components/article-assistant.tsx b/apps/course-builder-web/src/app/articles/_components/article-assistant.tsx new file mode 100644 index 000000000..658431699 --- /dev/null +++ b/apps/course-builder-web/src/app/articles/_components/article-assistant.tsx @@ -0,0 +1,170 @@ +'use client' + +import ReactMarkdown from 'react-markdown' +import * as React from 'react' +import {STREAM_COMPLETE} from '@/lib/streaming-chunk-publisher' +import {useRef} from 'react' +import {api} from '@/trpc/react' +import {useSocket} from '@/hooks/use-socket' +import {Button, ScrollArea, Textarea} from '@coursebuilder/ui' +import {EnterIcon} from '@sanity/icons' +import { + type ChatCompletionRequestMessage, + ChatCompletionRequestMessageRoleEnum, +} from 'openai-edge' + +export function ArticleAssistant({ + article, +}: { + article: {body: string | null; title: string; _id: string} +}) { + const {mutate: sendArticleChatMessage} = api.articles.chat.useMutation() + const [messages, setMessages] = React.useState< + ChatCompletionRequestMessage[] + >([]) + + const textareaRef = useRef(null) + + useSocket({ + room: article._id, + onMessage: (messageEvent) => { + try { + const messageData = JSON.parse(messageEvent.data) + + if ( + messageData.name === 'article.chat.completed' && + messageData.requestId === article._id + ) { + setMessages(messageData.body) + } + } catch (error) { + // noting to do + } + }, + }) + + return ( +
+

Assistant

+ +
+
+