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.
Files changed (206) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +111 -98
  3. package/.next/app-path-routes-manifest.json +9 -9
  4. package/.next/build-manifest.json +5 -5
  5. package/.next/prerender-manifest.json +4 -54
  6. package/.next/react-loadable-manifest.json +1 -1
  7. package/.next/server/app/_not-found/page.js +1 -1
  8. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  9. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  10. package/.next/server/app/agents/[agentId]/page.js +3 -3
  11. package/.next/server/app/agents/[agentId]/page.js.nft.json +1 -1
  12. package/.next/server/app/agents/[agentId]/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/api/agents/[agentId]/respond/route.js +2 -2
  14. package/.next/server/app/api/agents/[agentId]/respond/route.js.nft.json +1 -1
  15. package/.next/server/app/api/agents/[agentId]/respond/route_client-reference-manifest.js +1 -1
  16. package/.next/server/app/api/images/[rootId]/[file]/route_client-reference-manifest.js +1 -1
  17. package/.next/server/app/api/oauth/callback/route.js +3 -3
  18. package/.next/server/app/api/oauth/callback/route_client-reference-manifest.js +1 -1
  19. package/.next/server/app/api/oauth/start/route_client-reference-manifest.js +1 -1
  20. package/.next/server/app/api/roots/[id]/attachments/route.js +0 -0
  21. package/.next/server/app/api/roots/[id]/attachments/route_client-reference-manifest.js +1 -1
  22. package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route.js +2 -2
  23. package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route.js.nft.json +1 -1
  24. package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route_client-reference-manifest.js +1 -1
  25. package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route.js +2 -2
  26. package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route.js.nft.json +1 -1
  27. package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route_client-reference-manifest.js +1 -1
  28. package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route.js +2 -2
  29. package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route.js.nft.json +1 -1
  30. package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route_client-reference-manifest.js +1 -1
  31. package/.next/server/app/api/roots/[id]/dashboard/route.js +1 -1
  32. package/.next/server/app/api/roots/[id]/dashboard/route.js.nft.json +1 -1
  33. package/.next/server/app/api/roots/[id]/dashboard/route_client-reference-manifest.js +1 -1
  34. package/.next/server/app/api/roots/[id]/suggestions/route.js +1 -1
  35. package/.next/server/app/api/roots/[id]/suggestions/route.js.nft.json +1 -1
  36. package/.next/server/app/api/roots/[id]/suggestions/route_client-reference-manifest.js +1 -1
  37. package/.next/server/app/api/utilities/[scope]/[id]/bundle.js/route_client-reference-manifest.js +1 -1
  38. package/.next/server/app/api/utilities/[scope]/[id]/host/route.js +2 -2
  39. package/.next/server/app/api/utilities/[scope]/[id]/host/route.js.nft.json +1 -1
  40. package/.next/server/app/api/utilities/[scope]/[id]/host/route_client-reference-manifest.js +1 -1
  41. package/.next/server/app/api/utilities/[scope]/[id]/host-api.mjs/route_client-reference-manifest.js +1 -1
  42. package/.next/server/app/api/utilities/[scope]/[id]/host-ui.mjs/route_client-reference-manifest.js +1 -1
  43. package/.next/server/app/api/utilities/[scope]/[id]/iframe/route_client-reference-manifest.js +1 -1
  44. package/.next/server/app/api/utilities/[scope]/[id]/style.css/route_client-reference-manifest.js +1 -1
  45. package/.next/server/app/audit/page.js +2 -2
  46. package/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/onboarding/page.js +4 -4
  49. package/.next/server/app/onboarding/page.js.nft.json +1 -1
  50. package/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
  51. package/.next/server/app/page.js +2 -2
  52. package/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/server/app/roots/[id]/chat/[topicId]/page.js +2 -6
  55. package/.next/server/app/roots/[id]/chat/[topicId]/page.js.nft.json +1 -1
  56. package/.next/server/app/roots/[id]/chat/[topicId]/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/roots/[id]/kb/[...slug]/page.js +2 -6
  58. package/.next/server/app/roots/[id]/kb/[...slug]/page.js.nft.json +1 -1
  59. package/.next/server/app/roots/[id]/kb/[...slug]/page_client-reference-manifest.js +1 -1
  60. package/.next/server/app/roots/[id]/page.js +3 -3
  61. package/.next/server/app/roots/[id]/page.js.nft.json +1 -1
  62. package/.next/server/app/roots/[id]/page_client-reference-manifest.js +1 -1
  63. package/.next/server/app/roots/[id]/workflows/[wfId]/page.js +2 -2
  64. package/.next/server/app/roots/[id]/workflows/[wfId]/page.js.nft.json +1 -1
  65. package/.next/server/app/roots/[id]/workflows/[wfId]/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/roots/[id]/workflows/page.js +2 -2
  67. package/.next/server/app/roots/[id]/workflows/page.js.nft.json +1 -1
  68. package/.next/server/app/roots/[id]/workflows/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/roots/new/page.js +4 -2
  70. package/.next/server/app/roots/new/page.js.nft.json +1 -1
  71. package/.next/server/app/roots/new/page_client-reference-manifest.js +1 -1
  72. package/.next/server/app/settings/page.js +6 -6
  73. package/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/share/[id]/file/page.js +2 -2
  76. package/.next/server/app/share/[id]/file/page.js.nft.json +1 -1
  77. package/.next/server/app/share/[id]/file/page_client-reference-manifest.js +1 -1
  78. package/.next/server/app/share/[id]/page.js +2 -2
  79. package/.next/server/app/share/[id]/page.js.nft.json +1 -1
  80. package/.next/server/app/share/[id]/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/utilities/[scope]/[id]/page.js +2 -2
  82. package/.next/server/app/utilities/[scope]/[id]/page.js.nft.json +1 -1
  83. package/.next/server/app/utilities/[scope]/[id]/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/utilities/page.js +2 -17
  85. package/.next/server/app/utilities/page.js.nft.json +1 -1
  86. package/.next/server/app/utilities/page_client-reference-manifest.js +1 -1
  87. package/.next/server/app-paths-manifest.json +9 -9
  88. package/.next/server/chunks/1223.js +1 -1
  89. package/.next/server/chunks/133.js +1 -490
  90. package/.next/server/chunks/1888.js +1 -1
  91. package/.next/server/chunks/{9739.js → 1988.js} +13 -9
  92. package/.next/server/chunks/2433.js +1 -1
  93. package/.next/server/chunks/2503.js +1 -1
  94. package/.next/server/chunks/285.js +471 -0
  95. package/.next/server/chunks/2959.js +1 -0
  96. package/.next/server/chunks/2995.js +1 -0
  97. package/.next/server/chunks/3240.js +1 -1
  98. package/.next/server/chunks/3332.js +1 -1
  99. package/.next/server/chunks/3657.js +1 -1
  100. package/.next/server/chunks/4066.js +1 -1
  101. package/.next/server/chunks/4438.js +1 -0
  102. package/.next/server/chunks/4514.js +3 -0
  103. package/.next/server/chunks/4553.js +1 -1
  104. package/.next/server/chunks/4812.js +179 -0
  105. package/.next/server/chunks/4925.js +1 -1
  106. package/.next/server/chunks/{3953.js → 5068.js} +2 -2
  107. package/.next/server/chunks/5319.js +1 -1
  108. package/.next/server/chunks/569.js +1 -1
  109. package/.next/server/chunks/6730.js +1 -1
  110. package/.next/server/chunks/6909.js +142 -161
  111. package/.next/server/chunks/8262.js +1 -1
  112. package/.next/server/chunks/9098.js +1 -1
  113. package/.next/server/chunks/94.js +1 -1
  114. package/.next/server/chunks/9427.js +1 -0
  115. package/.next/server/chunks/9538.js +1 -0
  116. package/.next/server/chunks/963.js +1 -0
  117. package/.next/server/chunks/9835.js +1 -1
  118. package/.next/server/middleware-build-manifest.js +1 -1
  119. package/.next/server/middleware-manifest.json +5 -5
  120. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  121. package/.next/server/pages/500.html +1 -1
  122. package/.next/server/pages-manifest.json +1 -2
  123. package/.next/server/server-reference-manifest.js +1 -1
  124. package/.next/server/server-reference-manifest.json +1 -1
  125. package/.next/static/chunks/2865-134f546f21ca4330.js +1 -0
  126. package/.next/static/chunks/4108.5abdb7812a13eafd.js +1 -0
  127. package/.next/static/chunks/5254-4196f25e56270de5.js +1 -0
  128. package/.next/static/chunks/5521-cbc665104c7e59d3.js +1 -0
  129. package/.next/static/chunks/6445-99824866a51b582a.js +1 -0
  130. package/.next/static/chunks/8855-9b941d2b78f398ce.js +1 -0
  131. package/.next/static/chunks/8871-2948840b33c0863d.js +1 -0
  132. package/.next/static/chunks/9411-af5f758c57741929.js +3 -0
  133. package/.next/static/chunks/app/agents/[agentId]/page-5d6f4cb16b42d02b.js +1 -0
  134. package/.next/static/chunks/app/layout-d4cf24375db6d793.js +1 -0
  135. package/.next/static/chunks/app/onboarding/page-7303664b62ccc24a.js +1 -0
  136. package/.next/static/chunks/app/page-97d312db91d569f7.js +1 -0
  137. package/.next/static/chunks/app/roots/[id]/chat/[topicId]/page-123f60a544619a3c.js +1 -0
  138. package/.next/static/chunks/app/roots/[id]/kb/[...slug]/page-e253597edb1b2440.js +1 -0
  139. package/.next/static/chunks/app/roots/[id]/page-91a8de6a1c79f8a3.js +1 -0
  140. package/.next/static/chunks/app/roots/[id]/workflows/[wfId]/page-4300a52e163883df.js +1 -0
  141. package/.next/static/chunks/app/roots/new/page-6b104aad46a38173.js +1 -0
  142. package/.next/static/chunks/app/settings/page-afe1b80f7f45c5eb.js +1 -0
  143. package/.next/static/chunks/app/share/[id]/page-a5fb565bd892d4df.js +1 -0
  144. package/.next/static/chunks/app/utilities/[scope]/[id]/page-eb713a2b5209942c.js +1 -0
  145. package/.next/static/chunks/app/utilities/page-b7f30c151c42a27c.js +1 -0
  146. package/.next/static/chunks/{webpack-5fca180586957874.js → webpack-bddc3babcbc30dd7.js} +1 -1
  147. package/.next/static/css/60e9b6cdf1283e83.css +1 -0
  148. package/.next/trace +47 -46
  149. package/dist/lib/reflex/agents/prompts.js +46 -46
  150. package/dist/lib/reflex/agents/prompts.js.map +1 -1
  151. package/dist/lib/reflex/prompts/defaults.js +102 -102
  152. package/next.config.ts +4 -1
  153. package/package.json +2 -2
  154. package/.next/server/app/_not-found.html +0 -1
  155. package/.next/server/app/_not-found.meta +0 -8
  156. package/.next/server/app/_not-found.rsc +0 -18
  157. package/.next/server/app/index.html +0 -1
  158. package/.next/server/app/index.meta +0 -9
  159. package/.next/server/app/index.rsc +0 -19
  160. package/.next/server/chunks/1410.js +0 -1
  161. package/.next/server/chunks/1986.js +0 -1
  162. package/.next/server/chunks/2448.js +0 -3
  163. package/.next/server/chunks/5754.js +0 -3
  164. package/.next/server/chunks/7097.js +0 -1
  165. package/.next/server/chunks/7782.js +0 -1
  166. package/.next/server/chunks/7987.js +0 -1
  167. package/.next/server/chunks/810.js +0 -1
  168. package/.next/server/chunks/8843.js +0 -1
  169. package/.next/server/chunks/9328.js +0 -179
  170. package/.next/server/pages/404.html +0 -1
  171. package/.next/static/chunks/2488-c9590facb4b9f184.js +0 -1
  172. package/.next/static/chunks/2684-257d38989ef53935.js +0 -1
  173. package/.next/static/chunks/4108.fb9f99a9c899ef54.js +0 -1
  174. package/.next/static/chunks/6231-d83c1544bbea8424.js +0 -1
  175. package/.next/static/chunks/9045-731ff0865352dd95.js +0 -1
  176. package/.next/static/chunks/9496-75ccd3fadb294fba.js +0 -1
  177. package/.next/static/chunks/992-4e7b7f722c629e21.js +0 -1
  178. package/.next/static/chunks/app/agents/[agentId]/page-0b5c2838354d0eba.js +0 -1
  179. package/.next/static/chunks/app/layout-9a59ed07c18cb786.js +0 -1
  180. package/.next/static/chunks/app/onboarding/page-79f07a813ea2abfe.js +0 -1
  181. package/.next/static/chunks/app/page-27f4b98b02ac4f79.js +0 -1
  182. package/.next/static/chunks/app/roots/[id]/chat/[topicId]/page-8db2d0b75cd333c8.js +0 -1
  183. package/.next/static/chunks/app/roots/[id]/kb/[...slug]/page-873b131eec3a2f30.js +0 -1
  184. package/.next/static/chunks/app/roots/[id]/page-270d0d49eb668784.js +0 -1
  185. package/.next/static/chunks/app/roots/[id]/workflows/[wfId]/page-7c1f10dbe0bcb9ad.js +0 -1
  186. package/.next/static/chunks/app/roots/new/page-ac1a9f6379710ca2.js +0 -1
  187. package/.next/static/chunks/app/settings/page-81cb1393e817dfc3.js +0 -1
  188. package/.next/static/chunks/app/share/[id]/page-2d123f0a99e1606f.js +0 -1
  189. package/.next/static/chunks/app/utilities/[scope]/[id]/page-0bbb8d17af80c1da.js +0 -1
  190. package/.next/static/chunks/app/utilities/page-e6ce673b9357bf1f.js +0 -1
  191. package/.next/static/css/87e01f779d555d04.css +0 -1
  192. package/packages/utilities/learn-anything/README.md +0 -41
  193. package/packages/utilities/learn-anything/actions/_json.ts +0 -191
  194. package/packages/utilities/learn-anything/actions/_store.ts +0 -248
  195. package/packages/utilities/learn-anything/actions/buildModule.ts +0 -487
  196. package/packages/utilities/learn-anything/actions/explainSelection.ts +0 -64
  197. package/packages/utilities/learn-anything/actions/generateOutline.ts +0 -170
  198. package/packages/utilities/learn-anything/actions/generateQuiz.ts +0 -72
  199. package/packages/utilities/learn-anything/actions/generateTrainer.ts +0 -106
  200. package/packages/utilities/learn-anything/actions/refreshCourseCard.ts +0 -76
  201. package/packages/utilities/learn-anything/actions/tutorAsk.ts +0 -93
  202. package/packages/utilities/learn-anything/article-view.tsx +0 -464
  203. package/packages/utilities/learn-anything/manifest.json +0 -42
  204. package/packages/utilities/learn-anything/ui.tsx +0 -1589
  205. /package/.next/static/{og_wC7UPkGtJDiapaTgBr → IGuuMcet1qtGZQCP2MEn4}/_buildManifest.js +0 -0
  206. /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;