@learnpack/learnpack 5.0.362 → 5.0.364
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.js +444 -279
- package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/lib/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
- package/lib/creatorDist/index.html +2 -2
- package/lib/models/creator.d.ts +1 -0
- package/lib/utils/api.d.ts +0 -9
- package/lib/utils/api.js +1 -65
- package/lib/utils/generationRetry.d.ts +48 -0
- package/lib/utils/generationRetry.js +81 -0
- package/lib/utils/interactivity/componentsGuide.d.ts +50 -0
- package/lib/utils/interactivity/componentsGuide.js +193 -0
- package/lib/utils/interactivity/interactivityPlan.d.ts +20 -0
- package/lib/utils/interactivity/interactivityPlan.js +33 -0
- package/lib/utils/interactivity/structuredComponents.d.ts +34 -0
- package/lib/utils/interactivity/structuredComponents.js +792 -0
- package/lib/utils/rigoActions.d.ts +1 -1
- package/lib/utils/rigoActions.js +2 -2
- package/package.json +1 -1
- package/src/commands/serve.ts +653 -397
- package/src/creator/package-lock.json +297 -3
- package/src/creator/package.json +1 -0
- package/src/creator/src/App.test.tsx +124 -1
- package/src/creator/src/App.tsx +52 -36
- package/src/creator/src/components/FileCard.tsx +63 -19
- package/src/creator/src/components/FileReadListener.test.tsx +128 -0
- package/src/creator/src/components/FileReadListener.tsx +75 -0
- package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
- package/src/creator/src/components/FileReviewModal.tsx +128 -0
- package/src/creator/src/components/FileUploader.test.tsx +229 -0
- package/src/creator/src/components/FileUploader.tsx +212 -58
- package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
- package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
- package/src/creator/src/index.css +45 -0
- package/src/creator/src/locales/en.json +27 -5
- package/src/creator/src/locales/es.json +27 -5
- package/src/creator/src/main.tsx +2 -0
- package/src/creator/src/utils/completionResult.test.ts +61 -0
- package/src/creator/src/utils/completionResult.ts +61 -2
- package/src/creator/src/utils/courseLanguage.test.ts +6 -0
- package/src/creator/src/utils/creationRequest.test.ts +53 -18
- package/src/creator/src/utils/creationRequest.ts +17 -12
- package/src/creator/src/utils/fileRead.ts +60 -0
- package/src/creator/src/utils/lib.ts +0 -5
- package/src/creator/src/utils/store.ts +0 -10
- package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/src/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
- package/src/creatorDist/index.html +2 -2
- package/src/models/creator.ts +87 -84
- package/src/utils/api.ts +0 -83
- package/src/utils/generationRetry.ts +100 -0
- package/src/utils/interactivity/componentsGuide.ts +294 -0
- package/src/utils/interactivity/interactivityPlan.ts +57 -0
- package/src/utils/interactivity/structuredComponents.ts +1004 -0
- package/src/utils/rigoActions.ts +3 -2
package/src/creator/src/App.tsx
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
loginWithToken,
|
|
14
14
|
parseLesson,
|
|
15
15
|
fixTitleLength,
|
|
16
|
-
getTechnologies,
|
|
17
16
|
detectLanguage,
|
|
18
17
|
isValidPublicToken,
|
|
19
18
|
} from "./utils/lib"
|
|
@@ -29,15 +28,16 @@ import { possiblePurposes, PurposeSelector } from "./components/PurposeSelector"
|
|
|
29
28
|
import { useTranslation } from "react-i18next"
|
|
30
29
|
import NotificationListener from "./components/NotificationListener"
|
|
31
30
|
import {
|
|
32
|
-
COMPLETION_ERROR_I18N_KEY,
|
|
33
31
|
interpretCompletionPayload,
|
|
34
32
|
TCompletionErrorCode,
|
|
33
|
+
TGenerationFailure,
|
|
35
34
|
} from "./utils/completionResult"
|
|
36
35
|
import { useCompletionWatchdog } from "./utils/useCompletionWatchdog"
|
|
37
36
|
import { slugify } from "./utils/creatorUtils"
|
|
38
37
|
import TurnstileModal from "./components/TurnstileModal"
|
|
39
38
|
import { TMessage } from "./components/Message"
|
|
40
39
|
import { Lesson } from "./components/LessonItem"
|
|
40
|
+
import GenerationErrorCard from "./components/GenerationErrorCard"
|
|
41
41
|
import LanguageDetectionModal from "./components/LanguageDetectionModal"
|
|
42
42
|
import CourseLanguagePicker, {
|
|
43
43
|
TCourseLanguageChoice,
|
|
@@ -68,15 +68,11 @@ function App() {
|
|
|
68
68
|
resetFormState,
|
|
69
69
|
cleanAll,
|
|
70
70
|
setMessages,
|
|
71
|
-
technologies,
|
|
72
|
-
setTechnologies,
|
|
73
71
|
} = useStore(
|
|
74
72
|
useShallow((state) => ({
|
|
75
73
|
formState: state.formState,
|
|
76
74
|
setFormState: state.setFormState,
|
|
77
75
|
setAuth: state.setAuth,
|
|
78
|
-
technologies: state.technologies,
|
|
79
|
-
setTechnologies: state.setTechnologies,
|
|
80
76
|
push: state.push,
|
|
81
77
|
history: state.history,
|
|
82
78
|
cleanHistory: state.cleanHistory,
|
|
@@ -90,6 +86,8 @@ function App() {
|
|
|
90
86
|
)
|
|
91
87
|
|
|
92
88
|
const [notificationId, setNotificationId] = useState<string>("")
|
|
89
|
+
const [generationFailure, setGenerationFailure] =
|
|
90
|
+
useState<TGenerationFailure | null>(null)
|
|
93
91
|
const [showTurnstileModal, setShowTurnstileModal] = useState(false)
|
|
94
92
|
const [showLanguageDetectionModal, setShowLanguageDetectionModal] = useState(false)
|
|
95
93
|
const [detectedLanguage, setDetectedLanguage] = useState<string>("")
|
|
@@ -98,6 +96,9 @@ function App() {
|
|
|
98
96
|
// video transcript), for detecting the course language. Uploaded files are
|
|
99
97
|
// read from the store instead.
|
|
100
98
|
const seedTextRef = useRef<string | null>(null)
|
|
99
|
+
// Refs, because a retry clears the failure on screen but must not forget them.
|
|
100
|
+
const failedAttemptsRef = useRef(0)
|
|
101
|
+
const jobReferenceRef = useRef("")
|
|
101
102
|
// Course language settled when generation is fired, reused when the result
|
|
102
103
|
// arrives so the stored language matches the one that was sent.
|
|
103
104
|
const courseLanguageRef = useRef<TCourseLanguage | null>(null)
|
|
@@ -116,7 +117,6 @@ function App() {
|
|
|
116
117
|
useEffect(() => {
|
|
117
118
|
verifyToken()
|
|
118
119
|
checkQueryParams()
|
|
119
|
-
checkTechs()
|
|
120
120
|
}, [])
|
|
121
121
|
|
|
122
122
|
useCompletionWatchdog(notificationId, () => {
|
|
@@ -266,6 +266,7 @@ function App() {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
const handleCreateTutorial = async () => {
|
|
269
|
+
jobReferenceRef.current = ""
|
|
269
270
|
try {
|
|
270
271
|
const decision = decideLanguageForGeneration()
|
|
271
272
|
if (decision.kind === "ask") {
|
|
@@ -308,19 +309,10 @@ function App() {
|
|
|
308
309
|
return
|
|
309
310
|
}
|
|
310
311
|
|
|
311
|
-
let techs = technologies.filter(
|
|
312
|
-
(t) => t.lang === (courseLanguage ?? formState.language)
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
if (techs.length === 0) {
|
|
316
|
-
techs = technologies.filter((t) => t.lang === "en")
|
|
317
|
-
}
|
|
318
|
-
|
|
319
312
|
const res = await publicInteractiveCreation(
|
|
320
313
|
{
|
|
321
314
|
courseInfo: buildCourseInfo({
|
|
322
315
|
formState,
|
|
323
|
-
techs,
|
|
324
316
|
uploadedFiles,
|
|
325
317
|
language: courseLanguage,
|
|
326
318
|
}),
|
|
@@ -330,48 +322,74 @@ function App() {
|
|
|
330
322
|
formState.purpose || "learnpack-lesson-writer",
|
|
331
323
|
tokenType === "rigo" ? false : true
|
|
332
324
|
)
|
|
325
|
+
if (!res) {
|
|
326
|
+
handleCompletionFailure("UNKNOWN", "The completion request failed")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
jobReferenceRef.current = String(res.res?.id ?? "")
|
|
333
331
|
setNotificationId(res.notificationId)
|
|
334
332
|
} catch (error) {
|
|
335
333
|
console.error(error, "ERROR CREATING TUTORIAL")
|
|
336
|
-
|
|
337
|
-
setFormState({
|
|
338
|
-
isCompleted: false,
|
|
339
|
-
currentStep: "hasContentIndex",
|
|
340
|
-
})
|
|
334
|
+
handleCompletionFailure("UNKNOWN", String(error))
|
|
341
335
|
}
|
|
342
336
|
}
|
|
343
337
|
|
|
344
338
|
/**
|
|
345
|
-
* Leaves the loading screen
|
|
339
|
+
* Leaves the loading screen for the error screen, which stays until the user
|
|
340
|
+
* acts on it.
|
|
346
341
|
*
|
|
347
342
|
* Their answers stay in the store, so retrying costs them nothing. The raw
|
|
348
|
-
* detail goes to the console rather than the
|
|
343
|
+
* detail goes to the console rather than the screen: it carries provider
|
|
349
344
|
* internals, such as the Rigobot team id, that mean nothing to the user.
|
|
350
345
|
* @param code - The failure to report.
|
|
351
346
|
* @param detail - The underlying message, for the console.
|
|
347
|
+
* @param reference - The job the failure came from, when the failure names it.
|
|
352
348
|
*/
|
|
353
349
|
const handleCompletionFailure = (
|
|
354
350
|
code: TCompletionErrorCode,
|
|
355
|
-
detail: string
|
|
351
|
+
detail: string,
|
|
352
|
+
reference = ""
|
|
356
353
|
) => {
|
|
357
354
|
console.error("COURSE GENERATION FAILED", code, detail)
|
|
358
|
-
|
|
355
|
+
failedAttemptsRef.current += 1
|
|
356
|
+
setGenerationFailure({
|
|
357
|
+
code,
|
|
358
|
+
reference: reference || jobReferenceRef.current,
|
|
359
|
+
attempts: failedAttemptsRef.current,
|
|
360
|
+
})
|
|
359
361
|
setNotificationId("")
|
|
362
|
+
|
|
363
|
+
const { formState: current, uploadedFiles: files } = useStore.getState()
|
|
360
364
|
setFormState({
|
|
361
365
|
isCompleted: false,
|
|
362
|
-
currentStep:
|
|
366
|
+
currentStep:
|
|
367
|
+
files.length > 0 || current.contentIndex.length > 0
|
|
368
|
+
? "contentIndex"
|
|
369
|
+
: "hasContentIndex",
|
|
363
370
|
})
|
|
364
371
|
}
|
|
365
372
|
|
|
373
|
+
const handleRetryGeneration = () => {
|
|
374
|
+
setGenerationFailure(null)
|
|
375
|
+
setFormState({ isCompleted: true })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const handleReviewAnswers = () => {
|
|
379
|
+
failedAttemptsRef.current = 0
|
|
380
|
+
setGenerationFailure(null)
|
|
381
|
+
}
|
|
382
|
+
|
|
366
383
|
const handleNotification = (payload: unknown) => {
|
|
367
384
|
try {
|
|
368
385
|
const result = interpretCompletionPayload(payload)
|
|
369
386
|
|
|
370
387
|
if (!result.ok) {
|
|
371
|
-
handleCompletionFailure(result.code, result.detail)
|
|
388
|
+
handleCompletionFailure(result.code, result.detail, result.reference)
|
|
372
389
|
return
|
|
373
390
|
}
|
|
374
391
|
|
|
392
|
+
failedAttemptsRef.current = 0
|
|
375
393
|
const { parsed } = result
|
|
376
394
|
// parseLesson returns null for a step that does not match the expected
|
|
377
395
|
// "01.0 - Title [TYPE: description]" format. Those nulls used to slip into
|
|
@@ -461,14 +479,6 @@ function App() {
|
|
|
461
479
|
}
|
|
462
480
|
}
|
|
463
481
|
|
|
464
|
-
const checkTechs = async () => {
|
|
465
|
-
if (technologies.length === 0) {
|
|
466
|
-
const technologies = await getTechnologies()
|
|
467
|
-
console.log("TECHNOLOGIES", technologies)
|
|
468
|
-
setTechnologies(technologies)
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
482
|
const handleLanguageSwitch = () => {
|
|
473
483
|
setFormState({
|
|
474
484
|
language: detectedLanguage,
|
|
@@ -749,7 +759,13 @@ function App() {
|
|
|
749
759
|
}}
|
|
750
760
|
/>
|
|
751
761
|
)}
|
|
752
|
-
{
|
|
762
|
+
{generationFailure ? (
|
|
763
|
+
<GenerationErrorCard
|
|
764
|
+
failure={generationFailure}
|
|
765
|
+
onRetry={handleRetryGeneration}
|
|
766
|
+
onReview={handleReviewAnswers}
|
|
767
|
+
/>
|
|
768
|
+
) : formState.isCompleted && history.length === 0 ? (
|
|
753
769
|
<>
|
|
754
770
|
<Loader
|
|
755
771
|
text={t("loader.text")}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import toast from "react-hot-toast"
|
|
3
|
+
import { useTranslation } from "react-i18next"
|
|
1
4
|
import { SVGS } from "../assets/svgs"
|
|
5
|
+
import { readNotices } from "../utils/fileRead"
|
|
6
|
+
import { FileReviewButton } from "./FileReviewModal"
|
|
2
7
|
import { ParsedFile } from "./FileUploader"
|
|
3
8
|
import {
|
|
4
9
|
Tooltip,
|
|
@@ -12,23 +17,62 @@ export const FileCard = ({
|
|
|
12
17
|
}: {
|
|
13
18
|
file: ParsedFile
|
|
14
19
|
handleRemove: () => void
|
|
15
|
-
}) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
}) => {
|
|
21
|
+
const { t } = useTranslation()
|
|
22
|
+
const notices = readNotices(file, t)
|
|
23
|
+
const isReading = useRef(file.status === "PROCESSING")
|
|
24
|
+
|
|
25
|
+
// The sidebar never asks before reading, so a read that needs attention
|
|
26
|
+
// speaks up once when it ends. Reloading the page must not repeat it.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!isReading.current || file.status === "PROCESSING") return
|
|
29
|
+
isReading.current = false
|
|
30
|
+
|
|
31
|
+
if (file.status === "ERROR") {
|
|
32
|
+
toast.error(t("uploader.files.readFailed"))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
readNotices(file, t).forEach((notice) => toast(notice, { icon: "⚠️" }))
|
|
37
|
+
}, [file, t])
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={
|
|
42
|
+
"flex items-center justify-between gap-1 shadow-sm p-2 rounded-lg w-[100px] " +
|
|
43
|
+
(file.status === "ERROR"
|
|
44
|
+
? "bg-red-100"
|
|
45
|
+
: notices.length > 0
|
|
46
|
+
? "bg-amber-100"
|
|
47
|
+
: "bg-white")
|
|
48
|
+
}
|
|
30
49
|
>
|
|
31
|
-
{
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
{file.status === "PROCESSING" && (
|
|
51
|
+
<div className="shrink-0 w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
52
|
+
)}
|
|
53
|
+
<Tooltip>
|
|
54
|
+
<TooltipTrigger asChild>
|
|
55
|
+
<span className="text-xs text-gray-800 truncate">
|
|
56
|
+
{file.status === "SUCCESS" ? (
|
|
57
|
+
<FileReviewButton file={file} label={file.name} />
|
|
58
|
+
) : (
|
|
59
|
+
file.name
|
|
60
|
+
)}
|
|
61
|
+
</span>
|
|
62
|
+
</TooltipTrigger>
|
|
63
|
+
<TooltipContent>
|
|
64
|
+
<p>{file.name}</p>
|
|
65
|
+
{notices.map((notice) => (
|
|
66
|
+
<p key={notice}>{notice}</p>
|
|
67
|
+
))}
|
|
68
|
+
</TooltipContent>
|
|
69
|
+
</Tooltip>
|
|
70
|
+
<button
|
|
71
|
+
onClick={handleRemove}
|
|
72
|
+
className="p-1 text-red-500 hover:text-red-700 transition-colors cursor-pointer "
|
|
73
|
+
>
|
|
74
|
+
{SVGS.trash}
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
3
|
+
import FileReadListener, { FILE_READ_TIMEOUT_MS } from "./FileReadListener";
|
|
4
|
+
import useStore from "../utils/store";
|
|
5
|
+
import type { ParsedFile } from "./FileUploader";
|
|
6
|
+
|
|
7
|
+
const sockets = vi.hoisted(() => ({
|
|
8
|
+
listeners: new Map<string, (data: unknown) => void>(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../utils/socket", () => ({
|
|
12
|
+
default: class {
|
|
13
|
+
subscribe(id: string, callback: (data: unknown) => void) {
|
|
14
|
+
sockets.listeners.set(id, callback);
|
|
15
|
+
return () => sockets.listeners.delete(id);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const reading = (name: string): ParsedFile => ({
|
|
21
|
+
name,
|
|
22
|
+
text: "",
|
|
23
|
+
status: "PROCESSING",
|
|
24
|
+
notificationId: `read-${name}`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const answer = (name: string, data: unknown) =>
|
|
28
|
+
act(() => sockets.listeners.get(`read-${name}`)?.(data));
|
|
29
|
+
|
|
30
|
+
const file = (name: string) =>
|
|
31
|
+
useStore.getState().uploadedFiles.find((f) => f.name === name);
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
sockets.listeners.clear();
|
|
35
|
+
useStore.getState().setUploadedFiles([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
cleanup();
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("FileReadListener", () => {
|
|
44
|
+
it("stores the text and the report of a finished read", () => {
|
|
45
|
+
useStore.getState().setUploadedFiles([reading("a.pdf")]);
|
|
46
|
+
render(<FileReadListener />);
|
|
47
|
+
|
|
48
|
+
const report = {
|
|
49
|
+
total_pages: 1,
|
|
50
|
+
ai_pages: [1],
|
|
51
|
+
unread_pages: [],
|
|
52
|
+
failed_pages: [],
|
|
53
|
+
};
|
|
54
|
+
answer("a.pdf", { text: "\n[Page 1]\nHello", report });
|
|
55
|
+
|
|
56
|
+
expect(file("a.pdf")).toMatchObject({
|
|
57
|
+
status: "SUCCESS",
|
|
58
|
+
text: "\n[Page 1]\nHello",
|
|
59
|
+
report,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps both answers when two files finish in turn", () => {
|
|
64
|
+
useStore.getState().setUploadedFiles([reading("a.pdf"), reading("b.pdf")]);
|
|
65
|
+
render(<FileReadListener />);
|
|
66
|
+
|
|
67
|
+
answer("a.pdf", { text: "first" });
|
|
68
|
+
answer("b.pdf", { text: "second" });
|
|
69
|
+
|
|
70
|
+
expect(file("a.pdf")).toMatchObject({ status: "SUCCESS", text: "first" });
|
|
71
|
+
expect(file("b.pdf")).toMatchObject({ status: "SUCCESS", text: "second" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("hears files added from anywhere, without an upload card on screen", () => {
|
|
75
|
+
render(<FileReadListener />);
|
|
76
|
+
|
|
77
|
+
act(() => useStore.getState().setUploadedFiles([reading("sidebar.pdf")]));
|
|
78
|
+
answer("sidebar.pdf", { text: "Read from the sidebar" });
|
|
79
|
+
|
|
80
|
+
expect(file("sidebar.pdf")?.text).toBe("Read from the sidebar");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not touch a newer file that took the name of an old read", () => {
|
|
84
|
+
useStore.getState().setUploadedFiles([reading("a.pdf")]);
|
|
85
|
+
render(<FileReadListener />);
|
|
86
|
+
const oldAnswer = sockets.listeners.get("read-a.pdf");
|
|
87
|
+
|
|
88
|
+
act(() =>
|
|
89
|
+
useStore.getState().setUploadedFiles([
|
|
90
|
+
{ ...reading("a.pdf"), notificationId: "read-a.pdf-new" },
|
|
91
|
+
])
|
|
92
|
+
);
|
|
93
|
+
act(() => oldAnswer?.({ text: "old version" }));
|
|
94
|
+
|
|
95
|
+
expect(file("a.pdf")).toMatchObject({ status: "PROCESSING", text: "" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("marks the file as failed when the read comes back empty", () => {
|
|
99
|
+
useStore.getState().setUploadedFiles([reading("a.pdf")]);
|
|
100
|
+
render(<FileReadListener />);
|
|
101
|
+
|
|
102
|
+
answer("a.pdf", { text: "", status: "ERROR", error_code: "read_failed" });
|
|
103
|
+
|
|
104
|
+
expect(file("a.pdf")?.status).toBe("ERROR");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("gives up on a read that never answers", () => {
|
|
108
|
+
vi.useFakeTimers();
|
|
109
|
+
useStore.getState().setUploadedFiles([reading("a.pdf")]);
|
|
110
|
+
render(<FileReadListener />);
|
|
111
|
+
|
|
112
|
+
act(() => {
|
|
113
|
+
vi.advanceTimersByTime(FILE_READ_TIMEOUT_MS);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(file("a.pdf")?.status).toBe("ERROR");
|
|
117
|
+
expect(sockets.listeners.size).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("stops listening once the file is removed", () => {
|
|
121
|
+
useStore.getState().setUploadedFiles([reading("a.pdf")]);
|
|
122
|
+
render(<FileReadListener />);
|
|
123
|
+
|
|
124
|
+
act(() => useStore.getState().setUploadedFiles([]));
|
|
125
|
+
|
|
126
|
+
expect(sockets.listeners.size).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect } from "react"
|
|
2
|
+
import useStore from "../utils/store"
|
|
3
|
+
import CreatorSocket from "../utils/socket"
|
|
4
|
+
import type { ParsedFile } from "./FileUploader"
|
|
5
|
+
|
|
6
|
+
export const FILE_READ_TIMEOUT_MS = 5 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
const socketClient = new CreatorSocket("")
|
|
9
|
+
|
|
10
|
+
// Reads the store at call time: this runs long after the subscription was
|
|
11
|
+
// created, and two files can finish while the same list is on screen.
|
|
12
|
+
const updateFile = (notificationId: string, changes: Partial<ParsedFile>) => {
|
|
13
|
+
const { uploadedFiles, setUploadedFiles } = useStore.getState()
|
|
14
|
+
setUploadedFiles(
|
|
15
|
+
uploadedFiles.map((file) =>
|
|
16
|
+
file.notificationId === notificationId ? { ...file, ...changes } : file
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FileReadSubscription = ({
|
|
22
|
+
notificationId,
|
|
23
|
+
}: {
|
|
24
|
+
notificationId: string
|
|
25
|
+
}) => {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const timeout = setTimeout(() => {
|
|
28
|
+
updateFile(notificationId, { status: "ERROR" })
|
|
29
|
+
}, FILE_READ_TIMEOUT_MS)
|
|
30
|
+
|
|
31
|
+
const onAnswer = (data: Pick<ParsedFile, "text" | "report">) => {
|
|
32
|
+
updateFile(
|
|
33
|
+
notificationId,
|
|
34
|
+
data.text
|
|
35
|
+
? { status: "SUCCESS", text: data.text, report: data.report }
|
|
36
|
+
: { status: "ERROR" }
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const release = socketClient.subscribe(notificationId, onAnswer)
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
clearTimeout(timeout)
|
|
44
|
+
release()
|
|
45
|
+
}
|
|
46
|
+
}, [notificationId])
|
|
47
|
+
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Waits for the rigobot answer of every file being read.
|
|
53
|
+
*
|
|
54
|
+
* Lives above the routes because files are attached from two places and the
|
|
55
|
+
* user can leave either one while a read is running: a listener inside a card
|
|
56
|
+
* would die with the card, and the sidebar has no such card at all.
|
|
57
|
+
*/
|
|
58
|
+
const FileReadListener = () => {
|
|
59
|
+
const uploadedFiles = useStore((state) => state.uploadedFiles)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
{uploadedFiles
|
|
64
|
+
.filter((file) => file.status === "PROCESSING" && file.notificationId)
|
|
65
|
+
.map((file) => (
|
|
66
|
+
<FileReadSubscription
|
|
67
|
+
key={file.notificationId}
|
|
68
|
+
notificationId={file.notificationId}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
71
|
+
</>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default FileReadListener
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import toast from "react-hot-toast";
|
|
4
|
+
import "../i18n";
|
|
5
|
+
import { FileCard } from "./FileCard";
|
|
6
|
+
import { FileReviewButton } from "./FileReviewModal";
|
|
7
|
+
import { MarkdownRenderer } from "./MarkdownRenderer";
|
|
8
|
+
import useStore from "../utils/store";
|
|
9
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
10
|
+
import type { ParsedFile, ReadReport } from "./FileUploader";
|
|
11
|
+
|
|
12
|
+
vi.mock("react-hot-toast");
|
|
13
|
+
|
|
14
|
+
const report: ReadReport = {
|
|
15
|
+
total_pages: 3,
|
|
16
|
+
ai_pages: [],
|
|
17
|
+
unread_pages: [],
|
|
18
|
+
failed_pages: [],
|
|
19
|
+
markdown: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const file = (changes: Partial<ParsedFile> = {}): ParsedFile => ({
|
|
23
|
+
name: "seed.pdf",
|
|
24
|
+
text: "\n[Page 1]\n|Term|Definition|\n|---|---|\n|Loop|Repeats|\n",
|
|
25
|
+
status: "SUCCESS",
|
|
26
|
+
notificationId: "read-1",
|
|
27
|
+
report,
|
|
28
|
+
...changes,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const storedText = () => useStore.getState().uploadedFiles[0].text;
|
|
32
|
+
|
|
33
|
+
const open = (name: string) => fireEvent.click(screen.getByText(name));
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.resetAllMocks();
|
|
37
|
+
useStore.getState().setUploadedFiles([file()]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(cleanup);
|
|
41
|
+
|
|
42
|
+
describe("FileReviewButton", () => {
|
|
43
|
+
it("stays quiet on an ordinary read", () => {
|
|
44
|
+
render(<FileReviewButton file={file()} />);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByText("View text")).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("asks for a review when the AI read pages", () => {
|
|
50
|
+
render(<FileReviewButton file={file({ report: { ...report, ai_pages: [1, 2] } })} />);
|
|
51
|
+
|
|
52
|
+
open("Review text");
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
screen.getByText("We read 2 of 3 pages with AI. Check names, figures and dates.")
|
|
56
|
+
).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("names the pages that are missing, without repeating them", () => {
|
|
60
|
+
render(
|
|
61
|
+
<FileReviewButton
|
|
62
|
+
file={file({ report: { ...report, unread_pages: [3, 2], failed_pages: [2] } })}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
open("Review text");
|
|
67
|
+
|
|
68
|
+
expect(screen.getByText("We could not fully read these pages: 2, 3.")).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("draws tables instead of showing their markdown", () => {
|
|
72
|
+
render(<FileReviewButton file={file()} />);
|
|
73
|
+
|
|
74
|
+
open("View text");
|
|
75
|
+
|
|
76
|
+
expect(screen.getByRole("table")).toBeTruthy();
|
|
77
|
+
expect(screen.getByRole("cell", { name: "Repeats" })).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows page headings on screen and keeps the markers in the stored text", () => {
|
|
81
|
+
render(<FileReviewButton file={file()} />);
|
|
82
|
+
|
|
83
|
+
open("View text");
|
|
84
|
+
|
|
85
|
+
expect(screen.getByRole("heading", { name: "Page 1" })).toBeTruthy();
|
|
86
|
+
expect(screen.queryByText("[Page 1]")).toBeNull();
|
|
87
|
+
expect(storedText()).toContain("[Page 1]");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the lines of plain text apart", () => {
|
|
91
|
+
render(<FileReviewButton file={file({ text: "One\nTwo", report: undefined })} />);
|
|
92
|
+
|
|
93
|
+
open("View text");
|
|
94
|
+
|
|
95
|
+
expect(screen.getByText(/One/).className).toContain("whitespace-pre-wrap");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("saves edits where every prompt reads the file from", () => {
|
|
99
|
+
render(<FileReviewButton file={file()} />);
|
|
100
|
+
open("View text");
|
|
101
|
+
open("Edit");
|
|
102
|
+
|
|
103
|
+
fireEvent.change(screen.getByRole("textbox"), {
|
|
104
|
+
target: { value: "Corrected text" },
|
|
105
|
+
});
|
|
106
|
+
open("Save changes");
|
|
107
|
+
|
|
108
|
+
expect(storedText()).toBe("Corrected text");
|
|
109
|
+
expect(screen.queryByText("Save changes")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("discards edits on close", () => {
|
|
113
|
+
render(<FileReviewButton file={file()} />);
|
|
114
|
+
open("View text");
|
|
115
|
+
open("Edit");
|
|
116
|
+
|
|
117
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Changed" } });
|
|
118
|
+
open("Close");
|
|
119
|
+
|
|
120
|
+
expect(storedText()).toBe(file().text);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not let the user save an empty document", () => {
|
|
124
|
+
render(<FileReviewButton file={file()} />);
|
|
125
|
+
open("View text");
|
|
126
|
+
open("Edit");
|
|
127
|
+
|
|
128
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: " " } });
|
|
129
|
+
|
|
130
|
+
expect(
|
|
131
|
+
(screen.getByText("Save changes") as HTMLButtonElement).disabled
|
|
132
|
+
).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("MarkdownRenderer", () => {
|
|
137
|
+
it("leaves the chat's markdown without table support", () => {
|
|
138
|
+
render(<MarkdownRenderer content={"|a|b|\n|---|---|\n|1|2|"} />);
|
|
139
|
+
|
|
140
|
+
expect(screen.queryByRole("table")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("FileCard", () => {
|
|
145
|
+
const renderCard = (value: ParsedFile) =>
|
|
146
|
+
render(
|
|
147
|
+
<TooltipProvider>
|
|
148
|
+
<FileCard file={value} handleRemove={() => {}} />
|
|
149
|
+
</TooltipProvider>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const rerenderCard = (
|
|
153
|
+
rerender: ReturnType<typeof render>["rerender"],
|
|
154
|
+
value: ParsedFile
|
|
155
|
+
) =>
|
|
156
|
+
act(() =>
|
|
157
|
+
rerender(
|
|
158
|
+
<TooltipProvider>
|
|
159
|
+
<FileCard file={value} handleRemove={() => {}} />
|
|
160
|
+
</TooltipProvider>
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
it("warns once when a read from the sidebar used the AI", () => {
|
|
165
|
+
const { rerender } = renderCard(
|
|
166
|
+
file({ status: "PROCESSING", text: "", report: undefined })
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const finished = file({ report: { ...report, ai_pages: [1] } });
|
|
170
|
+
rerenderCard(rerender, finished);
|
|
171
|
+
rerenderCard(rerender, { ...finished });
|
|
172
|
+
|
|
173
|
+
expect(toast).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("says nothing when the page reloads with a read already finished", () => {
|
|
177
|
+
renderCard(file({ report: { ...report, ai_pages: [1] } }));
|
|
178
|
+
|
|
179
|
+
expect(toast).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reports a failed read", () => {
|
|
183
|
+
const { rerender } = renderCard(
|
|
184
|
+
file({ status: "PROCESSING", text: "", report: undefined })
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
rerenderCard(rerender, file({ status: "ERROR", text: "", report: undefined }));
|
|
188
|
+
|
|
189
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
190
|
+
"We could not read this file. Remove it or upload it again."
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
});
|