@learnpack/learnpack 5.0.355 → 5.0.357
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.
- package/lib/commands/serve.d.ts +0 -17
- package/lib/commands/serve.js +159 -129
- package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/lib/creatorDist/index.html +2 -2
- package/lib/models/creator.d.ts +1 -1
- package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
- package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
- package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
- package/lib/services/ingest/core/buildIngestConfig.js +40 -0
- package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
- package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
- package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
- package/lib/services/ingest/core/buildSidebar.js +28 -0
- package/lib/services/ingest/core/coursePaths.d.ts +31 -0
- package/lib/services/ingest/core/coursePaths.js +50 -0
- package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
- package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
- package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
- package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
- package/lib/services/ingest/core/pairLessons.d.ts +29 -0
- package/lib/services/ingest/core/pairLessons.js +102 -0
- package/lib/services/ingest/core/planWrites.d.ts +44 -0
- package/lib/services/ingest/core/planWrites.js +59 -0
- package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
- package/lib/services/ingest/core/resolveSlug.js +93 -0
- package/lib/services/ingest/core/types.d.ts +53 -0
- package/lib/services/ingest/core/types.js +2 -0
- package/lib/services/ingest/core/validatePackage.d.ts +38 -0
- package/lib/services/ingest/core/validatePackage.js +97 -0
- package/lib/services/ingest/createIngestService.d.ts +34 -0
- package/lib/services/ingest/createIngestService.js +38 -0
- package/lib/services/ingest/errors.d.ts +22 -0
- package/lib/services/ingest/errors.js +34 -0
- package/lib/services/ingest/http/errorMapping.d.ts +8 -0
- package/lib/services/ingest/http/errorMapping.js +40 -0
- package/lib/services/ingest/http/router.d.ts +32 -0
- package/lib/services/ingest/http/router.js +124 -0
- package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
- package/lib/services/ingest/http/zipPackageReader.js +62 -0
- package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
- package/lib/services/ingest/ports/courseStorage.js +2 -0
- package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
- package/lib/services/ingest/ports/packageRegistry.js +2 -0
- package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
- package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
- package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
- package/lib/utils/coursePackage/bucketIo.js +36 -0
- package/lib/utils/coursePackage/learnJson.d.ts +26 -0
- package/lib/utils/coursePackage/learnJson.js +36 -0
- package/lib/utils/coursePackage/sidebar.d.ts +15 -0
- package/lib/utils/coursePackage/sidebar.js +26 -0
- package/lib/utils/creatorSocket.js +15 -21
- package/lib/utils/gcpCredentials.d.ts +18 -0
- package/lib/utils/gcpCredentials.js +27 -0
- package/lib/utils/rigoActions.d.ts +16 -0
- package/lib/utils/rigoActions.js +102 -1
- package/lib/utils/socketRegistry.d.ts +46 -0
- package/lib/utils/socketRegistry.js +83 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +162 -114
- package/src/creator/README.md +63 -51
- package/src/creator/package-lock.json +1188 -8
- package/src/creator/package.json +5 -1
- package/src/creator/src/App.tsx +120 -54
- package/src/creator/src/components/FileUploader.tsx +1 -12
- package/src/creator/src/components/NotificationListener.tsx +1 -8
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
- package/src/creator/src/locales/en.json +8 -0
- package/src/creator/src/locales/es.json +8 -0
- package/src/creator/src/utils/completionResult.test.ts +144 -0
- package/src/creator/src/utils/completionResult.ts +154 -0
- package/src/creator/src/utils/constants.ts +17 -4
- package/src/creator/src/utils/socket.test.ts +113 -0
- package/src/creator/src/utils/socket.ts +60 -37
- package/src/creator/src/utils/store.ts +84 -67
- package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
- package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
- package/src/creator/vitest.config.ts +16 -0
- package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/src/creatorDist/index.html +2 -2
- package/src/models/creator.ts +1 -1
- package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
- package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
- package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
- package/src/services/ingest/core/buildIngestConfig.ts +51 -0
- package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
- package/src/services/ingest/core/buildSidebar.ts +32 -0
- package/src/services/ingest/core/coursePaths.ts +59 -0
- package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
- package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
- package/src/services/ingest/core/pairLessons.ts +132 -0
- package/src/services/ingest/core/planWrites.ts +91 -0
- package/src/services/ingest/core/resolveSlug.ts +119 -0
- package/src/services/ingest/core/types.ts +57 -0
- package/src/services/ingest/core/validatePackage.ts +132 -0
- package/src/services/ingest/createIngestService.ts +66 -0
- package/src/services/ingest/errors.ts +45 -0
- package/src/services/ingest/http/errorMapping.ts +49 -0
- package/src/services/ingest/http/router.ts +156 -0
- package/src/services/ingest/http/zipPackageReader.ts +81 -0
- package/src/services/ingest/ports/courseStorage.ts +25 -0
- package/src/services/ingest/ports/packageRegistry.ts +60 -0
- package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
- package/src/ui/_app/app.js +325 -325
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/coursePackage/bucketIo.ts +46 -0
- package/src/utils/coursePackage/learnJson.ts +37 -0
- package/src/utils/coursePackage/sidebar.ts +28 -0
- package/src/utils/creatorSocket.ts +16 -22
- package/src/utils/gcpCredentials.ts +38 -0
- package/src/utils/rigoActions.ts +163 -0
- package/src/utils/socketRegistry.ts +85 -0
- package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
- package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
package/src/creator/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
8
|
"build": "tsc -b && vite build",
|
|
9
|
+
"test": "vitest run",
|
|
9
10
|
"lint": "eslint .",
|
|
10
11
|
"preview": "vite preview",
|
|
11
12
|
"watch": "vite build --watch"
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@eslint/js": "^9.21.0",
|
|
45
|
+
"@testing-library/react": "^16.3.2",
|
|
44
46
|
"@types/react": "^19.0.10",
|
|
45
47
|
"@types/react-dom": "^19.0.4",
|
|
46
48
|
"@vitejs/plugin-react-swc": "^3.8.0",
|
|
@@ -49,10 +51,12 @@
|
|
|
49
51
|
"eslint-plugin-react-hooks": "^5.1.0",
|
|
50
52
|
"eslint-plugin-react-refresh": "^0.4.19",
|
|
51
53
|
"globals": "^15.15.0",
|
|
54
|
+
"jsdom": "^29.1.1",
|
|
52
55
|
"postcss": "^8.5.3",
|
|
53
56
|
"tailwindcss": "^4.1.3",
|
|
54
57
|
"typescript": "~5.7.2",
|
|
55
58
|
"typescript-eslint": "^8.24.1",
|
|
56
|
-
"vite": "^6.2.0"
|
|
59
|
+
"vite": "^6.2.0",
|
|
60
|
+
"vitest": "^4.1.11"
|
|
57
61
|
}
|
|
58
62
|
}
|
package/src/creator/src/App.tsx
CHANGED
|
@@ -28,9 +28,16 @@ import ResumeCourseModal from "./components/ResumeCourseModal"
|
|
|
28
28
|
import { possiblePurposes, PurposeSelector } from "./components/PurposeSelector"
|
|
29
29
|
import { useTranslation } from "react-i18next"
|
|
30
30
|
import NotificationListener from "./components/NotificationListener"
|
|
31
|
+
import {
|
|
32
|
+
COMPLETION_ERROR_I18N_KEY,
|
|
33
|
+
interpretCompletionPayload,
|
|
34
|
+
TCompletionErrorCode,
|
|
35
|
+
} from "./utils/completionResult"
|
|
36
|
+
import { useCompletionWatchdog } from "./utils/useCompletionWatchdog"
|
|
31
37
|
import { slugify } from "./utils/creatorUtils"
|
|
32
38
|
import TurnstileModal from "./components/TurnstileModal"
|
|
33
39
|
import { TMessage } from "./components/Message"
|
|
40
|
+
import { Lesson } from "./components/LessonItem"
|
|
34
41
|
import LanguageDetectionModal from "./components/LanguageDetectionModal"
|
|
35
42
|
|
|
36
43
|
function App() {
|
|
@@ -88,6 +95,13 @@ function App() {
|
|
|
88
95
|
checkTechs()
|
|
89
96
|
}, [])
|
|
90
97
|
|
|
98
|
+
useCompletionWatchdog(notificationId, () => {
|
|
99
|
+
handleCompletionFailure(
|
|
100
|
+
"TIMEOUT",
|
|
101
|
+
`No notification arrived for ${notificationId}`
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
91
105
|
const verifyToken = async () => {
|
|
92
106
|
const { token } = checkParams(["token"])
|
|
93
107
|
if (token) {
|
|
@@ -267,6 +281,111 @@ function App() {
|
|
|
267
281
|
}
|
|
268
282
|
}
|
|
269
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Leaves the loading screen and puts the user back on the last wizard step.
|
|
286
|
+
*
|
|
287
|
+
* Their answers stay in the store, so retrying costs them nothing. The raw
|
|
288
|
+
* detail goes to the console rather than the toast: it carries provider
|
|
289
|
+
* internals, such as the Rigobot team id, that mean nothing to the user.
|
|
290
|
+
* @param code - The failure to report.
|
|
291
|
+
* @param detail - The underlying message, for the console.
|
|
292
|
+
*/
|
|
293
|
+
const handleCompletionFailure = (
|
|
294
|
+
code: TCompletionErrorCode,
|
|
295
|
+
detail: string
|
|
296
|
+
) => {
|
|
297
|
+
console.error("COURSE GENERATION FAILED", code, detail)
|
|
298
|
+
toast.error(t(COMPLETION_ERROR_I18N_KEY[code]))
|
|
299
|
+
setNotificationId("")
|
|
300
|
+
setFormState({
|
|
301
|
+
isCompleted: false,
|
|
302
|
+
currentStep: "hasContentIndex",
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const handleNotification = (payload: unknown) => {
|
|
307
|
+
try {
|
|
308
|
+
const result = interpretCompletionPayload(payload)
|
|
309
|
+
|
|
310
|
+
if (!result.ok) {
|
|
311
|
+
handleCompletionFailure(result.code, result.detail)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const { parsed } = result
|
|
316
|
+
// parseLesson returns null for a step that does not match the expected
|
|
317
|
+
// "01.0 - Title [TYPE: description]" format. Those nulls used to slip into
|
|
318
|
+
// the syllabus unnoticed, because listOfSteps was typed as any.
|
|
319
|
+
const lessons = parsed.listOfSteps
|
|
320
|
+
.map((lesson) => parseLesson(lesson, []))
|
|
321
|
+
.filter((lesson): lesson is Lesson => lesson !== null)
|
|
322
|
+
|
|
323
|
+
if (lessons.length < parsed.listOfSteps.length) {
|
|
324
|
+
console.warn(
|
|
325
|
+
`Dropped ${
|
|
326
|
+
parsed.listOfSteps.length - lessons.length
|
|
327
|
+
} unparseable step(s)`,
|
|
328
|
+
parsed.listOfSteps
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
push({
|
|
333
|
+
lessons,
|
|
334
|
+
courseInfo: {
|
|
335
|
+
...formState,
|
|
336
|
+
title: parsed.title,
|
|
337
|
+
slug: slugify(fixTitleLength(parsed.title)),
|
|
338
|
+
description: parsed.description,
|
|
339
|
+
language: parsed.languageCode || formState.language || "en",
|
|
340
|
+
technologies:
|
|
341
|
+
parsed.technologies.length > 0
|
|
342
|
+
? parsed.technologies
|
|
343
|
+
: ["education", "quizzes"],
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
if (parsed.languageCode) {
|
|
348
|
+
i18n.changeLanguage(parsed.languageCode)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const initialMessages: TMessage[] = [
|
|
352
|
+
{
|
|
353
|
+
type: "user",
|
|
354
|
+
content: formState.description,
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
type: "assistant",
|
|
358
|
+
content: parsed.aiMessage,
|
|
359
|
+
},
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
if (lessons.length > 0) {
|
|
363
|
+
initialMessages.push({
|
|
364
|
+
type: "assistant",
|
|
365
|
+
content: t("contentIndex.okMessage"),
|
|
366
|
+
})
|
|
367
|
+
initialMessages.push({
|
|
368
|
+
type: "assistant",
|
|
369
|
+
content: t("contentIndex.instructionsMessage"),
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
setMessages(initialMessages)
|
|
374
|
+
setNotificationId("")
|
|
375
|
+
navigate("/creator/syllabus")
|
|
376
|
+
setFormState({
|
|
377
|
+
isCompleted: false,
|
|
378
|
+
currentStep: "description",
|
|
379
|
+
})
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// Nothing catches a throw from inside a socket.io listener, so a bug in
|
|
382
|
+
// this handler would strand the UI on the loading screen. Fail loudly
|
|
383
|
+
// into the wizard instead.
|
|
384
|
+
console.error(error, "ERROR HANDLING COURSE NOTIFICATION")
|
|
385
|
+
handleCompletionFailure("MALFORMED_RESPONSE", String(error))
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
270
389
|
const checkTechs = async () => {
|
|
271
390
|
if (technologies.length === 0) {
|
|
272
391
|
const technologies = await getTechnologies()
|
|
@@ -518,60 +637,7 @@ function App() {
|
|
|
518
637
|
/>
|
|
519
638
|
{notificationId && (
|
|
520
639
|
<NotificationListener
|
|
521
|
-
onNotification={
|
|
522
|
-
const lessons = res.parsed.listOfSteps.map((lesson: any) => {
|
|
523
|
-
return parseLesson(lesson, [])
|
|
524
|
-
})
|
|
525
|
-
|
|
526
|
-
push({
|
|
527
|
-
lessons,
|
|
528
|
-
courseInfo: {
|
|
529
|
-
...formState,
|
|
530
|
-
title: res.parsed.title,
|
|
531
|
-
slug: slugify(fixTitleLength(res.parsed.title)),
|
|
532
|
-
description: res.parsed.description,
|
|
533
|
-
language:
|
|
534
|
-
res.parsed.languageCode || formState.language || "en",
|
|
535
|
-
technologies:
|
|
536
|
-
res.parsed.technologies.length > 0
|
|
537
|
-
? res.parsed.technologies
|
|
538
|
-
: ["education", "quizzes"],
|
|
539
|
-
},
|
|
540
|
-
})
|
|
541
|
-
|
|
542
|
-
if (res.parsed.languageCode) {
|
|
543
|
-
i18n.changeLanguage(res.parsed.languageCode)
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
let initialMessages: TMessage[] = [
|
|
547
|
-
{
|
|
548
|
-
type: "user",
|
|
549
|
-
content: formState.description,
|
|
550
|
-
},
|
|
551
|
-
{
|
|
552
|
-
type: "assistant",
|
|
553
|
-
content: res.parsed.aiMessage,
|
|
554
|
-
},
|
|
555
|
-
]
|
|
556
|
-
|
|
557
|
-
if (lessons.length > 0) {
|
|
558
|
-
initialMessages.push({
|
|
559
|
-
type: "assistant",
|
|
560
|
-
content: t("contentIndex.okMessage"),
|
|
561
|
-
})
|
|
562
|
-
initialMessages.push({
|
|
563
|
-
type: "assistant",
|
|
564
|
-
content: t("contentIndex.instructionsMessage"),
|
|
565
|
-
})
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
setMessages(initialMessages)
|
|
569
|
-
navigate("/creator/syllabus")
|
|
570
|
-
setFormState({
|
|
571
|
-
isCompleted: false,
|
|
572
|
-
currentStep: "description",
|
|
573
|
-
})
|
|
574
|
-
}}
|
|
640
|
+
onNotification={handleNotification}
|
|
575
641
|
notificationId={notificationId}
|
|
576
642
|
/>
|
|
577
643
|
)}
|
|
@@ -70,18 +70,7 @@ const UploadedFileCard = ({ idx, file }: { idx: number; file: ParsedFile }) => {
|
|
|
70
70
|
useEffect(() => {
|
|
71
71
|
if (file.status === "SUCCESS") return
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
socketClient.connect()
|
|
75
|
-
socketClient.on(file.notificationId, handleUpdate)
|
|
76
|
-
|
|
77
|
-
socketClient.emit("registerNotification", {
|
|
78
|
-
notificationId: file.notificationId,
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
return () => {
|
|
82
|
-
socketClient.off(file.notificationId, handleUpdate)
|
|
83
|
-
socketClient.disconnect()
|
|
84
|
-
}
|
|
73
|
+
return socketClient.subscribe(file.notificationId, handleUpdate)
|
|
85
74
|
}, [])
|
|
86
75
|
|
|
87
76
|
return (
|
|
@@ -16,14 +16,7 @@ const NotificationListener: React.FC<NotificationListenerProps> = ({
|
|
|
16
16
|
useEffect(() => {
|
|
17
17
|
if (!notificationId) return
|
|
18
18
|
|
|
19
|
-
socketClient.
|
|
20
|
-
socketClient.on(notificationId, onNotification)
|
|
21
|
-
socketClient.emit("registerNotification", { notificationId })
|
|
22
|
-
|
|
23
|
-
return () => {
|
|
24
|
-
socketClient.off(notificationId, onNotification)
|
|
25
|
-
socketClient.disconnect()
|
|
26
|
-
}
|
|
19
|
+
return socketClient.subscribe(notificationId, onNotification)
|
|
27
20
|
}, [notificationId, onNotification])
|
|
28
21
|
|
|
29
22
|
return null
|
|
@@ -30,10 +30,16 @@ import { randomUUID } from "../../utils/creatorUtils"
|
|
|
30
30
|
import { RIGO_FLOAT_GIF } from "../../utils/constants"
|
|
31
31
|
import { useTranslation } from "react-i18next"
|
|
32
32
|
import NotificationListener from "../NotificationListener"
|
|
33
|
+
import {
|
|
34
|
+
COMPLETION_ERROR_I18N_KEY,
|
|
35
|
+
interpretCompletionPayload,
|
|
36
|
+
TCompletionErrorCode,
|
|
37
|
+
} from "../../utils/completionResult"
|
|
38
|
+
import { useCompletionWatchdog } from "../../utils/useCompletionWatchdog"
|
|
33
39
|
|
|
34
40
|
const SyllabusEditor: React.FC = () => {
|
|
35
41
|
const navigate = useNavigate()
|
|
36
|
-
const { i18n } = useTranslation()
|
|
42
|
+
const { t, i18n } = useTranslation()
|
|
37
43
|
|
|
38
44
|
const {
|
|
39
45
|
history,
|
|
@@ -155,6 +161,9 @@ const SyllabusEditor: React.FC = () => {
|
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
if (!isAuthenticated) {
|
|
164
|
+
// Without this the chat stays on "Thinking..." forever, because nothing
|
|
165
|
+
// downstream ever runs to turn it off.
|
|
166
|
+
setIsThinking(false)
|
|
158
167
|
setShowLoginModal(true)
|
|
159
168
|
return
|
|
160
169
|
}
|
|
@@ -200,6 +209,96 @@ const SyllabusEditor: React.FC = () => {
|
|
|
200
209
|
}
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Ends the "Thinking..." state and tells the user why.
|
|
214
|
+
*
|
|
215
|
+
* Drops the empty assistant placeholder that `sendPrompt` optimistically
|
|
216
|
+
* pushed, so the chat does not keep a blank bubble. The syllabus itself is
|
|
217
|
+
* untouched: a failed refinement leaves the previous version in place.
|
|
218
|
+
* @param code - The failure to report.
|
|
219
|
+
* @param detail - The underlying message, for the console.
|
|
220
|
+
*/
|
|
221
|
+
const handleCompletionFailure = (
|
|
222
|
+
code: TCompletionErrorCode,
|
|
223
|
+
detail: string
|
|
224
|
+
) => {
|
|
225
|
+
console.error("SYLLABUS UPDATE FAILED", code, detail)
|
|
226
|
+
toast.error(t(COMPLETION_ERROR_I18N_KEY[code]))
|
|
227
|
+
setMessages((prev) => prev.filter((m) => Boolean(m.content)))
|
|
228
|
+
setIsThinking(false)
|
|
229
|
+
setNotificationId("")
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const handleNotification = (payload: unknown) => {
|
|
233
|
+
try {
|
|
234
|
+
const result = interpretCompletionPayload(payload)
|
|
235
|
+
|
|
236
|
+
if (!result.ok) {
|
|
237
|
+
handleCompletionFailure(result.code, result.detail)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { parsed } = result
|
|
242
|
+
// parseLesson returns null for a step that does not match the expected
|
|
243
|
+
// "01.0 - Title [TYPE: description]" format. Those nulls used to slip into
|
|
244
|
+
// the syllabus unnoticed, because listOfSteps was typed as any.
|
|
245
|
+
const lessons: Lesson[] = parsed.listOfSteps
|
|
246
|
+
.map((step) => parseLesson(step, syllabus.lessons))
|
|
247
|
+
.filter((lesson): lesson is Lesson => lesson !== null)
|
|
248
|
+
|
|
249
|
+
if (lessons.length < parsed.listOfSteps.length) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`Dropped ${
|
|
252
|
+
parsed.listOfSteps.length - lessons.length
|
|
253
|
+
} unparseable step(s)`,
|
|
254
|
+
parsed.listOfSteps
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
push({
|
|
259
|
+
...syllabus,
|
|
260
|
+
lessons: lessons,
|
|
261
|
+
courseInfo: {
|
|
262
|
+
...syllabus.courseInfo,
|
|
263
|
+
title: parsed.title || syllabus.courseInfo.title,
|
|
264
|
+
description: parsed.description || syllabus.courseInfo.description,
|
|
265
|
+
language:
|
|
266
|
+
parsed.languageCode || syllabus.courseInfo.language || "en",
|
|
267
|
+
technologies:
|
|
268
|
+
parsed.technologies.length > 0
|
|
269
|
+
? parsed.technologies
|
|
270
|
+
: syllabus.courseInfo.technologies || [],
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
if (parsed.languageCode) {
|
|
275
|
+
i18n.changeLanguage(parsed.languageCode)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
setMessages((prev) => [
|
|
279
|
+
...prev.filter((m) => Boolean(m.content)),
|
|
280
|
+
{
|
|
281
|
+
type: "assistant",
|
|
282
|
+
content: parsed.aiMessage,
|
|
283
|
+
},
|
|
284
|
+
])
|
|
285
|
+
setIsThinking(false)
|
|
286
|
+
setNotificationId("")
|
|
287
|
+
} catch (error) {
|
|
288
|
+
// A throw inside a socket.io listener is caught by nobody and would leave
|
|
289
|
+
// the chat stuck on "Thinking...".
|
|
290
|
+
console.error(error, "ERROR HANDLING SYLLABUS NOTIFICATION")
|
|
291
|
+
handleCompletionFailure("MALFORMED_RESPONSE", String(error))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
useCompletionWatchdog(notificationId, () => {
|
|
296
|
+
handleCompletionFailure(
|
|
297
|
+
"TIMEOUT",
|
|
298
|
+
`No notification arrived for ${notificationId}`
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
203
302
|
const handleSubmit = async () => {
|
|
204
303
|
if (!auth.bcToken || !auth.rigoToken) {
|
|
205
304
|
setShowLoginModal(true)
|
|
@@ -284,41 +383,7 @@ It may take a moment..."
|
|
|
284
383
|
<div className="flex w-full bg-white rounded-md shadow-md overflow-hidden h-screen ">
|
|
285
384
|
<ParamsChecker />
|
|
286
385
|
<NotificationListener
|
|
287
|
-
onNotification={
|
|
288
|
-
const lessons: Lesson[] = res.parsed.listOfSteps.map((step: any) =>
|
|
289
|
-
parseLesson(step, syllabus.lessons)
|
|
290
|
-
)
|
|
291
|
-
push({
|
|
292
|
-
...syllabus,
|
|
293
|
-
lessons: lessons,
|
|
294
|
-
courseInfo: {
|
|
295
|
-
...syllabus.courseInfo,
|
|
296
|
-
title: res.parsed.title || syllabus.courseInfo.title,
|
|
297
|
-
description:
|
|
298
|
-
res.parsed.description || syllabus.courseInfo.description,
|
|
299
|
-
language:
|
|
300
|
-
res.parsed.languageCode || syllabus.courseInfo.language || "en",
|
|
301
|
-
technologies:
|
|
302
|
-
res.parsed.technologies.length > 0
|
|
303
|
-
? res.parsed.technologies
|
|
304
|
-
: syllabus.courseInfo.technologies || [],
|
|
305
|
-
},
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
if (res.parsed.languageCode) {
|
|
309
|
-
i18n.changeLanguage(res.parsed.languageCode)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
setMessages((prev) => [
|
|
313
|
-
...prev.filter((m) => Boolean(m.content)),
|
|
314
|
-
{
|
|
315
|
-
type: "assistant",
|
|
316
|
-
content: res.parsed.aiMessage,
|
|
317
|
-
},
|
|
318
|
-
])
|
|
319
|
-
setIsThinking(false)
|
|
320
|
-
setNotificationId("")
|
|
321
|
-
}}
|
|
386
|
+
onNotification={handleNotification}
|
|
322
387
|
notificationId={notificationId}
|
|
323
388
|
/>
|
|
324
389
|
{showLoginModal && (
|
|
@@ -87,6 +87,14 @@
|
|
|
87
87
|
"text": "Learnpack is setting up your tutorial. It may take a moment...",
|
|
88
88
|
"thinking": "Thinking..."
|
|
89
89
|
},
|
|
90
|
+
"completionError": {
|
|
91
|
+
"quotaExceeded": "The course generation service has run out of capacity right now. Please try again later or contact support.",
|
|
92
|
+
"rateLimited": "Too many requests at the moment. Please wait a few seconds and try again.",
|
|
93
|
+
"providerUnavailable": "The course generation service is temporarily unavailable. Please try again in a few minutes.",
|
|
94
|
+
"malformedResponse": "We couldn't read the generated syllabus. Please try again.",
|
|
95
|
+
"timeout": "This is taking longer than expected, so we stopped waiting. Your answers were kept, please try again.",
|
|
96
|
+
"unknown": "Something went wrong while generating the course. Please try again."
|
|
97
|
+
},
|
|
90
98
|
"sidebar": {
|
|
91
99
|
"chatWithMe": "Chat with me to update the course content",
|
|
92
100
|
"howCanLearnPackHelpYou": "How can LearnPack help you?"
|
|
@@ -87,6 +87,14 @@
|
|
|
87
87
|
"text": "Learnpack está configurando tu tutorial. Puede que tarde un momento...",
|
|
88
88
|
"thinking": "Pensando..."
|
|
89
89
|
},
|
|
90
|
+
"completionError": {
|
|
91
|
+
"quotaExceeded": "El servicio de generación de cursos no tiene capacidad disponible en este momento. Inténtalo más tarde o contacta con soporte.",
|
|
92
|
+
"rateLimited": "Demasiadas solicitudes en este momento. Espera unos segundos e inténtalo de nuevo.",
|
|
93
|
+
"providerUnavailable": "El servicio de generación de cursos no está disponible temporalmente. Inténtalo de nuevo en unos minutos.",
|
|
94
|
+
"malformedResponse": "No pudimos leer el temario generado. Inténtalo de nuevo.",
|
|
95
|
+
"timeout": "Esto está tardando más de lo esperado y dejamos de esperar. Guardamos tus respuestas, inténtalo de nuevo.",
|
|
96
|
+
"unknown": "Algo salió mal al generar el curso. Inténtalo de nuevo."
|
|
97
|
+
},
|
|
90
98
|
"sidebar": {
|
|
91
99
|
"chatWithMe": "Chatea conmigo para actualizar el contenido del curso",
|
|
92
100
|
"howCanLearnPackHelpYou": "¿Cómo puede LearnPack ayudarte?"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
COMPLETION_ERROR_I18N_KEY,
|
|
4
|
+
interpretCompletionPayload,
|
|
5
|
+
TCompletionErrorCode,
|
|
6
|
+
} from "./completionResult";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The payload that actually stranded the creator: a job Rigobot ran and failed,
|
|
10
|
+
* with `parsed: null`. Reading `parsed.listOfSteps` on this is what threw inside
|
|
11
|
+
* the socket listener.
|
|
12
|
+
*/
|
|
13
|
+
const QUOTA_FAILURE = {
|
|
14
|
+
id: 642271,
|
|
15
|
+
status: "ERROR",
|
|
16
|
+
status_text:
|
|
17
|
+
"Error code: 403 - {'code': 'permission-denied', 'error': 'Your team " +
|
|
18
|
+
"fde60f91-0622-41c0-a31a-566d8df3497b has either used all available " +
|
|
19
|
+
"credits or reached its monthly spending limit. To continue making API " +
|
|
20
|
+
"requests, please purchase more credits or raise your spending limit.'}",
|
|
21
|
+
answer: null,
|
|
22
|
+
parsed: null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SUCCESSFUL_JOB = {
|
|
26
|
+
id: 1,
|
|
27
|
+
status: "SUCCESS",
|
|
28
|
+
status_text: "",
|
|
29
|
+
parsed: {
|
|
30
|
+
title: "Claude Code Skills",
|
|
31
|
+
description: "A short course about writing skills for Claude Code.",
|
|
32
|
+
languageCode: "en",
|
|
33
|
+
technologies: ["agent-skills", "ai"],
|
|
34
|
+
aiMessage: "Here is the syllabus I drafted.",
|
|
35
|
+
listOfSteps: ["00.0 - Welcome [READ: ...]", "01.0 - Skills [READ: ...]"],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("interpretCompletionPayload", () => {
|
|
40
|
+
it("returns the parsed syllabus for a successful job", () => {
|
|
41
|
+
const result = interpretCompletionPayload(SUCCESSFUL_JOB);
|
|
42
|
+
|
|
43
|
+
expect(result.ok).toBe(true);
|
|
44
|
+
if (!result.ok) return;
|
|
45
|
+
|
|
46
|
+
expect(result.parsed.listOfSteps).toHaveLength(2);
|
|
47
|
+
expect(result.parsed.title).toBe("Claude Code Skills");
|
|
48
|
+
expect(result.parsed.languageCode).toBe("en");
|
|
49
|
+
expect(result.parsed.technologies).toEqual(["agent-skills", "ai"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reports the credits failure from the incident as a quota problem", () => {
|
|
53
|
+
const result = interpretCompletionPayload(QUOTA_FAILURE);
|
|
54
|
+
|
|
55
|
+
expect(result.ok).toBe(false);
|
|
56
|
+
if (result.ok) return;
|
|
57
|
+
|
|
58
|
+
expect(result.code).toBe("QUOTA_EXCEEDED");
|
|
59
|
+
expect(result.detail).toContain("permission-denied");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not throw on the payload shape that used to crash the listener", () => {
|
|
63
|
+
expect(() => interpretCompletionPayload(QUOTA_FAILURE)).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const statusTexts: Array<[string, TCompletionErrorCode]> = [
|
|
67
|
+
["Your team has used all available credits", "QUOTA_EXCEEDED"],
|
|
68
|
+
["monthly spending limit reached", "QUOTA_EXCEEDED"],
|
|
69
|
+
["Error code: 429 - rate limit exceeded", "RATE_LIMITED"],
|
|
70
|
+
["Error code: 503 - service unavailable", "PROVIDER_UNAVAILABLE"],
|
|
71
|
+
["The request timed out", "PROVIDER_UNAVAILABLE"],
|
|
72
|
+
["Missing key description inside inputs dictionary", "UNKNOWN"],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
statusTexts.forEach(([statusText, expected]) => {
|
|
76
|
+
it(`classifies "${statusText.slice(0, 40)}..." as ${expected}`, () => {
|
|
77
|
+
const result = interpretCompletionPayload({
|
|
78
|
+
status: "ERROR",
|
|
79
|
+
status_text: statusText,
|
|
80
|
+
parsed: null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.ok).toBe(false);
|
|
84
|
+
if (result.ok) return;
|
|
85
|
+
expect(result.code).toBe(expected);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("treats a SUCCESS job without a list of steps as malformed", () => {
|
|
90
|
+
const result = interpretCompletionPayload({
|
|
91
|
+
status: "SUCCESS",
|
|
92
|
+
parsed: { title: "Something", listOfSteps: null },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result.ok).toBe(false);
|
|
96
|
+
if (result.ok) return;
|
|
97
|
+
expect(result.code).toBe("MALFORMED_RESPONSE");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("fills in the optional fields the syllabus screens read", () => {
|
|
101
|
+
const result = interpretCompletionPayload({
|
|
102
|
+
status: "SUCCESS",
|
|
103
|
+
parsed: { listOfSteps: ["00.0 - Welcome [READ: ...]"] },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.ok).toBe(true);
|
|
107
|
+
if (!result.ok) return;
|
|
108
|
+
|
|
109
|
+
// The screens do `parsed.technologies.length > 0` and `parsed.title || ...`,
|
|
110
|
+
// so these have to exist even when Rigobot omits them.
|
|
111
|
+
expect(result.parsed.technologies).toEqual([]);
|
|
112
|
+
expect(result.parsed.title).toBe("");
|
|
113
|
+
expect(result.parsed.aiMessage).toBe("");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const junk: Array<[string, unknown]> = [
|
|
117
|
+
["null", null],
|
|
118
|
+
["undefined", undefined],
|
|
119
|
+
["a string", "not a job"],
|
|
120
|
+
["a number", 42],
|
|
121
|
+
["an empty object", {}],
|
|
122
|
+
["a job with no parsed field", { status: "SUCCESS" }],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
junk.forEach(([label, payload]) => {
|
|
126
|
+
it(`reports ${label} as malformed instead of throwing`, () => {
|
|
127
|
+
const result = interpretCompletionPayload(payload);
|
|
128
|
+
|
|
129
|
+
expect(result.ok).toBe(false);
|
|
130
|
+
if (result.ok) return;
|
|
131
|
+
expect(result.code).toBe("MALFORMED_RESPONSE");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("COMPLETION_ERROR_I18N_KEY", () => {
|
|
137
|
+
it("has a distinct key for every failure code", () => {
|
|
138
|
+
const keys = Object.values(COMPLETION_ERROR_I18N_KEY);
|
|
139
|
+
|
|
140
|
+
expect(keys.length).toBeGreaterThan(0);
|
|
141
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
142
|
+
keys.forEach((key) => expect(key).toMatch(/^completionError\./));
|
|
143
|
+
});
|
|
144
|
+
});
|