reflex-agent 0.2.4 → 0.3.1
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/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +111 -98
- package/.next/app-path-routes-manifest.json +9 -9
- package/.next/build-manifest.json +5 -5
- package/.next/prerender-manifest.json +4 -54
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/agents/[agentId]/page.js +3 -3
- package/.next/server/app/agents/[agentId]/page.js.nft.json +1 -1
- package/.next/server/app/agents/[agentId]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/api/agents/[agentId]/respond/route.js +2 -2
- package/.next/server/app/api/agents/[agentId]/respond/route.js.nft.json +1 -1
- package/.next/server/app/api/agents/[agentId]/respond/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/images/[rootId]/[file]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/oauth/callback/route.js +3 -3
- package/.next/server/app/api/oauth/callback/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/oauth/start/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/roots/[id]/attachments/route.js +0 -0
- package/.next/server/app/api/roots/[id]/attachments/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route.js +2 -2
- package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route.js.nft.json +1 -1
- package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route.js +2 -2
- package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route.js.nft.json +1 -1
- package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route.js +2 -2
- package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route.js.nft.json +1 -1
- package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/roots/[id]/dashboard/route.js +1 -1
- package/.next/server/app/api/roots/[id]/dashboard/route.js.nft.json +1 -1
- package/.next/server/app/api/roots/[id]/dashboard/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/roots/[id]/suggestions/route.js +1 -1
- package/.next/server/app/api/roots/[id]/suggestions/route.js.nft.json +1 -1
- package/.next/server/app/api/roots/[id]/suggestions/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/bundle.js/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/host/route.js +2 -2
- package/.next/server/app/api/utilities/[scope]/[id]/host/route.js.nft.json +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/host/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/host-api.mjs/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/host-ui.mjs/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/iframe/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/utilities/[scope]/[id]/style.css/route_client-reference-manifest.js +1 -1
- package/.next/server/app/audit/page.js +2 -2
- package/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/server/app/onboarding/page.js +4 -4
- package/.next/server/app/onboarding/page.js.nft.json +1 -1
- package/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/roots/[id]/chat/[topicId]/page.js +2 -6
- package/.next/server/app/roots/[id]/chat/[topicId]/page.js.nft.json +1 -1
- package/.next/server/app/roots/[id]/chat/[topicId]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/roots/[id]/kb/[...slug]/page.js +2 -6
- package/.next/server/app/roots/[id]/kb/[...slug]/page.js.nft.json +1 -1
- package/.next/server/app/roots/[id]/kb/[...slug]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/roots/[id]/page.js +3 -3
- package/.next/server/app/roots/[id]/page.js.nft.json +1 -1
- package/.next/server/app/roots/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/roots/[id]/workflows/[wfId]/page.js +2 -2
- package/.next/server/app/roots/[id]/workflows/[wfId]/page.js.nft.json +1 -1
- package/.next/server/app/roots/[id]/workflows/[wfId]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/roots/[id]/workflows/page.js +2 -2
- package/.next/server/app/roots/[id]/workflows/page.js.nft.json +1 -1
- package/.next/server/app/roots/[id]/workflows/page_client-reference-manifest.js +1 -1
- package/.next/server/app/roots/new/page.js +4 -2
- package/.next/server/app/roots/new/page.js.nft.json +1 -1
- package/.next/server/app/roots/new/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/page.js +6 -6
- package/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/share/[id]/file/page.js +2 -2
- package/.next/server/app/share/[id]/file/page.js.nft.json +1 -1
- package/.next/server/app/share/[id]/file/page_client-reference-manifest.js +1 -1
- package/.next/server/app/share/[id]/page.js +2 -2
- package/.next/server/app/share/[id]/page.js.nft.json +1 -1
- package/.next/server/app/share/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/utilities/[scope]/[id]/page.js +2 -2
- package/.next/server/app/utilities/[scope]/[id]/page.js.nft.json +1 -1
- package/.next/server/app/utilities/[scope]/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/utilities/page.js +2 -17
- package/.next/server/app/utilities/page.js.nft.json +1 -1
- package/.next/server/app/utilities/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +9 -9
- package/.next/server/chunks/1223.js +1 -1
- package/.next/server/chunks/133.js +1 -490
- package/.next/server/chunks/1888.js +1 -1
- package/.next/server/chunks/{9739.js → 1988.js} +13 -9
- package/.next/server/chunks/2433.js +1 -1
- package/.next/server/chunks/2503.js +1 -1
- package/.next/server/chunks/285.js +471 -0
- package/.next/server/chunks/2959.js +1 -0
- package/.next/server/chunks/2995.js +1 -0
- package/.next/server/chunks/3240.js +1 -1
- package/.next/server/chunks/3332.js +1 -1
- package/.next/server/chunks/3657.js +1 -1
- package/.next/server/chunks/4066.js +1 -1
- package/.next/server/chunks/4438.js +1 -0
- package/.next/server/chunks/4514.js +3 -0
- package/.next/server/chunks/4553.js +1 -1
- package/.next/server/chunks/4812.js +179 -0
- package/.next/server/chunks/4925.js +1 -1
- package/.next/server/chunks/{3953.js → 5068.js} +2 -2
- package/.next/server/chunks/5319.js +1 -1
- package/.next/server/chunks/569.js +1 -1
- package/.next/server/chunks/6730.js +1 -1
- package/.next/server/chunks/6909.js +142 -161
- package/.next/server/chunks/8262.js +1 -1
- package/.next/server/chunks/9098.js +1 -1
- package/.next/server/chunks/94.js +1 -1
- package/.next/server/chunks/9427.js +1 -0
- package/.next/server/chunks/9538.js +1 -0
- package/.next/server/chunks/963.js +1 -0
- package/.next/server/chunks/9835.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-manifest.json +5 -5
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/pages-manifest.json +1 -2
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/2865-134f546f21ca4330.js +1 -0
- package/.next/static/chunks/4108.5abdb7812a13eafd.js +1 -0
- package/.next/static/chunks/5254-4196f25e56270de5.js +1 -0
- package/.next/static/chunks/5521-cbc665104c7e59d3.js +1 -0
- package/.next/static/chunks/6445-99824866a51b582a.js +1 -0
- package/.next/static/chunks/8855-9b941d2b78f398ce.js +1 -0
- package/.next/static/chunks/8871-2948840b33c0863d.js +1 -0
- package/.next/static/chunks/9411-af5f758c57741929.js +3 -0
- package/.next/static/chunks/app/agents/[agentId]/page-5d6f4cb16b42d02b.js +1 -0
- package/.next/static/chunks/app/layout-d4cf24375db6d793.js +1 -0
- package/.next/static/chunks/app/onboarding/page-7303664b62ccc24a.js +1 -0
- package/.next/static/chunks/app/page-97d312db91d569f7.js +1 -0
- package/.next/static/chunks/app/roots/[id]/chat/[topicId]/page-123f60a544619a3c.js +1 -0
- package/.next/static/chunks/app/roots/[id]/kb/[...slug]/page-e253597edb1b2440.js +1 -0
- package/.next/static/chunks/app/roots/[id]/page-91a8de6a1c79f8a3.js +1 -0
- package/.next/static/chunks/app/roots/[id]/workflows/[wfId]/page-4300a52e163883df.js +1 -0
- package/.next/static/chunks/app/roots/new/page-6b104aad46a38173.js +1 -0
- package/.next/static/chunks/app/settings/page-afe1b80f7f45c5eb.js +1 -0
- package/.next/static/chunks/app/share/[id]/page-a5fb565bd892d4df.js +1 -0
- package/.next/static/chunks/app/utilities/[scope]/[id]/page-eb713a2b5209942c.js +1 -0
- package/.next/static/chunks/app/utilities/page-b7f30c151c42a27c.js +1 -0
- package/.next/static/chunks/{webpack-5fca180586957874.js → webpack-bddc3babcbc30dd7.js} +1 -1
- package/.next/static/css/60e9b6cdf1283e83.css +1 -0
- package/.next/trace +47 -46
- package/dist/lib/reflex/agents/prompts.js +46 -46
- package/dist/lib/reflex/agents/prompts.js.map +1 -1
- package/dist/lib/reflex/prompts/defaults.js +102 -102
- package/next.config.ts +4 -1
- package/package.json +2 -2
- package/.next/server/app/_not-found.html +0 -1
- package/.next/server/app/_not-found.meta +0 -8
- package/.next/server/app/_not-found.rsc +0 -18
- package/.next/server/app/index.html +0 -1
- package/.next/server/app/index.meta +0 -9
- package/.next/server/app/index.rsc +0 -19
- package/.next/server/chunks/1410.js +0 -1
- package/.next/server/chunks/1986.js +0 -1
- package/.next/server/chunks/2448.js +0 -3
- package/.next/server/chunks/5754.js +0 -3
- package/.next/server/chunks/7097.js +0 -1
- package/.next/server/chunks/7782.js +0 -1
- package/.next/server/chunks/7987.js +0 -1
- package/.next/server/chunks/810.js +0 -1
- package/.next/server/chunks/8843.js +0 -1
- package/.next/server/chunks/9328.js +0 -179
- package/.next/server/pages/404.html +0 -1
- package/.next/static/chunks/2488-c9590facb4b9f184.js +0 -1
- package/.next/static/chunks/2684-257d38989ef53935.js +0 -1
- package/.next/static/chunks/4108.fb9f99a9c899ef54.js +0 -1
- package/.next/static/chunks/6231-d83c1544bbea8424.js +0 -1
- package/.next/static/chunks/9045-731ff0865352dd95.js +0 -1
- package/.next/static/chunks/9496-75ccd3fadb294fba.js +0 -1
- package/.next/static/chunks/992-4e7b7f722c629e21.js +0 -1
- package/.next/static/chunks/app/agents/[agentId]/page-0b5c2838354d0eba.js +0 -1
- package/.next/static/chunks/app/layout-9a59ed07c18cb786.js +0 -1
- package/.next/static/chunks/app/onboarding/page-79f07a813ea2abfe.js +0 -1
- package/.next/static/chunks/app/page-27f4b98b02ac4f79.js +0 -1
- package/.next/static/chunks/app/roots/[id]/chat/[topicId]/page-8db2d0b75cd333c8.js +0 -1
- package/.next/static/chunks/app/roots/[id]/kb/[...slug]/page-873b131eec3a2f30.js +0 -1
- package/.next/static/chunks/app/roots/[id]/page-270d0d49eb668784.js +0 -1
- package/.next/static/chunks/app/roots/[id]/workflows/[wfId]/page-7c1f10dbe0bcb9ad.js +0 -1
- package/.next/static/chunks/app/roots/new/page-ac1a9f6379710ca2.js +0 -1
- package/.next/static/chunks/app/settings/page-81cb1393e817dfc3.js +0 -1
- package/.next/static/chunks/app/share/[id]/page-2d123f0a99e1606f.js +0 -1
- package/.next/static/chunks/app/utilities/[scope]/[id]/page-0bbb8d17af80c1da.js +0 -1
- package/.next/static/chunks/app/utilities/page-e6ce673b9357bf1f.js +0 -1
- package/.next/static/css/87e01f779d555d04.css +0 -1
- package/packages/utilities/learn-anything/README.md +0 -41
- package/packages/utilities/learn-anything/actions/_json.ts +0 -191
- package/packages/utilities/learn-anything/actions/_store.ts +0 -248
- package/packages/utilities/learn-anything/actions/buildModule.ts +0 -487
- package/packages/utilities/learn-anything/actions/explainSelection.ts +0 -64
- package/packages/utilities/learn-anything/actions/generateOutline.ts +0 -170
- package/packages/utilities/learn-anything/actions/generateQuiz.ts +0 -72
- package/packages/utilities/learn-anything/actions/generateTrainer.ts +0 -106
- package/packages/utilities/learn-anything/actions/refreshCourseCard.ts +0 -76
- package/packages/utilities/learn-anything/actions/tutorAsk.ts +0 -93
- package/packages/utilities/learn-anything/article-view.tsx +0 -464
- package/packages/utilities/learn-anything/manifest.json +0 -42
- package/packages/utilities/learn-anything/ui.tsx +0 -1589
- /package/.next/static/{og_wC7UPkGtJDiapaTgBr → IGuuMcet1qtGZQCP2MEn4}/_buildManifest.js +0 -0
- /package/.next/static/{og_wC7UPkGtJDiapaTgBr → IGuuMcet1qtGZQCP2MEn4}/_ssgManifest.js +0 -0
|
@@ -1,1589 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { reflex } from "@host/api";
|
|
3
|
-
import { listCourses, readCourse, writeCourse, type CourseState } from "./actions/_store";
|
|
4
|
-
import { ArticleImageLightbox, ArticleView } from "./article-view";
|
|
5
|
-
import {
|
|
6
|
-
Badge,
|
|
7
|
-
Button,
|
|
8
|
-
Card,
|
|
9
|
-
CardContent,
|
|
10
|
-
CardHeader,
|
|
11
|
-
CardTitle,
|
|
12
|
-
Input,
|
|
13
|
-
Textarea,
|
|
14
|
-
} from "@host/ui";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* "Изучи что угодно" — universal AI tutor / course builder.
|
|
18
|
-
*
|
|
19
|
-
* UI state machine:
|
|
20
|
-
* list → user's existing courses + "Новый курс" button
|
|
21
|
-
* wizard → topic → agent-driven Q&A → ready
|
|
22
|
-
* outline → preview of generated modules → "Начать"
|
|
23
|
-
* module → article + video + links + diagrams + quiz + homework + trainer
|
|
24
|
-
* trainer → fullscreen iframe with the generated HTML
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
interface OutlineModule {
|
|
28
|
-
id: string;
|
|
29
|
-
title: string;
|
|
30
|
-
objective: string;
|
|
31
|
-
estMinutes: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface CourseSummary {
|
|
35
|
-
relPath: string;
|
|
36
|
-
courseId: string;
|
|
37
|
-
topic: string;
|
|
38
|
-
modules: OutlineModule[];
|
|
39
|
-
progress: Record<string, { completed?: boolean; quizScore?: number }>;
|
|
40
|
-
createdAt: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface ModuleContent {
|
|
44
|
-
courseId: string;
|
|
45
|
-
moduleId: string;
|
|
46
|
-
title: string;
|
|
47
|
-
article: string;
|
|
48
|
-
videos: Array<{ title: string; url: string; note?: string }>;
|
|
49
|
-
links: Array<{ title: string; url: string; snippet?: string }>;
|
|
50
|
-
images: Array<{ alt: string; url: string }>;
|
|
51
|
-
diagrams: Array<{ title: string; mermaid: string }>;
|
|
52
|
-
homework: string[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface QuizQuestion {
|
|
56
|
-
stem: string;
|
|
57
|
-
options: string[];
|
|
58
|
-
correctIndex: number;
|
|
59
|
-
explanation: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type View =
|
|
63
|
-
| { name: "list" }
|
|
64
|
-
| { name: "wizard"; topic: string }
|
|
65
|
-
| { name: "outline"; course: CourseSummary }
|
|
66
|
-
| { name: "course"; course: CourseSummary }
|
|
67
|
-
| {
|
|
68
|
-
name: "module";
|
|
69
|
-
course: CourseSummary;
|
|
70
|
-
module: OutlineModule;
|
|
71
|
-
content: ModuleContent | null;
|
|
72
|
-
quiz: QuizQuestion[] | null;
|
|
73
|
-
}
|
|
74
|
-
| { name: "trainer"; html: string; title: string };
|
|
75
|
-
|
|
76
|
-
export default function LearnAnythingUtility() {
|
|
77
|
-
const [view, setView] = useState<View>({ name: "list" });
|
|
78
|
-
const [courses, setCourses] = useState<CourseSummary[]>([]);
|
|
79
|
-
const [error, setError] = useState<string | null>(null);
|
|
80
|
-
|
|
81
|
-
const refreshList = async () => {
|
|
82
|
-
try {
|
|
83
|
-
const states = await listCourses();
|
|
84
|
-
setCourses(
|
|
85
|
-
states.map((s) => ({
|
|
86
|
-
relPath: `data/courses/${s.courseId}.json`,
|
|
87
|
-
courseId: s.courseId,
|
|
88
|
-
topic: s.topic,
|
|
89
|
-
modules: s.modules,
|
|
90
|
-
progress: s.progress,
|
|
91
|
-
createdAt: s.createdAt,
|
|
92
|
-
})),
|
|
93
|
-
);
|
|
94
|
-
} catch (err: unknown) {
|
|
95
|
-
setError(String(err));
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
void refreshList();
|
|
101
|
-
}, []);
|
|
102
|
-
|
|
103
|
-
// Whenever the user returns to the list view, re-pull from KB —
|
|
104
|
-
// covers "just created a course", "just deleted a module", any
|
|
105
|
-
// background change the wizard/outline/module flows produced.
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
if (view.name === "list") void refreshList();
|
|
108
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
-
}, [view.name]);
|
|
110
|
-
|
|
111
|
-
return (
|
|
112
|
-
<div className="min-h-screen bg-slate-50">
|
|
113
|
-
<div className="mx-auto max-w-3xl px-4 py-6 space-y-4">
|
|
114
|
-
<header className="flex items-center gap-3">
|
|
115
|
-
<h1 className="text-xl font-semibold">🎓 Изучи что угодно</h1>
|
|
116
|
-
<span className="text-xs text-slate-500">
|
|
117
|
-
персональный AI-наставник
|
|
118
|
-
</span>
|
|
119
|
-
<div className="ml-auto" />
|
|
120
|
-
{view.name !== "list" && (
|
|
121
|
-
<Button
|
|
122
|
-
variant="outline"
|
|
123
|
-
type="button"
|
|
124
|
-
onClick={() => setView({ name: "list" })}
|
|
125
|
-
>
|
|
126
|
-
← К курсам
|
|
127
|
-
</Button>
|
|
128
|
-
)}
|
|
129
|
-
</header>
|
|
130
|
-
|
|
131
|
-
{view.name === "list" && (
|
|
132
|
-
<CourseList
|
|
133
|
-
courses={courses}
|
|
134
|
-
onOpen={(c) => setView({ name: "course", course: c })}
|
|
135
|
-
onNew={(topic) => setView({ name: "wizard", topic })}
|
|
136
|
-
/>
|
|
137
|
-
)}
|
|
138
|
-
|
|
139
|
-
{view.name === "wizard" && (
|
|
140
|
-
<WizardView
|
|
141
|
-
topic={view.topic}
|
|
142
|
-
onCancel={() => setView({ name: "list" })}
|
|
143
|
-
onReady={async (course) => {
|
|
144
|
-
await refreshList();
|
|
145
|
-
setView({ name: "outline", course });
|
|
146
|
-
}}
|
|
147
|
-
onError={setError}
|
|
148
|
-
/>
|
|
149
|
-
)}
|
|
150
|
-
|
|
151
|
-
{view.name === "outline" && (
|
|
152
|
-
<OutlineView
|
|
153
|
-
course={view.course}
|
|
154
|
-
onStart={() => setView({ name: "course", course: view.course })}
|
|
155
|
-
/>
|
|
156
|
-
)}
|
|
157
|
-
|
|
158
|
-
{view.name === "course" && (
|
|
159
|
-
<CourseView
|
|
160
|
-
course={view.course}
|
|
161
|
-
onOpenModule={(mod) =>
|
|
162
|
-
setView({
|
|
163
|
-
name: "module",
|
|
164
|
-
course: view.course,
|
|
165
|
-
module: mod,
|
|
166
|
-
content: null,
|
|
167
|
-
quiz: null,
|
|
168
|
-
})
|
|
169
|
-
}
|
|
170
|
-
/>
|
|
171
|
-
)}
|
|
172
|
-
|
|
173
|
-
{view.name === "module" && (
|
|
174
|
-
<ModuleView
|
|
175
|
-
course={view.course}
|
|
176
|
-
module={view.module}
|
|
177
|
-
content={view.content}
|
|
178
|
-
quiz={view.quiz}
|
|
179
|
-
onContent={(content) =>
|
|
180
|
-
setView((cur) =>
|
|
181
|
-
cur.name === "module" ? { ...cur, content } : cur,
|
|
182
|
-
)
|
|
183
|
-
}
|
|
184
|
-
onQuiz={(quiz) =>
|
|
185
|
-
setView((cur) =>
|
|
186
|
-
cur.name === "module" ? { ...cur, quiz } : cur,
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
onTrainer={(html, title) =>
|
|
190
|
-
setView({ name: "trainer", html, title })
|
|
191
|
-
}
|
|
192
|
-
onProgress={async (mark) => {
|
|
193
|
-
await markProgress(view.course, view.module.id, mark);
|
|
194
|
-
await refreshList();
|
|
195
|
-
}}
|
|
196
|
-
onError={setError}
|
|
197
|
-
/>
|
|
198
|
-
)}
|
|
199
|
-
|
|
200
|
-
{view.name === "trainer" && (
|
|
201
|
-
<TrainerView
|
|
202
|
-
html={view.html}
|
|
203
|
-
title={view.title}
|
|
204
|
-
onClose={() =>
|
|
205
|
-
setView((cur) => ({ name: "list" } as View))
|
|
206
|
-
}
|
|
207
|
-
/>
|
|
208
|
-
)}
|
|
209
|
-
|
|
210
|
-
{error && (
|
|
211
|
-
<p className="text-xs text-red-600">Ошибка: {error}</p>
|
|
212
|
-
)}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
// LIST VIEW
|
|
220
|
-
|
|
221
|
-
function CourseList({
|
|
222
|
-
courses,
|
|
223
|
-
onOpen,
|
|
224
|
-
onNew,
|
|
225
|
-
}: {
|
|
226
|
-
courses: CourseSummary[];
|
|
227
|
-
onOpen: (c: CourseSummary) => void;
|
|
228
|
-
onNew: (topic: string) => void;
|
|
229
|
-
}) {
|
|
230
|
-
const [topic, setTopic] = useState("");
|
|
231
|
-
return (
|
|
232
|
-
<div className="space-y-4">
|
|
233
|
-
<Card>
|
|
234
|
-
<CardHeader>
|
|
235
|
-
<CardTitle>🎯 Что хочешь изучить?</CardTitle>
|
|
236
|
-
</CardHeader>
|
|
237
|
-
<CardContent className="space-y-2">
|
|
238
|
-
<Input
|
|
239
|
-
value={topic}
|
|
240
|
-
onChange={(e) => setTopic(e.target.value)}
|
|
241
|
-
placeholder="напр. рисование акварелью, python для бэкенда, испанский с нуля"
|
|
242
|
-
onKeyDown={(e) => {
|
|
243
|
-
if (e.key === "Enter" && topic.trim()) {
|
|
244
|
-
e.preventDefault();
|
|
245
|
-
onNew(topic.trim());
|
|
246
|
-
}
|
|
247
|
-
}}
|
|
248
|
-
/>
|
|
249
|
-
<Button
|
|
250
|
-
onClick={() => topic.trim() && onNew(topic.trim())}
|
|
251
|
-
disabled={!topic.trim()}
|
|
252
|
-
>
|
|
253
|
-
Собрать курс →
|
|
254
|
-
</Button>
|
|
255
|
-
</CardContent>
|
|
256
|
-
</Card>
|
|
257
|
-
|
|
258
|
-
<Card>
|
|
259
|
-
<CardHeader>
|
|
260
|
-
<CardTitle>📚 Мои курсы ({courses.length})</CardTitle>
|
|
261
|
-
</CardHeader>
|
|
262
|
-
<CardContent>
|
|
263
|
-
{courses.length === 0 ? (
|
|
264
|
-
<p className="text-sm text-slate-500">
|
|
265
|
-
Пока ни одного. Введи тему выше — Reflex задаст 3-4 вопроса и
|
|
266
|
-
соберёт программу.
|
|
267
|
-
</p>
|
|
268
|
-
) : (
|
|
269
|
-
<ul className="space-y-1.5">
|
|
270
|
-
{courses.map((c) => {
|
|
271
|
-
const total = c.modules.length;
|
|
272
|
-
const done = Object.values(c.progress).filter(
|
|
273
|
-
(p) => p?.completed,
|
|
274
|
-
).length;
|
|
275
|
-
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
276
|
-
return (
|
|
277
|
-
<li key={c.relPath}>
|
|
278
|
-
<button
|
|
279
|
-
type="button"
|
|
280
|
-
onClick={() => onOpen(c)}
|
|
281
|
-
className="w-full text-left rounded-md border bg-white px-3 py-2 hover:bg-slate-50"
|
|
282
|
-
>
|
|
283
|
-
<div className="flex items-center gap-2">
|
|
284
|
-
<span className="font-medium truncate">{c.topic}</span>
|
|
285
|
-
<span className="ml-auto text-xs text-slate-500">
|
|
286
|
-
{done}/{total} модулей
|
|
287
|
-
</span>
|
|
288
|
-
</div>
|
|
289
|
-
<div className="mt-1 h-1 rounded-full bg-slate-200 overflow-hidden">
|
|
290
|
-
<div
|
|
291
|
-
className="h-full bg-violet-500"
|
|
292
|
-
style={{ width: `${pct}%` }}
|
|
293
|
-
/>
|
|
294
|
-
</div>
|
|
295
|
-
</button>
|
|
296
|
-
</li>
|
|
297
|
-
);
|
|
298
|
-
})}
|
|
299
|
-
</ul>
|
|
300
|
-
)}
|
|
301
|
-
</CardContent>
|
|
302
|
-
</Card>
|
|
303
|
-
</div>
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ---------------------------------------------------------------------------
|
|
308
|
-
// WIZARD
|
|
309
|
-
|
|
310
|
-
function WizardView({
|
|
311
|
-
topic,
|
|
312
|
-
onCancel,
|
|
313
|
-
onReady,
|
|
314
|
-
onError,
|
|
315
|
-
}: {
|
|
316
|
-
topic: string;
|
|
317
|
-
onCancel: () => void;
|
|
318
|
-
onReady: (course: CourseSummary) => void;
|
|
319
|
-
onError: (err: string) => void;
|
|
320
|
-
}) {
|
|
321
|
-
const [history, setHistory] = useState<Array<{ question: string; answer: string }>>([]);
|
|
322
|
-
const [current, setCurrent] = useState<{
|
|
323
|
-
question: string;
|
|
324
|
-
header?: string;
|
|
325
|
-
choices?: string[];
|
|
326
|
-
} | null>(null);
|
|
327
|
-
const [draft, setDraft] = useState("");
|
|
328
|
-
const [busy, setBusy] = useState(false);
|
|
329
|
-
const [phase, setPhase] = useState<"ask" | "building" | "done">("ask");
|
|
330
|
-
|
|
331
|
-
const ask = async (h: typeof history) => {
|
|
332
|
-
setBusy(true);
|
|
333
|
-
try {
|
|
334
|
-
const r = (await reflex.actions.invoke({
|
|
335
|
-
name: "tutorAsk",
|
|
336
|
-
args: { topic, history: h },
|
|
337
|
-
})) as {
|
|
338
|
-
done: boolean;
|
|
339
|
-
question?: string;
|
|
340
|
-
header?: string;
|
|
341
|
-
choices?: string[];
|
|
342
|
-
};
|
|
343
|
-
if (r.done || !r.question) {
|
|
344
|
-
await buildOutline(h);
|
|
345
|
-
} else {
|
|
346
|
-
setCurrent({
|
|
347
|
-
question: r.question,
|
|
348
|
-
...(r.header ? { header: r.header } : {}),
|
|
349
|
-
...(r.choices ? { choices: r.choices } : {}),
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
} catch (err: unknown) {
|
|
353
|
-
onError(String(err));
|
|
354
|
-
onCancel();
|
|
355
|
-
} finally {
|
|
356
|
-
setBusy(false);
|
|
357
|
-
}
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
const buildOutline = async (h: typeof history) => {
|
|
361
|
-
setPhase("building");
|
|
362
|
-
try {
|
|
363
|
-
const r = (await reflex.actions.invoke({
|
|
364
|
-
name: "generateOutline",
|
|
365
|
-
args: { topic, history: h },
|
|
366
|
-
})) as {
|
|
367
|
-
courseId: string;
|
|
368
|
-
topic: string;
|
|
369
|
-
modules: OutlineModule[];
|
|
370
|
-
relPath: string;
|
|
371
|
-
createdAt: string;
|
|
372
|
-
};
|
|
373
|
-
await reflex.actions.invoke({ name: "refreshCourseCard" });
|
|
374
|
-
onReady({
|
|
375
|
-
relPath: r.relPath,
|
|
376
|
-
courseId: r.courseId,
|
|
377
|
-
topic: r.topic,
|
|
378
|
-
modules: r.modules,
|
|
379
|
-
progress: {},
|
|
380
|
-
createdAt: r.createdAt,
|
|
381
|
-
});
|
|
382
|
-
} catch (err: unknown) {
|
|
383
|
-
onError(String(err));
|
|
384
|
-
onCancel();
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
useEffect(() => {
|
|
389
|
-
void ask([]);
|
|
390
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
391
|
-
}, []);
|
|
392
|
-
|
|
393
|
-
if (phase === "building") {
|
|
394
|
-
return (
|
|
395
|
-
<Card>
|
|
396
|
-
<CardContent className="py-8 text-center space-y-2">
|
|
397
|
-
<div className="text-2xl">📐</div>
|
|
398
|
-
<p className="text-sm text-slate-600">
|
|
399
|
-
Собираю программу курса… это занимает 20-40 секунд.
|
|
400
|
-
</p>
|
|
401
|
-
</CardContent>
|
|
402
|
-
</Card>
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
return (
|
|
407
|
-
<Card>
|
|
408
|
-
<CardHeader>
|
|
409
|
-
<CardTitle>
|
|
410
|
-
<span className="text-slate-500 text-xs uppercase tracking-wider">
|
|
411
|
-
Тема:
|
|
412
|
-
</span>{" "}
|
|
413
|
-
{topic}
|
|
414
|
-
</CardTitle>
|
|
415
|
-
</CardHeader>
|
|
416
|
-
<CardContent className="space-y-3">
|
|
417
|
-
{history.length > 0 && (
|
|
418
|
-
<div className="space-y-1 text-xs">
|
|
419
|
-
{history.map((qa, i) => (
|
|
420
|
-
<div key={i} className="text-slate-500">
|
|
421
|
-
<span className="font-medium">Q{i + 1}:</span> {qa.question}
|
|
422
|
-
<br />
|
|
423
|
-
<span className="font-medium">A{i + 1}:</span> {qa.answer}
|
|
424
|
-
</div>
|
|
425
|
-
))}
|
|
426
|
-
</div>
|
|
427
|
-
)}
|
|
428
|
-
{current ? (
|
|
429
|
-
<>
|
|
430
|
-
{current.header && (
|
|
431
|
-
<Badge variant="outline" className="text-[10px]">
|
|
432
|
-
{current.header}
|
|
433
|
-
</Badge>
|
|
434
|
-
)}
|
|
435
|
-
<p className="font-medium">{current.question}</p>
|
|
436
|
-
{current.choices && current.choices.length > 0 ? (
|
|
437
|
-
<div className="flex flex-wrap gap-1.5">
|
|
438
|
-
{current.choices.map((c, i) => (
|
|
439
|
-
<button
|
|
440
|
-
key={i}
|
|
441
|
-
type="button"
|
|
442
|
-
onClick={() => setDraft(c)}
|
|
443
|
-
className={
|
|
444
|
-
"rounded-full border px-3 py-1 text-xs " +
|
|
445
|
-
(draft === c
|
|
446
|
-
? "bg-violet-600 text-white border-violet-600"
|
|
447
|
-
: "bg-white hover:bg-slate-100")
|
|
448
|
-
}
|
|
449
|
-
>
|
|
450
|
-
{c}
|
|
451
|
-
</button>
|
|
452
|
-
))}
|
|
453
|
-
</div>
|
|
454
|
-
) : null}
|
|
455
|
-
<Textarea
|
|
456
|
-
value={draft}
|
|
457
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
458
|
-
rows={3}
|
|
459
|
-
placeholder="свой ответ…"
|
|
460
|
-
/>
|
|
461
|
-
<div className="flex items-center gap-2">
|
|
462
|
-
<Button
|
|
463
|
-
disabled={busy || !draft.trim()}
|
|
464
|
-
onClick={() => {
|
|
465
|
-
const next = [
|
|
466
|
-
...history,
|
|
467
|
-
{ question: current.question, answer: draft.trim() },
|
|
468
|
-
];
|
|
469
|
-
setHistory(next);
|
|
470
|
-
setDraft("");
|
|
471
|
-
setCurrent(null);
|
|
472
|
-
void ask(next);
|
|
473
|
-
}}
|
|
474
|
-
>
|
|
475
|
-
Ответить →
|
|
476
|
-
</Button>
|
|
477
|
-
<Button
|
|
478
|
-
type="button"
|
|
479
|
-
variant="outline"
|
|
480
|
-
disabled={busy}
|
|
481
|
-
onClick={() => void buildOutline(history)}
|
|
482
|
-
>
|
|
483
|
-
Хватит, собирай курс
|
|
484
|
-
</Button>
|
|
485
|
-
<Button type="button" variant="outline" onClick={onCancel}>
|
|
486
|
-
Отмена
|
|
487
|
-
</Button>
|
|
488
|
-
</div>
|
|
489
|
-
</>
|
|
490
|
-
) : (
|
|
491
|
-
<p className="text-sm text-slate-500">Думаю над вопросом…</p>
|
|
492
|
-
)}
|
|
493
|
-
</CardContent>
|
|
494
|
-
</Card>
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// ---------------------------------------------------------------------------
|
|
499
|
-
// OUTLINE
|
|
500
|
-
|
|
501
|
-
function OutlineView({
|
|
502
|
-
course,
|
|
503
|
-
onStart,
|
|
504
|
-
}: {
|
|
505
|
-
course: CourseSummary;
|
|
506
|
-
onStart: () => void;
|
|
507
|
-
}) {
|
|
508
|
-
return (
|
|
509
|
-
<Card>
|
|
510
|
-
<CardHeader>
|
|
511
|
-
<CardTitle>🧭 Программа курса «{course.topic}»</CardTitle>
|
|
512
|
-
</CardHeader>
|
|
513
|
-
<CardContent className="space-y-3">
|
|
514
|
-
<ol className="space-y-2 list-decimal pl-5">
|
|
515
|
-
{course.modules.map((m) => (
|
|
516
|
-
<li key={m.id} className="text-sm">
|
|
517
|
-
<span className="font-medium">{m.title}</span>
|
|
518
|
-
{m.objective && (
|
|
519
|
-
<span className="text-slate-600"> — {m.objective}</span>
|
|
520
|
-
)}
|
|
521
|
-
<span className="text-xs text-slate-400 ml-1">
|
|
522
|
-
(~{m.estMinutes} мин)
|
|
523
|
-
</span>
|
|
524
|
-
</li>
|
|
525
|
-
))}
|
|
526
|
-
</ol>
|
|
527
|
-
<Button onClick={onStart}>Начать обучение →</Button>
|
|
528
|
-
</CardContent>
|
|
529
|
-
</Card>
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// ---------------------------------------------------------------------------
|
|
534
|
-
// COURSE OVERVIEW
|
|
535
|
-
|
|
536
|
-
function CourseView({
|
|
537
|
-
course,
|
|
538
|
-
onOpenModule,
|
|
539
|
-
}: {
|
|
540
|
-
course: CourseSummary;
|
|
541
|
-
onOpenModule: (m: OutlineModule) => void;
|
|
542
|
-
}) {
|
|
543
|
-
const done = course.modules.filter(
|
|
544
|
-
(m) => course.progress[m.id]?.completed,
|
|
545
|
-
).length;
|
|
546
|
-
return (
|
|
547
|
-
<Card>
|
|
548
|
-
<CardHeader>
|
|
549
|
-
<CardTitle>📚 {course.topic}</CardTitle>
|
|
550
|
-
</CardHeader>
|
|
551
|
-
<CardContent className="space-y-3">
|
|
552
|
-
<div className="text-xs text-slate-500">
|
|
553
|
-
Прогресс: {done} из {course.modules.length}
|
|
554
|
-
</div>
|
|
555
|
-
<div className="h-1.5 rounded-full bg-slate-200 overflow-hidden">
|
|
556
|
-
<div
|
|
557
|
-
className="h-full bg-violet-500"
|
|
558
|
-
style={{
|
|
559
|
-
width: `${course.modules.length > 0 ? (done / course.modules.length) * 100 : 0}%`,
|
|
560
|
-
}}
|
|
561
|
-
/>
|
|
562
|
-
</div>
|
|
563
|
-
<ul className="space-y-1.5">
|
|
564
|
-
{course.modules.map((m, i) => {
|
|
565
|
-
const p = course.progress[m.id];
|
|
566
|
-
return (
|
|
567
|
-
<li key={m.id}>
|
|
568
|
-
<button
|
|
569
|
-
type="button"
|
|
570
|
-
onClick={() => onOpenModule(m)}
|
|
571
|
-
className="w-full text-left rounded-md border bg-white px-3 py-2 hover:bg-slate-50 flex items-center gap-2"
|
|
572
|
-
>
|
|
573
|
-
<span
|
|
574
|
-
className={
|
|
575
|
-
"h-5 w-5 rounded-full text-[10px] font-mono flex items-center justify-center " +
|
|
576
|
-
(p?.completed
|
|
577
|
-
? "bg-emerald-500 text-white"
|
|
578
|
-
: "bg-slate-200 text-slate-600")
|
|
579
|
-
}
|
|
580
|
-
>
|
|
581
|
-
{p?.completed ? "✓" : i + 1}
|
|
582
|
-
</span>
|
|
583
|
-
<span className="flex-1 truncate">{m.title}</span>
|
|
584
|
-
{p?.quizScore !== undefined && (
|
|
585
|
-
<Badge variant="outline" className="text-[10px]">
|
|
586
|
-
тест {p.quizScore}%
|
|
587
|
-
</Badge>
|
|
588
|
-
)}
|
|
589
|
-
<span className="text-[10px] text-slate-400">
|
|
590
|
-
~{m.estMinutes} мин
|
|
591
|
-
</span>
|
|
592
|
-
</button>
|
|
593
|
-
</li>
|
|
594
|
-
);
|
|
595
|
-
})}
|
|
596
|
-
</ul>
|
|
597
|
-
</CardContent>
|
|
598
|
-
</Card>
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// ---------------------------------------------------------------------------
|
|
603
|
-
// MODULE VIEW (article + video + quiz + homework + trainer + selection-explain)
|
|
604
|
-
|
|
605
|
-
function ModuleView({
|
|
606
|
-
course,
|
|
607
|
-
module,
|
|
608
|
-
content,
|
|
609
|
-
quiz,
|
|
610
|
-
onContent,
|
|
611
|
-
onQuiz,
|
|
612
|
-
onTrainer,
|
|
613
|
-
onProgress,
|
|
614
|
-
onError,
|
|
615
|
-
}: {
|
|
616
|
-
course: CourseSummary;
|
|
617
|
-
module: OutlineModule;
|
|
618
|
-
content: ModuleContent | null;
|
|
619
|
-
quiz: QuizQuestion[] | null;
|
|
620
|
-
onContent: (c: ModuleContent) => void;
|
|
621
|
-
onQuiz: (q: QuizQuestion[]) => void;
|
|
622
|
-
onTrainer: (html: string, title: string) => void;
|
|
623
|
-
onProgress: (mark: { completed?: boolean; quizScore?: number }) => Promise<void>;
|
|
624
|
-
onError: (err: string) => void;
|
|
625
|
-
}) {
|
|
626
|
-
const [building, setBuilding] = useState(false);
|
|
627
|
-
/**
|
|
628
|
-
* Book-style annotation. When the reader selects text, this object
|
|
629
|
-
* captures:
|
|
630
|
-
* - `text`: what's selected (passed to the explainer).
|
|
631
|
-
* - `rects`: viewport rectangles per line — used to draw the
|
|
632
|
-
* yellow highlight overlay so the user sees what the note refers
|
|
633
|
-
* to even after the browser drops the selection on click.
|
|
634
|
-
* - `anchorTop` / `anchorBottom` / `anchorLeft`: where the popover
|
|
635
|
-
* should attach (above the selection by default, below if there's
|
|
636
|
-
* no vertical room).
|
|
637
|
-
* - `question` / `showQuestion`: optional custom user question.
|
|
638
|
-
* - `status`: idle → loading → done.
|
|
639
|
-
* - `explanation`: agent's reply.
|
|
640
|
-
*/
|
|
641
|
-
const [annot, setAnnot] = useState<{
|
|
642
|
-
text: string;
|
|
643
|
-
rects: { top: number; left: number; width: number; height: number }[];
|
|
644
|
-
anchorTop: number;
|
|
645
|
-
anchorBottom: number;
|
|
646
|
-
anchorLeft: number;
|
|
647
|
-
anchorWidth: number;
|
|
648
|
-
question: string;
|
|
649
|
-
showQuestion: boolean;
|
|
650
|
-
status: "idle" | "loading" | "done";
|
|
651
|
-
explanation: string | null;
|
|
652
|
-
} | null>(null);
|
|
653
|
-
const [galleryZoom, setGalleryZoom] = useState<{ src: string; alt: string } | null>(
|
|
654
|
-
null,
|
|
655
|
-
);
|
|
656
|
-
const [quizBusy, setQuizBusy] = useState(false);
|
|
657
|
-
const [quizAnswers, setQuizAnswers] = useState<Record<number, number>>({});
|
|
658
|
-
const [quizSubmitted, setQuizSubmitted] = useState(false);
|
|
659
|
-
const [trainerBusy, setTrainerBusy] = useState(false);
|
|
660
|
-
const [trainerIdea, setTrainerIdea] = useState("");
|
|
661
|
-
const articleRef = useRef<HTMLDivElement | null>(null);
|
|
662
|
-
|
|
663
|
-
// Try to load already-cached module content from KB first.
|
|
664
|
-
useEffect(() => {
|
|
665
|
-
void (async () => {
|
|
666
|
-
try {
|
|
667
|
-
const list = (await reflex.kb.list({ kind: "course-module" })) ?? [];
|
|
668
|
-
for (const it of list) {
|
|
669
|
-
const { content: raw } = await reflex.kb.read({ relPath: it.relPath });
|
|
670
|
-
const fm = parseFrontmatter(raw);
|
|
671
|
-
if (
|
|
672
|
-
stringOf(fm?.courseId) === course.courseId &&
|
|
673
|
-
stringOf(fm?.moduleId) === module.id
|
|
674
|
-
) {
|
|
675
|
-
const body = stripFrontmatter(raw);
|
|
676
|
-
onContent({
|
|
677
|
-
courseId: course.courseId,
|
|
678
|
-
moduleId: module.id,
|
|
679
|
-
title: module.title,
|
|
680
|
-
article: body,
|
|
681
|
-
videos: parseJson<ModuleContent["videos"]>(fm?.videos) ?? [],
|
|
682
|
-
links: parseJson<ModuleContent["links"]>(fm?.links) ?? [],
|
|
683
|
-
images: parseJson<ModuleContent["images"]>(fm?.images) ?? [],
|
|
684
|
-
diagrams: parseJson<ModuleContent["diagrams"]>(fm?.diagrams) ?? [],
|
|
685
|
-
homework: parseJson<string[]>(fm?.homework) ?? [],
|
|
686
|
-
});
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
} catch {
|
|
691
|
-
/* fall through to build */
|
|
692
|
-
}
|
|
693
|
-
})();
|
|
694
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
695
|
-
}, []);
|
|
696
|
-
|
|
697
|
-
const build = async () => {
|
|
698
|
-
setBuilding(true);
|
|
699
|
-
try {
|
|
700
|
-
const r = (await reflex.actions.invoke({
|
|
701
|
-
name: "buildModule",
|
|
702
|
-
args: {
|
|
703
|
-
courseId: course.courseId,
|
|
704
|
-
moduleId: module.id,
|
|
705
|
-
moduleTitle: module.title,
|
|
706
|
-
moduleObjective: module.objective,
|
|
707
|
-
topic: course.topic,
|
|
708
|
-
},
|
|
709
|
-
})) as ModuleContent;
|
|
710
|
-
onContent(r);
|
|
711
|
-
} catch (err: unknown) {
|
|
712
|
-
onError(String(err));
|
|
713
|
-
} finally {
|
|
714
|
-
setBuilding(false);
|
|
715
|
-
}
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Snapshot the current browser selection into our annotation state.
|
|
720
|
-
* Wrapped in a `requestAnimationFrame` defer because the browser may
|
|
721
|
-
* not have finalised the selection by the time `mouseup` fires (and
|
|
722
|
-
* a stale selection sneaks in). RAF runs after the next layout pass
|
|
723
|
-
* so `window.getSelection()` reflects the user's actual drag.
|
|
724
|
-
*/
|
|
725
|
-
const captureSelection = () => {
|
|
726
|
-
if (typeof window === "undefined") return;
|
|
727
|
-
const sel = window.getSelection?.();
|
|
728
|
-
const text = sel?.toString().trim() ?? "";
|
|
729
|
-
if (text.length < 6) return;
|
|
730
|
-
let rects: { top: number; left: number; width: number; height: number }[] = [];
|
|
731
|
-
let anchorTop = 0;
|
|
732
|
-
let anchorBottom = 0;
|
|
733
|
-
let anchorLeft = 0;
|
|
734
|
-
let anchorWidth = 0;
|
|
735
|
-
try {
|
|
736
|
-
const range = sel?.rangeCount ? sel.getRangeAt(0) : null;
|
|
737
|
-
if (range) {
|
|
738
|
-
const list = range.getClientRects();
|
|
739
|
-
for (let i = 0; i < list.length; i++) {
|
|
740
|
-
const r = list[i]!;
|
|
741
|
-
if (r.width <= 0 || r.height <= 0) continue;
|
|
742
|
-
rects.push({
|
|
743
|
-
top: r.top,
|
|
744
|
-
left: r.left,
|
|
745
|
-
width: r.width,
|
|
746
|
-
height: r.height,
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
const bbox = range.getBoundingClientRect();
|
|
750
|
-
anchorTop = bbox.top;
|
|
751
|
-
anchorBottom = bbox.bottom;
|
|
752
|
-
anchorLeft = bbox.left;
|
|
753
|
-
anchorWidth = bbox.width;
|
|
754
|
-
}
|
|
755
|
-
} catch {
|
|
756
|
-
/* keep empty */
|
|
757
|
-
}
|
|
758
|
-
setAnnot({
|
|
759
|
-
text,
|
|
760
|
-
rects,
|
|
761
|
-
anchorTop,
|
|
762
|
-
anchorBottom,
|
|
763
|
-
anchorLeft,
|
|
764
|
-
anchorWidth,
|
|
765
|
-
question: "",
|
|
766
|
-
showQuestion: false,
|
|
767
|
-
status: "idle",
|
|
768
|
-
explanation: null,
|
|
769
|
-
});
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
const onTextSelect = () => {
|
|
773
|
-
// Defer one frame so the browser's selection state is up-to-date.
|
|
774
|
-
// On some Macs `mouseup → getSelection()` still returns the previous
|
|
775
|
-
// selection (race condition between event dispatch and selection
|
|
776
|
-
// update). RAF lets that settle.
|
|
777
|
-
if (typeof window === "undefined") return;
|
|
778
|
-
window.requestAnimationFrame(captureSelection);
|
|
779
|
-
};
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Clear the native browser selection when our popover closes so the
|
|
783
|
-
* next user drag starts from a clean slate. Without this, Safari/
|
|
784
|
-
* Chromium occasionally re-emits the OLD selection on the next
|
|
785
|
-
* mouseup → looked like "annotation doesn't reappear" because we
|
|
786
|
-
* thought the new text was the same as the closed one.
|
|
787
|
-
*/
|
|
788
|
-
const closeAnnot = () => {
|
|
789
|
-
if (typeof window !== "undefined") {
|
|
790
|
-
try {
|
|
791
|
-
window.getSelection?.()?.removeAllRanges();
|
|
792
|
-
} catch {
|
|
793
|
-
/* ignore */
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
setAnnot(null);
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
useEffect(() => {
|
|
800
|
-
if (!annot) return;
|
|
801
|
-
const onKey = (e: KeyboardEvent) => {
|
|
802
|
-
if (e.key === "Escape") closeAnnot();
|
|
803
|
-
};
|
|
804
|
-
document.addEventListener("keydown", onKey);
|
|
805
|
-
return () => document.removeEventListener("keydown", onKey);
|
|
806
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
807
|
-
}, [annot]);
|
|
808
|
-
|
|
809
|
-
const runExplain = async (customQuestion?: string) => {
|
|
810
|
-
if (!annot || !content) return;
|
|
811
|
-
setAnnot({ ...annot, status: "loading", explanation: null });
|
|
812
|
-
try {
|
|
813
|
-
const r = (await reflex.actions.invoke({
|
|
814
|
-
name: "explainSelection",
|
|
815
|
-
args: {
|
|
816
|
-
selection: annot.text,
|
|
817
|
-
context: content.article.slice(0, 1500),
|
|
818
|
-
topic: course.topic,
|
|
819
|
-
moduleTitle: module.title,
|
|
820
|
-
...(customQuestion && customQuestion.trim()
|
|
821
|
-
? { question: customQuestion.trim() }
|
|
822
|
-
: {}),
|
|
823
|
-
},
|
|
824
|
-
})) as { text: string };
|
|
825
|
-
setAnnot((cur) =>
|
|
826
|
-
cur ? { ...cur, status: "done", explanation: r.text } : null,
|
|
827
|
-
);
|
|
828
|
-
} catch (err: unknown) {
|
|
829
|
-
onError(String(err));
|
|
830
|
-
setAnnot((cur) => (cur ? { ...cur, status: "idle" } : null));
|
|
831
|
-
}
|
|
832
|
-
};
|
|
833
|
-
|
|
834
|
-
const startQuiz = async () => {
|
|
835
|
-
if (!content) return;
|
|
836
|
-
setQuizBusy(true);
|
|
837
|
-
setQuizSubmitted(false);
|
|
838
|
-
setQuizAnswers({});
|
|
839
|
-
try {
|
|
840
|
-
const r = (await reflex.actions.invoke({
|
|
841
|
-
name: "generateQuiz",
|
|
842
|
-
args: {
|
|
843
|
-
moduleTitle: module.title,
|
|
844
|
-
moduleObjective: module.objective,
|
|
845
|
-
article: content.article,
|
|
846
|
-
},
|
|
847
|
-
})) as { questions: QuizQuestion[] };
|
|
848
|
-
onQuiz(r.questions);
|
|
849
|
-
} catch (err: unknown) {
|
|
850
|
-
onError(String(err));
|
|
851
|
-
} finally {
|
|
852
|
-
setQuizBusy(false);
|
|
853
|
-
}
|
|
854
|
-
};
|
|
855
|
-
|
|
856
|
-
const submitQuiz = async () => {
|
|
857
|
-
if (!quiz) return;
|
|
858
|
-
const correct = quiz.filter(
|
|
859
|
-
(q, i) => quizAnswers[i] === q.correctIndex,
|
|
860
|
-
).length;
|
|
861
|
-
const score = Math.round((correct / quiz.length) * 100);
|
|
862
|
-
setQuizSubmitted(true);
|
|
863
|
-
await onProgress({ quizScore: score, completed: score >= 60 });
|
|
864
|
-
await reflex.actions.invoke({ name: "refreshCourseCard" });
|
|
865
|
-
};
|
|
866
|
-
|
|
867
|
-
const makeTrainer = async () => {
|
|
868
|
-
setTrainerBusy(true);
|
|
869
|
-
try {
|
|
870
|
-
const r = (await reflex.actions.invoke({
|
|
871
|
-
name: "generateTrainer",
|
|
872
|
-
args: {
|
|
873
|
-
courseId: course.courseId,
|
|
874
|
-
moduleId: module.id,
|
|
875
|
-
moduleTitle: module.title,
|
|
876
|
-
moduleObjective: module.objective,
|
|
877
|
-
prompt: trainerIdea,
|
|
878
|
-
},
|
|
879
|
-
})) as { html: string; title: string };
|
|
880
|
-
onTrainer(r.html, r.title);
|
|
881
|
-
} catch (err: unknown) {
|
|
882
|
-
onError(String(err));
|
|
883
|
-
} finally {
|
|
884
|
-
setTrainerBusy(false);
|
|
885
|
-
}
|
|
886
|
-
};
|
|
887
|
-
|
|
888
|
-
if (building) {
|
|
889
|
-
return (
|
|
890
|
-
<Card>
|
|
891
|
-
<CardContent className="py-10 text-center text-sm text-slate-500">
|
|
892
|
-
📖 Подбираю материал, статьи, видео, схемы… 30-60 секунд.
|
|
893
|
-
</CardContent>
|
|
894
|
-
</Card>
|
|
895
|
-
);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
if (!content) {
|
|
899
|
-
return (
|
|
900
|
-
<Card>
|
|
901
|
-
<CardHeader>
|
|
902
|
-
<CardTitle>{module.title}</CardTitle>
|
|
903
|
-
</CardHeader>
|
|
904
|
-
<CardContent className="space-y-2">
|
|
905
|
-
<p className="text-sm text-slate-600">{module.objective}</p>
|
|
906
|
-
<Button onClick={() => void build()}>Подготовить модуль →</Button>
|
|
907
|
-
</CardContent>
|
|
908
|
-
</Card>
|
|
909
|
-
);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
return (
|
|
913
|
-
<div className="space-y-4">
|
|
914
|
-
<Card>
|
|
915
|
-
<CardHeader>
|
|
916
|
-
<CardTitle>{content.title}</CardTitle>
|
|
917
|
-
</CardHeader>
|
|
918
|
-
<CardContent className="space-y-3">
|
|
919
|
-
<div ref={articleRef}>
|
|
920
|
-
<ArticleView source={content.article} onMouseUp={onTextSelect} />
|
|
921
|
-
</div>
|
|
922
|
-
{/* Book-style annotation: yellow highlight on the selection
|
|
923
|
-
rectangles + a margin-note popover that floats near the
|
|
924
|
-
selection (above by default, below if no room). */}
|
|
925
|
-
{annot && (
|
|
926
|
-
<AnnotationOverlay
|
|
927
|
-
annot={annot}
|
|
928
|
-
onClose={closeAnnot}
|
|
929
|
-
onExplain={() => void runExplain()}
|
|
930
|
-
onAsk={(q) => void runExplain(q)}
|
|
931
|
-
onChange={(patch) =>
|
|
932
|
-
setAnnot((cur) => (cur ? { ...cur, ...patch } : null))
|
|
933
|
-
}
|
|
934
|
-
/>
|
|
935
|
-
)}
|
|
936
|
-
</CardContent>
|
|
937
|
-
</Card>
|
|
938
|
-
|
|
939
|
-
{content.images.length > 0 && (
|
|
940
|
-
<Card>
|
|
941
|
-
<CardHeader>
|
|
942
|
-
<CardTitle>🖼 Иллюстрации</CardTitle>
|
|
943
|
-
</CardHeader>
|
|
944
|
-
<CardContent className="grid grid-cols-2 gap-3">
|
|
945
|
-
{content.images.map((im, i) => (
|
|
946
|
-
<figure key={i} className="space-y-1">
|
|
947
|
-
<img
|
|
948
|
-
src={im.url}
|
|
949
|
-
alt={im.alt}
|
|
950
|
-
loading="lazy"
|
|
951
|
-
title={im.alt || "Открыть на весь экран"}
|
|
952
|
-
onClick={() =>
|
|
953
|
-
setGalleryZoom({ src: im.url, alt: im.alt })
|
|
954
|
-
}
|
|
955
|
-
className="rounded border bg-white cursor-zoom-in transition hover:opacity-90"
|
|
956
|
-
/>
|
|
957
|
-
<figcaption className="text-[11px] text-slate-500">
|
|
958
|
-
{im.alt}
|
|
959
|
-
</figcaption>
|
|
960
|
-
</figure>
|
|
961
|
-
))}
|
|
962
|
-
</CardContent>
|
|
963
|
-
</Card>
|
|
964
|
-
)}
|
|
965
|
-
{galleryZoom && (
|
|
966
|
-
<ArticleImageLightbox
|
|
967
|
-
src={galleryZoom.src}
|
|
968
|
-
alt={galleryZoom.alt}
|
|
969
|
-
onClose={() => setGalleryZoom(null)}
|
|
970
|
-
/>
|
|
971
|
-
)}
|
|
972
|
-
|
|
973
|
-
{content.diagrams.length > 0 && (
|
|
974
|
-
<Card>
|
|
975
|
-
<CardHeader>
|
|
976
|
-
<CardTitle>📐 Схемы</CardTitle>
|
|
977
|
-
</CardHeader>
|
|
978
|
-
<CardContent className="space-y-4">
|
|
979
|
-
{content.diagrams.map((d, i) => (
|
|
980
|
-
<div key={i}>
|
|
981
|
-
<div className="text-sm font-semibold text-slate-700 mb-1">
|
|
982
|
-
{d.title}
|
|
983
|
-
</div>
|
|
984
|
-
{/* Wrap mermaid source in a fenced block so ArticleView
|
|
985
|
-
picks it up and renders SVG via the global mermaid lib. */}
|
|
986
|
-
<ArticleView
|
|
987
|
-
source={"```mermaid\n" + d.mermaid + "\n```"}
|
|
988
|
-
/>
|
|
989
|
-
</div>
|
|
990
|
-
))}
|
|
991
|
-
</CardContent>
|
|
992
|
-
</Card>
|
|
993
|
-
)}
|
|
994
|
-
|
|
995
|
-
{content.videos.length > 0 && (
|
|
996
|
-
<Card>
|
|
997
|
-
<CardHeader>
|
|
998
|
-
<CardTitle>🎬 Видео</CardTitle>
|
|
999
|
-
</CardHeader>
|
|
1000
|
-
<CardContent className="space-y-2">
|
|
1001
|
-
{content.videos.map((v, i) => (
|
|
1002
|
-
<a
|
|
1003
|
-
key={i}
|
|
1004
|
-
href={v.url}
|
|
1005
|
-
target="_blank"
|
|
1006
|
-
rel="noopener noreferrer"
|
|
1007
|
-
className="block rounded border bg-white px-3 py-2 hover:bg-slate-50 text-sm"
|
|
1008
|
-
>
|
|
1009
|
-
<div className="font-medium">{v.title}</div>
|
|
1010
|
-
{v.note && (
|
|
1011
|
-
<div className="text-xs text-slate-500">{v.note}</div>
|
|
1012
|
-
)}
|
|
1013
|
-
<div className="text-[10px] font-mono text-slate-400 truncate">
|
|
1014
|
-
{v.url}
|
|
1015
|
-
</div>
|
|
1016
|
-
</a>
|
|
1017
|
-
))}
|
|
1018
|
-
</CardContent>
|
|
1019
|
-
</Card>
|
|
1020
|
-
)}
|
|
1021
|
-
|
|
1022
|
-
{content.links.length > 0 && (
|
|
1023
|
-
<Card>
|
|
1024
|
-
<CardHeader>
|
|
1025
|
-
<CardTitle>📑 Источники</CardTitle>
|
|
1026
|
-
</CardHeader>
|
|
1027
|
-
<CardContent className="space-y-1">
|
|
1028
|
-
{content.links.map((l, i) => (
|
|
1029
|
-
<a
|
|
1030
|
-
key={i}
|
|
1031
|
-
href={l.url}
|
|
1032
|
-
target="_blank"
|
|
1033
|
-
rel="noopener noreferrer"
|
|
1034
|
-
className="block text-sm hover:underline"
|
|
1035
|
-
>
|
|
1036
|
-
{l.title}
|
|
1037
|
-
{l.snippet && (
|
|
1038
|
-
<span className="text-xs text-slate-500"> — {l.snippet}</span>
|
|
1039
|
-
)}
|
|
1040
|
-
</a>
|
|
1041
|
-
))}
|
|
1042
|
-
</CardContent>
|
|
1043
|
-
</Card>
|
|
1044
|
-
)}
|
|
1045
|
-
|
|
1046
|
-
{content.homework.length > 0 && (
|
|
1047
|
-
<Card>
|
|
1048
|
-
<CardHeader>
|
|
1049
|
-
<CardTitle>📝 Домашнее задание</CardTitle>
|
|
1050
|
-
</CardHeader>
|
|
1051
|
-
<CardContent>
|
|
1052
|
-
<ul className="space-y-1 text-sm list-decimal pl-5">
|
|
1053
|
-
{content.homework.map((h, i) => (
|
|
1054
|
-
<li key={i}>{h}</li>
|
|
1055
|
-
))}
|
|
1056
|
-
</ul>
|
|
1057
|
-
</CardContent>
|
|
1058
|
-
</Card>
|
|
1059
|
-
)}
|
|
1060
|
-
|
|
1061
|
-
<Card>
|
|
1062
|
-
<CardHeader>
|
|
1063
|
-
<CardTitle>✅ Тест-проверка</CardTitle>
|
|
1064
|
-
</CardHeader>
|
|
1065
|
-
<CardContent className="space-y-3">
|
|
1066
|
-
{!quiz ? (
|
|
1067
|
-
<Button onClick={() => void startQuiz()} disabled={quizBusy}>
|
|
1068
|
-
{quizBusy ? "Готовлю…" : "Сгенерировать тест"}
|
|
1069
|
-
</Button>
|
|
1070
|
-
) : (
|
|
1071
|
-
<>
|
|
1072
|
-
<ol className="space-y-3 list-decimal pl-5">
|
|
1073
|
-
{quiz.map((q, i) => (
|
|
1074
|
-
<li key={i} className="text-sm">
|
|
1075
|
-
<div className="font-medium">{q.stem}</div>
|
|
1076
|
-
<ul className="mt-1 space-y-0.5">
|
|
1077
|
-
{q.options.map((opt, oi) => {
|
|
1078
|
-
const picked = quizAnswers[i] === oi;
|
|
1079
|
-
const correct = quizSubmitted && oi === q.correctIndex;
|
|
1080
|
-
const wrongPick =
|
|
1081
|
-
quizSubmitted && picked && oi !== q.correctIndex;
|
|
1082
|
-
return (
|
|
1083
|
-
<li key={oi}>
|
|
1084
|
-
<label
|
|
1085
|
-
className={
|
|
1086
|
-
"flex items-center gap-1.5 rounded px-2 py-1 text-xs cursor-pointer " +
|
|
1087
|
-
(correct
|
|
1088
|
-
? "bg-emerald-100 text-emerald-900"
|
|
1089
|
-
: wrongPick
|
|
1090
|
-
? "bg-red-100 text-red-900"
|
|
1091
|
-
: picked
|
|
1092
|
-
? "bg-violet-100"
|
|
1093
|
-
: "hover:bg-slate-100")
|
|
1094
|
-
}
|
|
1095
|
-
>
|
|
1096
|
-
<input
|
|
1097
|
-
type="radio"
|
|
1098
|
-
name={`q-${i}`}
|
|
1099
|
-
checked={picked}
|
|
1100
|
-
disabled={quizSubmitted}
|
|
1101
|
-
onChange={() =>
|
|
1102
|
-
setQuizAnswers((cur) => ({
|
|
1103
|
-
...cur,
|
|
1104
|
-
[i]: oi,
|
|
1105
|
-
}))
|
|
1106
|
-
}
|
|
1107
|
-
/>
|
|
1108
|
-
{opt}
|
|
1109
|
-
</label>
|
|
1110
|
-
</li>
|
|
1111
|
-
);
|
|
1112
|
-
})}
|
|
1113
|
-
</ul>
|
|
1114
|
-
{quizSubmitted && (
|
|
1115
|
-
<p className="text-[11px] text-slate-600 mt-1 italic">
|
|
1116
|
-
{q.explanation}
|
|
1117
|
-
</p>
|
|
1118
|
-
)}
|
|
1119
|
-
</li>
|
|
1120
|
-
))}
|
|
1121
|
-
</ol>
|
|
1122
|
-
{!quizSubmitted ? (
|
|
1123
|
-
<Button
|
|
1124
|
-
onClick={() => void submitQuiz()}
|
|
1125
|
-
disabled={Object.keys(quizAnswers).length < quiz.length}
|
|
1126
|
-
>
|
|
1127
|
-
Проверить
|
|
1128
|
-
</Button>
|
|
1129
|
-
) : (
|
|
1130
|
-
<Badge variant="default" className="text-xs">
|
|
1131
|
-
Результат:{" "}
|
|
1132
|
-
{Math.round(
|
|
1133
|
-
(quiz.filter((q, i) => quizAnswers[i] === q.correctIndex)
|
|
1134
|
-
.length /
|
|
1135
|
-
quiz.length) *
|
|
1136
|
-
100,
|
|
1137
|
-
)}
|
|
1138
|
-
%
|
|
1139
|
-
</Badge>
|
|
1140
|
-
)}
|
|
1141
|
-
</>
|
|
1142
|
-
)}
|
|
1143
|
-
</CardContent>
|
|
1144
|
-
</Card>
|
|
1145
|
-
|
|
1146
|
-
<Card>
|
|
1147
|
-
<CardHeader>
|
|
1148
|
-
<CardTitle>🕹 Тренажёр</CardTitle>
|
|
1149
|
-
</CardHeader>
|
|
1150
|
-
<CardContent className="space-y-2">
|
|
1151
|
-
<Input
|
|
1152
|
-
value={trainerIdea}
|
|
1153
|
-
onChange={(e) => setTrainerIdea(e.target.value)}
|
|
1154
|
-
placeholder="идея тренажёра (опционально) — Reflex придумает сам если пусто"
|
|
1155
|
-
/>
|
|
1156
|
-
<Button onClick={() => void makeTrainer()} disabled={trainerBusy}>
|
|
1157
|
-
{trainerBusy ? "Делаю…" : "Сгенерировать интерактивный тренажёр"}
|
|
1158
|
-
</Button>
|
|
1159
|
-
</CardContent>
|
|
1160
|
-
</Card>
|
|
1161
|
-
|
|
1162
|
-
<Card>
|
|
1163
|
-
<CardContent className="py-3 flex items-center gap-2">
|
|
1164
|
-
<Button
|
|
1165
|
-
onClick={() => void onProgress({ completed: true })}
|
|
1166
|
-
variant="outline"
|
|
1167
|
-
>
|
|
1168
|
-
✓ Отметить пройденным
|
|
1169
|
-
</Button>
|
|
1170
|
-
<Button
|
|
1171
|
-
onClick={() => void onProgress({ completed: false })}
|
|
1172
|
-
variant="outline"
|
|
1173
|
-
>
|
|
1174
|
-
Снять отметку
|
|
1175
|
-
</Button>
|
|
1176
|
-
</CardContent>
|
|
1177
|
-
</Card>
|
|
1178
|
-
</div>
|
|
1179
|
-
);
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// ---------------------------------------------------------------------------
|
|
1183
|
-
// TRAINER (sandboxed iframe via srcdoc)
|
|
1184
|
-
|
|
1185
|
-
function TrainerView({
|
|
1186
|
-
html,
|
|
1187
|
-
title,
|
|
1188
|
-
onClose,
|
|
1189
|
-
}: {
|
|
1190
|
-
html: string;
|
|
1191
|
-
title: string;
|
|
1192
|
-
onClose: () => void;
|
|
1193
|
-
}) {
|
|
1194
|
-
return (
|
|
1195
|
-
<Card>
|
|
1196
|
-
<CardHeader>
|
|
1197
|
-
<CardTitle>
|
|
1198
|
-
🕹 Тренажёр · {title}
|
|
1199
|
-
<Button
|
|
1200
|
-
type="button"
|
|
1201
|
-
variant="outline"
|
|
1202
|
-
className="ml-auto"
|
|
1203
|
-
onClick={onClose}
|
|
1204
|
-
>
|
|
1205
|
-
Закрыть
|
|
1206
|
-
</Button>
|
|
1207
|
-
</CardTitle>
|
|
1208
|
-
</CardHeader>
|
|
1209
|
-
<CardContent>
|
|
1210
|
-
<iframe
|
|
1211
|
-
srcDoc={html}
|
|
1212
|
-
sandbox="allow-scripts"
|
|
1213
|
-
className="w-full rounded border bg-white"
|
|
1214
|
-
style={{ height: 500 }}
|
|
1215
|
-
title={title}
|
|
1216
|
-
/>
|
|
1217
|
-
</CardContent>
|
|
1218
|
-
</Card>
|
|
1219
|
-
);
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// ---------------------------------------------------------------------------
|
|
1223
|
-
// helpers
|
|
1224
|
-
|
|
1225
|
-
async function markProgress(
|
|
1226
|
-
course: CourseSummary,
|
|
1227
|
-
moduleId: string,
|
|
1228
|
-
mark: { completed?: boolean; quizScore?: number },
|
|
1229
|
-
): Promise<void> {
|
|
1230
|
-
// Source of truth is the sandboxed JSON file — patch + rewrite. KB
|
|
1231
|
-
// entry stays static (we don't update it on every tick).
|
|
1232
|
-
const cur = await readCourse(course.courseId);
|
|
1233
|
-
const base: CourseState = cur ?? {
|
|
1234
|
-
courseId: course.courseId,
|
|
1235
|
-
topic: course.topic,
|
|
1236
|
-
modules: course.modules,
|
|
1237
|
-
progress: course.progress,
|
|
1238
|
-
wizardAnswers: [],
|
|
1239
|
-
createdAt: course.createdAt,
|
|
1240
|
-
updatedAt: course.createdAt,
|
|
1241
|
-
};
|
|
1242
|
-
const next: CourseState = {
|
|
1243
|
-
...base,
|
|
1244
|
-
progress: {
|
|
1245
|
-
...base.progress,
|
|
1246
|
-
[moduleId]: { ...base.progress[moduleId], ...mark },
|
|
1247
|
-
},
|
|
1248
|
-
updatedAt: new Date().toISOString(),
|
|
1249
|
-
};
|
|
1250
|
-
await writeCourse(next);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
|
1254
|
-
const m = /^---\n([\s\S]*?)\n---/.exec(raw);
|
|
1255
|
-
if (!m) return null;
|
|
1256
|
-
const out: Record<string, unknown> = {};
|
|
1257
|
-
for (const line of m[1]!.split("\n")) {
|
|
1258
|
-
const i = line.indexOf(":");
|
|
1259
|
-
if (i < 0) continue;
|
|
1260
|
-
out[line.slice(0, i).trim()] = line
|
|
1261
|
-
.slice(i + 1)
|
|
1262
|
-
.trim()
|
|
1263
|
-
.replace(/^["']|["']$/g, "");
|
|
1264
|
-
}
|
|
1265
|
-
return out;
|
|
1266
|
-
}
|
|
1267
|
-
function stripFrontmatter(raw: string): string {
|
|
1268
|
-
const m = /^---\n[\s\S]*?\n---\n?/.exec(raw);
|
|
1269
|
-
return m ? raw.slice(m[0].length) : raw;
|
|
1270
|
-
}
|
|
1271
|
-
function parseJson<T>(v: unknown): T | null {
|
|
1272
|
-
if (typeof v !== "string") return null;
|
|
1273
|
-
try {
|
|
1274
|
-
return JSON.parse(v) as T;
|
|
1275
|
-
} catch {
|
|
1276
|
-
return null;
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
function stringOf(v: unknown): string {
|
|
1280
|
-
return typeof v === "string" ? v : "";
|
|
1281
|
-
}
|
|
1282
|
-
function baseSlug(rel: string): string {
|
|
1283
|
-
return (rel.split("/").pop() ?? rel).replace(/\.md$/, "");
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
// ---------------------------------------------------------------------------
|
|
1287
|
-
// Annotation overlay — book-style margin note anchored to the user's
|
|
1288
|
-
// selection. Renders two layers:
|
|
1289
|
-
//
|
|
1290
|
-
// 1. Highlight rectangles painted over each selection client-rect so
|
|
1291
|
-
// the reader sees what the note refers to even after the browser
|
|
1292
|
-
// drops the native selection on button click.
|
|
1293
|
-
//
|
|
1294
|
-
// 2. A floating popover above the selection (or below if the
|
|
1295
|
-
// selection sits near the top of the viewport). Initial state
|
|
1296
|
-
// offers `✨ Объяснить` (default action) and `❓ Свой вопрос`
|
|
1297
|
-
// (reveals an input). After the agent replies the popover stays
|
|
1298
|
-
// pinned in place and shows the explanation inline — readable
|
|
1299
|
-
// like a Kindle/Apple Books margin annotation.
|
|
1300
|
-
|
|
1301
|
-
interface AnnotState {
|
|
1302
|
-
text: string;
|
|
1303
|
-
rects: { top: number; left: number; width: number; height: number }[];
|
|
1304
|
-
anchorTop: number;
|
|
1305
|
-
anchorBottom: number;
|
|
1306
|
-
anchorLeft: number;
|
|
1307
|
-
anchorWidth: number;
|
|
1308
|
-
question: string;
|
|
1309
|
-
showQuestion: boolean;
|
|
1310
|
-
status: "idle" | "loading" | "done";
|
|
1311
|
-
explanation: string | null;
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function AnnotationOverlay({
|
|
1315
|
-
annot,
|
|
1316
|
-
onClose,
|
|
1317
|
-
onExplain,
|
|
1318
|
-
onAsk,
|
|
1319
|
-
onChange,
|
|
1320
|
-
}: {
|
|
1321
|
-
annot: AnnotState;
|
|
1322
|
-
onClose: () => void;
|
|
1323
|
-
onExplain: () => void;
|
|
1324
|
-
onAsk: (question: string) => void;
|
|
1325
|
-
onChange: (patch: Partial<AnnotState>) => void;
|
|
1326
|
-
}) {
|
|
1327
|
-
// Popover is ~360px wide, ~auto height up to 60vh. Decide whether to
|
|
1328
|
-
// place it above or below the selection based on available room.
|
|
1329
|
-
const PW = 360;
|
|
1330
|
-
const PH_MAX = Math.min(
|
|
1331
|
-
480,
|
|
1332
|
-
typeof window !== "undefined" ? window.innerHeight * 0.7 : 480,
|
|
1333
|
-
);
|
|
1334
|
-
const viewportW =
|
|
1335
|
-
typeof window !== "undefined" ? window.innerWidth : 1024;
|
|
1336
|
-
const viewportH =
|
|
1337
|
-
typeof window !== "undefined" ? window.innerHeight : 768;
|
|
1338
|
-
const placeAbove = annot.anchorTop > PH_MAX + 24;
|
|
1339
|
-
const top = placeAbove
|
|
1340
|
-
? Math.max(8, annot.anchorTop - PH_MAX - 12)
|
|
1341
|
-
: Math.min(viewportH - PH_MAX - 8, annot.anchorBottom + 12);
|
|
1342
|
-
const desiredLeft = annot.anchorLeft + annot.anchorWidth / 2 - PW / 2;
|
|
1343
|
-
const left = Math.min(viewportW - PW - 8, Math.max(8, desiredLeft));
|
|
1344
|
-
|
|
1345
|
-
return (
|
|
1346
|
-
<>
|
|
1347
|
-
{/* Highlight overlay — one rectangle per visual line. */}
|
|
1348
|
-
{annot.rects.map((r, i) => (
|
|
1349
|
-
<div
|
|
1350
|
-
key={i}
|
|
1351
|
-
style={{
|
|
1352
|
-
position: "fixed",
|
|
1353
|
-
top: r.top,
|
|
1354
|
-
left: r.left,
|
|
1355
|
-
width: r.width,
|
|
1356
|
-
height: r.height,
|
|
1357
|
-
background: "rgba(250, 204, 21, 0.25)",
|
|
1358
|
-
borderBottom: "2px solid rgba(217, 119, 6, 0.65)",
|
|
1359
|
-
pointerEvents: "none",
|
|
1360
|
-
zIndex: 40,
|
|
1361
|
-
}}
|
|
1362
|
-
/>
|
|
1363
|
-
))}
|
|
1364
|
-
|
|
1365
|
-
{/* Margin note popover. */}
|
|
1366
|
-
<div
|
|
1367
|
-
role="dialog"
|
|
1368
|
-
aria-label="Объяснение"
|
|
1369
|
-
style={{
|
|
1370
|
-
position: "fixed",
|
|
1371
|
-
top,
|
|
1372
|
-
left,
|
|
1373
|
-
width: PW,
|
|
1374
|
-
maxHeight: PH_MAX,
|
|
1375
|
-
background: "#fffdf6",
|
|
1376
|
-
border: "1px solid #e7e5d6",
|
|
1377
|
-
borderRadius: 10,
|
|
1378
|
-
boxShadow:
|
|
1379
|
-
"0 1px 0 rgba(0,0,0,0.04), 0 10px 30px rgba(15,23,42,0.18)",
|
|
1380
|
-
zIndex: 50,
|
|
1381
|
-
display: "flex",
|
|
1382
|
-
flexDirection: "column",
|
|
1383
|
-
overflow: "hidden",
|
|
1384
|
-
}}
|
|
1385
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
1386
|
-
>
|
|
1387
|
-
<div
|
|
1388
|
-
style={{
|
|
1389
|
-
display: "flex",
|
|
1390
|
-
alignItems: "center",
|
|
1391
|
-
gap: 8,
|
|
1392
|
-
padding: "8px 10px",
|
|
1393
|
-
borderBottom: "1px solid #efece1",
|
|
1394
|
-
background:
|
|
1395
|
-
"linear-gradient(0deg, rgba(250,204,21,0.10), rgba(250,204,21,0.10))",
|
|
1396
|
-
fontSize: 12,
|
|
1397
|
-
color: "#7c5e10",
|
|
1398
|
-
}}
|
|
1399
|
-
>
|
|
1400
|
-
<span aria-hidden>✨</span>
|
|
1401
|
-
<span style={{ fontWeight: 600 }}>Заметка</span>
|
|
1402
|
-
<span
|
|
1403
|
-
style={{
|
|
1404
|
-
marginLeft: 6,
|
|
1405
|
-
color: "#94908a",
|
|
1406
|
-
fontSize: 11,
|
|
1407
|
-
fontStyle: "italic",
|
|
1408
|
-
flex: 1,
|
|
1409
|
-
overflow: "hidden",
|
|
1410
|
-
textOverflow: "ellipsis",
|
|
1411
|
-
whiteSpace: "nowrap",
|
|
1412
|
-
}}
|
|
1413
|
-
title={annot.text}
|
|
1414
|
-
>
|
|
1415
|
-
{annot.text}
|
|
1416
|
-
</span>
|
|
1417
|
-
<button
|
|
1418
|
-
type="button"
|
|
1419
|
-
onClick={onClose}
|
|
1420
|
-
aria-label="Закрыть"
|
|
1421
|
-
style={{
|
|
1422
|
-
border: "none",
|
|
1423
|
-
background: "transparent",
|
|
1424
|
-
color: "#94908a",
|
|
1425
|
-
cursor: "pointer",
|
|
1426
|
-
fontSize: 18,
|
|
1427
|
-
lineHeight: 1,
|
|
1428
|
-
padding: "0 4px",
|
|
1429
|
-
}}
|
|
1430
|
-
>
|
|
1431
|
-
×
|
|
1432
|
-
</button>
|
|
1433
|
-
</div>
|
|
1434
|
-
|
|
1435
|
-
<div
|
|
1436
|
-
style={{
|
|
1437
|
-
flex: 1,
|
|
1438
|
-
overflowY: "auto",
|
|
1439
|
-
padding: "10px 12px",
|
|
1440
|
-
fontSize: 13,
|
|
1441
|
-
lineHeight: 1.55,
|
|
1442
|
-
color: "#1f2937",
|
|
1443
|
-
}}
|
|
1444
|
-
>
|
|
1445
|
-
{annot.status === "idle" && (
|
|
1446
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
1447
|
-
<div style={{ display: "flex", gap: 8 }}>
|
|
1448
|
-
<button
|
|
1449
|
-
type="button"
|
|
1450
|
-
onClick={onExplain}
|
|
1451
|
-
style={{
|
|
1452
|
-
flex: 1,
|
|
1453
|
-
padding: "8px 12px",
|
|
1454
|
-
background: "#7c3aed",
|
|
1455
|
-
color: "white",
|
|
1456
|
-
border: "none",
|
|
1457
|
-
borderRadius: 6,
|
|
1458
|
-
fontSize: 12,
|
|
1459
|
-
fontWeight: 500,
|
|
1460
|
-
cursor: "pointer",
|
|
1461
|
-
}}
|
|
1462
|
-
>
|
|
1463
|
-
✨ Объяснить
|
|
1464
|
-
</button>
|
|
1465
|
-
<button
|
|
1466
|
-
type="button"
|
|
1467
|
-
onClick={() =>
|
|
1468
|
-
onChange({ showQuestion: !annot.showQuestion })
|
|
1469
|
-
}
|
|
1470
|
-
style={{
|
|
1471
|
-
padding: "8px 12px",
|
|
1472
|
-
background: annot.showQuestion ? "#ede9fe" : "white",
|
|
1473
|
-
color: "#5b21b6",
|
|
1474
|
-
border: "1px solid #ddd6fe",
|
|
1475
|
-
borderRadius: 6,
|
|
1476
|
-
fontSize: 12,
|
|
1477
|
-
cursor: "pointer",
|
|
1478
|
-
}}
|
|
1479
|
-
title="Задать свой вопрос про выделенный фрагмент"
|
|
1480
|
-
>
|
|
1481
|
-
❓ Свой вопрос
|
|
1482
|
-
</button>
|
|
1483
|
-
</div>
|
|
1484
|
-
{annot.showQuestion && (
|
|
1485
|
-
<form
|
|
1486
|
-
onSubmit={(e) => {
|
|
1487
|
-
e.preventDefault();
|
|
1488
|
-
if (annot.question.trim()) onAsk(annot.question);
|
|
1489
|
-
}}
|
|
1490
|
-
style={{ display: "flex", flexDirection: "column", gap: 6 }}
|
|
1491
|
-
>
|
|
1492
|
-
<textarea
|
|
1493
|
-
value={annot.question}
|
|
1494
|
-
onChange={(e) => onChange({ question: e.target.value })}
|
|
1495
|
-
placeholder="Например: «при чём тут этот закон?» или «дай пример»"
|
|
1496
|
-
autoFocus
|
|
1497
|
-
rows={3}
|
|
1498
|
-
style={{
|
|
1499
|
-
width: "100%",
|
|
1500
|
-
padding: "6px 8px",
|
|
1501
|
-
border: "1px solid #ddd6fe",
|
|
1502
|
-
borderRadius: 6,
|
|
1503
|
-
fontSize: 12,
|
|
1504
|
-
fontFamily: "inherit",
|
|
1505
|
-
resize: "vertical",
|
|
1506
|
-
}}
|
|
1507
|
-
/>
|
|
1508
|
-
<button
|
|
1509
|
-
type="submit"
|
|
1510
|
-
disabled={!annot.question.trim()}
|
|
1511
|
-
style={{
|
|
1512
|
-
padding: "6px 10px",
|
|
1513
|
-
background: "#7c3aed",
|
|
1514
|
-
color: "white",
|
|
1515
|
-
border: "none",
|
|
1516
|
-
borderRadius: 6,
|
|
1517
|
-
fontSize: 12,
|
|
1518
|
-
cursor: annot.question.trim() ? "pointer" : "default",
|
|
1519
|
-
opacity: annot.question.trim() ? 1 : 0.5,
|
|
1520
|
-
alignSelf: "flex-end",
|
|
1521
|
-
}}
|
|
1522
|
-
>
|
|
1523
|
-
Спросить →
|
|
1524
|
-
</button>
|
|
1525
|
-
</form>
|
|
1526
|
-
)}
|
|
1527
|
-
</div>
|
|
1528
|
-
)}
|
|
1529
|
-
|
|
1530
|
-
{annot.status === "loading" && (
|
|
1531
|
-
<div
|
|
1532
|
-
style={{
|
|
1533
|
-
display: "flex",
|
|
1534
|
-
alignItems: "center",
|
|
1535
|
-
gap: 8,
|
|
1536
|
-
color: "#7c5e10",
|
|
1537
|
-
fontStyle: "italic",
|
|
1538
|
-
padding: "8px 0",
|
|
1539
|
-
}}
|
|
1540
|
-
>
|
|
1541
|
-
<span aria-hidden>✨</span>
|
|
1542
|
-
<span>{annot.question ? "Думаю над вопросом…" : "Объясняю…"}</span>
|
|
1543
|
-
</div>
|
|
1544
|
-
)}
|
|
1545
|
-
|
|
1546
|
-
{annot.status === "done" && annot.explanation && (
|
|
1547
|
-
<div style={{ fontFamily: "Georgia, 'Times New Roman', serif" }}>
|
|
1548
|
-
<ArticleView source={annot.explanation} />
|
|
1549
|
-
<div
|
|
1550
|
-
style={{
|
|
1551
|
-
marginTop: 10,
|
|
1552
|
-
paddingTop: 8,
|
|
1553
|
-
borderTop: "1px dashed #efece1",
|
|
1554
|
-
display: "flex",
|
|
1555
|
-
gap: 6,
|
|
1556
|
-
}}
|
|
1557
|
-
>
|
|
1558
|
-
<button
|
|
1559
|
-
type="button"
|
|
1560
|
-
onClick={() =>
|
|
1561
|
-
onChange({
|
|
1562
|
-
status: "idle",
|
|
1563
|
-
explanation: null,
|
|
1564
|
-
showQuestion: true,
|
|
1565
|
-
})
|
|
1566
|
-
}
|
|
1567
|
-
style={{
|
|
1568
|
-
padding: "4px 8px",
|
|
1569
|
-
background: "white",
|
|
1570
|
-
color: "#5b21b6",
|
|
1571
|
-
border: "1px solid #ddd6fe",
|
|
1572
|
-
borderRadius: 6,
|
|
1573
|
-
fontSize: 11,
|
|
1574
|
-
cursor: "pointer",
|
|
1575
|
-
}}
|
|
1576
|
-
>
|
|
1577
|
-
❓ Ещё вопрос
|
|
1578
|
-
</button>
|
|
1579
|
-
</div>
|
|
1580
|
-
</div>
|
|
1581
|
-
)}
|
|
1582
|
-
</div>
|
|
1583
|
-
</div>
|
|
1584
|
-
</>
|
|
1585
|
-
);
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
// quiet unused-import linter for Memo-like helpers
|
|
1589
|
-
void useMemo;
|