@learnpack/learnpack 5.0.67 → 5.0.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ import React, { useRef, useState } from "react"
2
+ import { SVGS } from "../../assets/svgs"
3
+ import { useShallow } from "zustand/react/shallow"
4
+ import useStore from "../../utils/store"
5
+ import { interactiveCreation } from "../../utils/rigo"
6
+ import {
7
+ parseLesson,
8
+ uploadFileToBucket,
9
+ useConsumableCall,
10
+ } from "../../utils/lib"
11
+ import {
12
+ createLearnJson,
13
+ processExercise,
14
+ slugify,
15
+ randomUUID,
16
+ } from "../../utils/creatorUtils"
17
+
18
+ import Loader from "../Loader"
19
+ import { Message, TMessage } from "../Message"
20
+ import { Lesson } from "../LessonItem"
21
+ import FileUploader from "../FileUploader"
22
+ import { ConsumablesManager } from "../ConsumablesManager"
23
+ import toast from "react-hot-toast"
24
+ import { ContentIndex } from "./ContentIndex"
25
+ import { Syllabus } from "../../utils/store"
26
+
27
+ const ContentIndexHeader = ({
28
+ messages,
29
+ syllabus,
30
+ }: {
31
+ messages: TMessage[]
32
+ syllabus: Syllabus
33
+ }) => {
34
+ return (
35
+ <div>
36
+ <h2 className="text-lg font-semibold">
37
+ {messages.filter((m) => m.type === "assistant" && m.content.length > 0)
38
+ .length === 0
39
+ ? "I've created a detailed structure for your course."
40
+ : "I've updated the structure based on your feedback."}
41
+ </h2>
42
+ <p className="text-sm text-gray-600">
43
+ {messages.filter((m) => m.type === "assistant" && m.content.length > 0)
44
+ .length === 0
45
+ ? `It includes a mix of reading, coding exercises, and quizzes. Give
46
+ it a look and let me know if it aligns with your expectations or if
47
+ there are any changes you'd like to make.`
48
+ : "Based on your input, here is the new syllabus, updates are highlighted in yellow"}
49
+ </p>
50
+ <h3 className="text-sm text-gray-600 mt-2 font-bold">
51
+ {syllabus.courseInfo.title}
52
+ </h3>
53
+ </div>
54
+ )
55
+ }
56
+
57
+
58
+
59
+ const Sidebar = ({
60
+ messages,
61
+ sendPrompt,
62
+ handleSubmit,
63
+ }: {
64
+ messages: TMessage[]
65
+ sendPrompt: (prompt: string) => void
66
+ handleSubmit: () => void
67
+ }) => {
68
+ const inputRef = useRef<HTMLTextAreaElement>(null)
69
+ const syllabus = useStore((state) => state.syllabus)
70
+ const setSyllabus = useStore((state) => state.setSyllabus)
71
+ return (
72
+ <div className="w-1/3 p-6 text-sm text-gray-700 border-r bg-learnpack-blue h-screen overflow-y-auto scrollbar-hide relative">
73
+ <p className="mt-2">If you're satisfied, type "OK" in the chat.</p>
74
+ <p className="mt-2">If not, use the chat to give more context.</p>
75
+
76
+ <div className="mt-10 space-y-2 pb-16">
77
+ {messages.map((message, index) => (
78
+ <Message key={index} type={message.type} content={message.content} />
79
+ ))}
80
+ </div>
81
+
82
+ <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-[90%] border rounded-md bg-white text-gray-400 resize-none h-24 ">
83
+ <textarea
84
+ ref={inputRef}
85
+ style={{ resize: "none" }}
86
+ className="w-full h-full p-2"
87
+ placeholder="How can Learnpack help you?"
88
+ autoFocus
89
+ onKeyUp={(e) => {
90
+ if (e.key === "Enter" && !e.shiftKey) {
91
+ e.preventDefault()
92
+ sendPrompt(inputRef.current?.value || "")
93
+ inputRef.current!.value = ""
94
+ }
95
+ // if the pressed key is K or k
96
+ if (e.key === "K" || e.key === "k") {
97
+ e.preventDefault()
98
+
99
+ if (inputRef.current?.value.toLowerCase().trim() === "ok") {
100
+ handleSubmit()
101
+ }
102
+ }
103
+ }}
104
+ />
105
+ <div className="absolute bottom-2 right-2 flex gap-1 items-center">
106
+ <div className="relative inline-block">
107
+ {syllabus.uploadedFiles?.length > 0 && (
108
+ <span
109
+ className="absolute -top-1 right-0 inline-flex items-center justify-center w-3 h-3 text-[10px] text-white bg-blue-200 rounded-full hover:bg-red-300 cursor-pointer"
110
+ title="Remove uploaded files"
111
+ onClick={() => {
112
+ setSyllabus({
113
+ ...syllabus,
114
+ uploadedFiles: [],
115
+ })
116
+ }}
117
+ >
118
+ {syllabus.uploadedFiles?.length}
119
+ </span>
120
+ )}
121
+ <FileUploader
122
+ onResult={(res) => {
123
+ setSyllabus({
124
+ ...syllabus,
125
+ uploadedFiles: [...syllabus.uploadedFiles, ...res],
126
+ })
127
+ }}
128
+ />
129
+ </div>
130
+
131
+ <button
132
+ className="cursor-pointer blue-on-hover flex items-center justify-center w-6 h-6"
133
+ onClick={() => sendPrompt(inputRef.current?.value || "")}
134
+ >
135
+ {SVGS.send}
136
+ </button>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ const SyllabusEditor: React.FC = () => {
144
+ const [messages, setMessages] = useState<TMessage[]>([])
145
+ const [isGenerating, setIsGenerating] = useState(false)
146
+ const prevLessons = useRef<Lesson[]>([])
147
+ const { syllabus, setSyllabus, auth } = useStore(
148
+ useShallow((state) => ({
149
+ syllabus: state.syllabus,
150
+ setSyllabus: state.setSyllabus,
151
+ auth: state.auth,
152
+ }))
153
+ )
154
+
155
+ const sendPrompt = async (prompt: string) => {
156
+ setMessages([
157
+ ...messages,
158
+ { type: "user", content: prompt },
159
+ { type: "assistant", content: "" },
160
+ ])
161
+ prevLessons.current = syllabus.lessons
162
+ const res = await interactiveCreation(auth.rigoToken, {
163
+ courseInfo: JSON.stringify(syllabus),
164
+ prevInteractions:
165
+ messages
166
+ .map((message) => `${message.type}: ${message.content}`)
167
+ .join("\n") + `\nUSER: ${prompt}`,
168
+ })
169
+ console.log(res, "RES")
170
+ const lessons: Lesson[] = res.parsed.listOfSteps.map((step: any) =>
171
+ parseLesson(step)
172
+ )
173
+ setSyllabus({
174
+ ...syllabus,
175
+ lessons: lessons,
176
+ courseInfo: {
177
+ ...syllabus.courseInfo,
178
+ title: res.parsed.title || syllabus.courseInfo.title,
179
+ },
180
+ })
181
+ setMessages((prev) => {
182
+ const newMessages = [...prev]
183
+ newMessages[newMessages.length - 1].content = res.parsed.aiMessage
184
+ return newMessages
185
+ })
186
+ }
187
+
188
+ const handleSubmit = async () => {
189
+ const success = await useConsumableCall(auth.bcToken, "ai-generation")
190
+ if (!success) {
191
+ toast.error("You don't have enough credits to generate a course!")
192
+ return
193
+ }
194
+ setIsGenerating(true)
195
+
196
+ const lessonsPromises = syllabus.lessons.map((lesson) =>
197
+ processExercise(
198
+ auth.rigoToken,
199
+ syllabus.lessons,
200
+ JSON.stringify(syllabus.courseInfo),
201
+ lesson,
202
+ "courses/" +
203
+ slugify(syllabus.courseInfo.title || randomUUID()) +
204
+ "/exercises"
205
+ )
206
+ )
207
+ await Promise.all(lessonsPromises)
208
+
209
+ const learnJson = createLearnJson(syllabus.courseInfo)
210
+ await uploadFileToBucket(
211
+ JSON.stringify(learnJson),
212
+ "courses/" +
213
+ slugify(syllabus.courseInfo.title || randomUUID()) +
214
+ "/learn.json"
215
+ )
216
+ setIsGenerating(false)
217
+
218
+ window.location.href = `/?slug=${slugify(
219
+ syllabus.courseInfo.title || "exercises"
220
+ )}&token=${auth.bcToken}`
221
+ }
222
+
223
+ return isGenerating ? (
224
+ <Loader
225
+ listeningTo="course-generation"
226
+ icon={SVGS.rigoSoftBlue}
227
+ initialBuffer="🚀 Starting course generation..."
228
+ text="Learnpack is setting up your tutorial.
229
+ It may take a moment..."
230
+ />
231
+ ) : (
232
+ <div className="flex w-full bg-white rounded-md shadow-md overflow-hidden h-screen ">
233
+ <ConsumablesManager />
234
+ {/* Sidebar */}
235
+ <Sidebar
236
+ messages={messages}
237
+ sendPrompt={sendPrompt}
238
+ handleSubmit={handleSubmit}
239
+ />
240
+
241
+ <div className="w-2/3 p-8 space-y-6">
242
+ <ContentIndexHeader messages={messages} syllabus={syllabus} />
243
+ <ContentIndex
244
+ prevLessons={prevLessons.current}
245
+ handleSubmit={handleSubmit}
246
+ />
247
+ </div>
248
+ </div>
249
+ )
250
+ }
251
+
252
+ export default SyllabusEditor
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"
3
3
  import { BrowserRouter, Route, Routes } from "react-router"
4
4
  import "./index.css"
5
5
  import App from "./App.tsx"
6
- import SyllabusEditor from "./components/SyllabusEditor.tsx"
6
+ import SyllabusEditor from "./components/syllabus/SyllabusEditor.tsx"
7
7
  import { Toaster } from "react-hot-toast"
8
8
 
9
9
  createRoot(document.getElementById("root")!).render(
@@ -1,4 +1,5 @@
1
1
  import axios from "axios"
2
+ import { BREATHECODE_HOST } from "./constants"
2
3
 
3
4
  type ParsedLesson = {
4
5
  id: string
@@ -40,3 +41,82 @@ export const checkParams = () => {
40
41
  const token = urlParams.get("token")
41
42
  return { token }
42
43
  }
44
+
45
+ export async function getConsumables(token: string): Promise<any> {
46
+ const url = `${BREATHECODE_HOST}/v1/payments/me/service/consumable?virtual=true`
47
+
48
+ const headers = {
49
+ Authorization: `Token ${token}`,
50
+ }
51
+
52
+ try {
53
+ const response = await axios.get(url, { headers })
54
+ return response.data
55
+ } catch (error) {
56
+ console.error("Error fetching consumables:", error)
57
+ throw error
58
+ }
59
+ }
60
+
61
+ type ConsumableSlug =
62
+ | "ai-conversation-message"
63
+ | "ai-compilation"
64
+ | "ai-generation"
65
+ | "ai-course-generation"
66
+
67
+ export async function useConsumableCall(
68
+ breathecodeToken: string,
69
+ consumableSlug: ConsumableSlug = "ai-conversation-message"
70
+ ): Promise<boolean> {
71
+ const url = `${BREATHECODE_HOST}/v1/payments/me/service/${consumableSlug}/consumptionsession`
72
+
73
+ const headers = {
74
+ Authorization: `Token ${breathecodeToken}`,
75
+ }
76
+
77
+ try {
78
+ const response = await axios.put(url, {}, { headers })
79
+
80
+ if (response.status >= 200 && response.status < 300) {
81
+ console.log(response.data)
82
+ console.log(`Successfully consumed ${consumableSlug}`)
83
+ return true
84
+ } else {
85
+ console.error(`Request failed with status code: ${response.status}`)
86
+ console.error(`Response: ${response.data}`)
87
+ return false
88
+ }
89
+ } catch (error) {
90
+ console.error(`Error consuming ${consumableSlug}:`, error)
91
+ return false
92
+ }
93
+ }
94
+
95
+ type ConsumableItem = {
96
+ id: number
97
+ how_many: number
98
+ unit_type: string
99
+ valid_until: string | null
100
+ }
101
+
102
+ type VoidEntry = {
103
+ id: number
104
+ slug: string
105
+ balance: { unit: number }
106
+ items: ConsumableItem[]
107
+ }
108
+
109
+ export const parseConsumables = (
110
+ voids: VoidEntry[]
111
+ ): Record<string, number> => {
112
+ const result: Record<string, number> = {}
113
+
114
+ voids.forEach((entry) => {
115
+ const maxHowMany = entry.items.length
116
+ ? Math.max(...entry.items.map((item) => item.how_many))
117
+ : 0
118
+ result[entry.slug] = maxHowMany
119
+ })
120
+
121
+ return result
122
+ }
@@ -18,7 +18,7 @@ type Auth = {
18
18
  rigoToken: string
19
19
  userId: string
20
20
  }
21
- type Syllabus = {
21
+ export type Syllabus = {
22
22
  lessons: Lesson[]
23
23
  courseInfo: FormState
24
24
  uploadedFiles: {
@@ -27,6 +27,10 @@ type Syllabus = {
27
27
  }[]
28
28
  }
29
29
 
30
+ type Consumables = {
31
+ [key: string]: number
32
+ }
33
+
30
34
  type Store = {
31
35
  auth: Auth
32
36
  formState: FormState
@@ -34,6 +38,8 @@ type Store = {
34
38
  syllabus: Syllabus
35
39
  setSyllabus: (syllabus: Partial<Syllabus>) => void
36
40
  setFormState: (formState: Partial<FormState>) => void
41
+ consumables: Consumables
42
+ setConsumables: (consumables: Partial<Consumables>) => void
37
43
  }
38
44
 
39
45
  const useStore = create<Store>()(
@@ -70,6 +76,19 @@ const useStore = create<Store>()(
70
76
  },
71
77
  uploadedFiles: [],
72
78
  },
79
+ consumables: {},
80
+ setConsumables: (consumables: Partial<Consumables>) =>
81
+ set((state) => {
82
+ const sanitized: Consumables = Object.fromEntries(
83
+ Object.entries(consumables).map(([k, v]) => [k, v ?? 0])
84
+ )
85
+ return {
86
+ consumables: {
87
+ ...state.consumables,
88
+ ...sanitized,
89
+ },
90
+ }
91
+ }),
73
92
  setSyllabus: (syllabus: Partial<Syllabus>) =>
74
93
  set((state) => ({ syllabus: { ...state.syllabus, ...syllabus } })),
75
94
  setAuth: (auth: Auth) => set({ auth }),