@learnpack/learnpack 5.0.361 → 5.0.363
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 +44 -7
- package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/lib/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
- package/lib/creatorDist/index.html +2 -2
- package/lib/utils/api.d.ts +0 -9
- package/lib/utils/api.js +1 -65
- package/lib/utils/lessonRecovery.d.ts +18 -0
- package/lib/utils/lessonRecovery.js +31 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +65 -18
- 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-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
- package/src/creatorDist/index.html +2 -2
- package/src/utils/api.ts +0 -83
- package/src/utils/lessonRecovery.ts +32 -0
|
@@ -24,7 +24,6 @@ vi.mock("./utils/lib", async (importOriginal) => ({
|
|
|
24
24
|
...(await importOriginal<typeof import("./utils/lib")>()),
|
|
25
25
|
isValidPublicToken: vi.fn(async () => true),
|
|
26
26
|
isValidRigoToken: vi.fn(async () => false),
|
|
27
|
-
getTechnologies: vi.fn(async () => []),
|
|
28
27
|
loginWithToken: vi.fn(),
|
|
29
28
|
}));
|
|
30
29
|
let deliverNotification: ((payload: unknown) => void) | null = null;
|
|
@@ -34,6 +33,12 @@ vi.mock("./components/NotificationListener", () => ({
|
|
|
34
33
|
return null;
|
|
35
34
|
},
|
|
36
35
|
}));
|
|
36
|
+
let expireWatchdog: (() => void) | null = null;
|
|
37
|
+
vi.mock("./utils/useCompletionWatchdog", () => ({
|
|
38
|
+
useCompletionWatchdog: (_id: string, onTimeout: () => void) => {
|
|
39
|
+
expireWatchdog = onTimeout;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
37
42
|
vi.mock("./components/TurnstileChallenge", () => ({ default: () => null }));
|
|
38
43
|
vi.mock("./components/TurnstileModal", () => ({ default: () => null }));
|
|
39
44
|
|
|
@@ -296,6 +301,124 @@ describe("language URL param", () => {
|
|
|
296
301
|
});
|
|
297
302
|
});
|
|
298
303
|
|
|
304
|
+
describe("when generation fails", () => {
|
|
305
|
+
const TITLE = "We could not generate your course";
|
|
306
|
+
const REJECTED = {
|
|
307
|
+
id: 231,
|
|
308
|
+
status: "ERROR",
|
|
309
|
+
status_text: "The selected model openai/gpt-5.6-luna doesn't support structured outputs",
|
|
310
|
+
parsed: null,
|
|
311
|
+
};
|
|
312
|
+
const UNEXPLAINED = { id: 232, status: "ERROR", status_text: "boom", parsed: null };
|
|
313
|
+
|
|
314
|
+
const generate = async () => {
|
|
315
|
+
deliverNotification = null;
|
|
316
|
+
finishWizard();
|
|
317
|
+
await waitFor(() => expect(deliverNotification).not.toBeNull());
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const fail = async (payload: unknown) => {
|
|
321
|
+
await generate();
|
|
322
|
+
act(() => deliverNotification!(payload));
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
it("stays on screen with its reference instead of returning to the wizard", async () => {
|
|
326
|
+
await setUp({});
|
|
327
|
+
await fail(UNEXPLAINED);
|
|
328
|
+
|
|
329
|
+
expect(screen.getByRole("alert").textContent).toContain(TITLE);
|
|
330
|
+
expect(screen.getByText("Reference code: 232")).toBeTruthy();
|
|
331
|
+
expect(screen.getByText("Your answers and files are still saved.")).toBeTruthy();
|
|
332
|
+
expect(screen.queryByText("Any materials to get this course started?")).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("retries with one click and no more questions", async () => {
|
|
336
|
+
await setUp({});
|
|
337
|
+
await fail(UNEXPLAINED);
|
|
338
|
+
|
|
339
|
+
deliverNotification = null;
|
|
340
|
+
fireEvent.click(screen.getByText("Try again"));
|
|
341
|
+
|
|
342
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalledTimes(2));
|
|
343
|
+
await waitFor(() => expect(deliverNotification).not.toBeNull());
|
|
344
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("sends to support a failure a retry cannot fix", async () => {
|
|
348
|
+
await setUp({});
|
|
349
|
+
await fail(REJECTED);
|
|
350
|
+
|
|
351
|
+
expect(
|
|
352
|
+
screen.getByText("Please contact support and share the reference code.")
|
|
353
|
+
).toBeTruthy();
|
|
354
|
+
expect(screen.getByText("Reference code: 231")).toBeTruthy();
|
|
355
|
+
expect(screen.queryByText("Try again")).toBeNull();
|
|
356
|
+
expect(screen.getByText("Review my answers")).toBeTruthy();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("sends to support an unknown failure that repeats", async () => {
|
|
360
|
+
await setUp({});
|
|
361
|
+
await fail(UNEXPLAINED);
|
|
362
|
+
expect(screen.getByText("Try again")).toBeTruthy();
|
|
363
|
+
|
|
364
|
+
deliverNotification = null;
|
|
365
|
+
fireEvent.click(screen.getByText("Try again"));
|
|
366
|
+
await waitFor(() => expect(deliverNotification).not.toBeNull());
|
|
367
|
+
act(() => deliverNotification!(UNEXPLAINED));
|
|
368
|
+
|
|
369
|
+
expect(screen.queryByText("Try again")).toBeNull();
|
|
370
|
+
expect(
|
|
371
|
+
screen.getByText("Please contact support and share the reference code.")
|
|
372
|
+
).toBeTruthy();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("names the job even when its notification never arrives", async () => {
|
|
376
|
+
vi.mocked(publicInteractiveCreation).mockResolvedValueOnce({
|
|
377
|
+
res: { id: 77 },
|
|
378
|
+
notificationId: "job-1",
|
|
379
|
+
});
|
|
380
|
+
await setUp({});
|
|
381
|
+
await generate();
|
|
382
|
+
|
|
383
|
+
act(() => expireWatchdog!());
|
|
384
|
+
|
|
385
|
+
expect(
|
|
386
|
+
screen.getByText("This is taking longer than expected, so we stopped waiting. Please try again.")
|
|
387
|
+
).toBeTruthy();
|
|
388
|
+
expect(screen.getByText("Reference code: 77")).toBeTruthy();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("shows no reference when the request never created a job", async () => {
|
|
392
|
+
vi.mocked(publicInteractiveCreation).mockResolvedValueOnce(null);
|
|
393
|
+
await setUp({});
|
|
394
|
+
finishWizard();
|
|
395
|
+
|
|
396
|
+
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
|
397
|
+
expect(screen.queryByText(/Reference code/)).toBeNull();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("reviews the materials when there are files, keeping them", async () => {
|
|
401
|
+
await setUp({ form: { hasContentIndex: true }, files: [file(ENGLISH_SEED)] });
|
|
402
|
+
await fail(REJECTED);
|
|
403
|
+
|
|
404
|
+
fireEvent.click(screen.getByText("Review my answers"));
|
|
405
|
+
|
|
406
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
407
|
+
expect(useStore.getState().formState.currentStep).toBe("contentIndex");
|
|
408
|
+
expect(useStore.getState().uploadedFiles).toHaveLength(1);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("asks about materials again when there were none", async () => {
|
|
412
|
+
await setUp({});
|
|
413
|
+
await fail(REJECTED);
|
|
414
|
+
|
|
415
|
+
fireEvent.click(screen.getByText("Review my answers"));
|
|
416
|
+
|
|
417
|
+
expect(useStore.getState().formState.currentStep).toBe("hasContentIndex");
|
|
418
|
+
expect(screen.getByText("Any materials to get this course started?")).toBeTruthy();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
299
422
|
describe("description language modal", () => {
|
|
300
423
|
const typeDescriptionAndGoNext = (description: string) => {
|
|
301
424
|
fireEvent.change(screen.getByPlaceholderText(i18n.t("stepWizard.descriptionPlaceholder")), {
|
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
|