reflex-agent 0.3.0 → 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 (148) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +101 -101
  3. package/.next/app-path-routes-manifest.json +9 -9
  4. package/.next/build-manifest.json +5 -5
  5. package/.next/prerender-manifest.json +3 -3
  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_client-reference-manifest.js +1 -1
  9. package/.next/server/app/agents/[agentId]/page.js +2 -2
  10. package/.next/server/app/agents/[agentId]/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/api/agents/[agentId]/respond/route.js +1 -1
  12. package/.next/server/app/api/agents/[agentId]/respond/route_client-reference-manifest.js +1 -1
  13. package/.next/server/app/api/images/[rootId]/[file]/route_client-reference-manifest.js +1 -1
  14. package/.next/server/app/api/oauth/callback/route_client-reference-manifest.js +1 -1
  15. package/.next/server/app/api/oauth/start/route_client-reference-manifest.js +1 -1
  16. package/.next/server/app/api/roots/[id]/attachments/route_client-reference-manifest.js +1 -1
  17. package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route.js +1 -1
  18. package/.next/server/app/api/roots/[id]/chat/[topicId]/send/route_client-reference-manifest.js +1 -1
  19. package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route.js +1 -1
  20. package/.next/server/app/api/roots/[id]/chat/[topicId]/stop/route_client-reference-manifest.js +1 -1
  21. package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route.js +1 -1
  22. package/.next/server/app/api/roots/[id]/chat/[topicId]/stream/route_client-reference-manifest.js +1 -1
  23. package/.next/server/app/api/roots/[id]/dashboard/route.js +1 -1
  24. package/.next/server/app/api/roots/[id]/dashboard/route_client-reference-manifest.js +1 -1
  25. package/.next/server/app/api/roots/[id]/suggestions/route.js +1 -1
  26. package/.next/server/app/api/roots/[id]/suggestions/route_client-reference-manifest.js +1 -1
  27. package/.next/server/app/api/utilities/[scope]/[id]/bundle.js/route_client-reference-manifest.js +1 -1
  28. package/.next/server/app/api/utilities/[scope]/[id]/host/route.js +1 -1
  29. package/.next/server/app/api/utilities/[scope]/[id]/host/route_client-reference-manifest.js +1 -1
  30. package/.next/server/app/api/utilities/[scope]/[id]/host-api.mjs/route_client-reference-manifest.js +1 -1
  31. package/.next/server/app/api/utilities/[scope]/[id]/host-ui.mjs/route_client-reference-manifest.js +1 -1
  32. package/.next/server/app/api/utilities/[scope]/[id]/iframe/route_client-reference-manifest.js +1 -1
  33. package/.next/server/app/api/utilities/[scope]/[id]/style.css/route_client-reference-manifest.js +1 -1
  34. package/.next/server/app/audit/page.js +1 -1
  35. package/.next/server/app/audit/page.js.nft.json +1 -1
  36. package/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  37. package/.next/server/app/onboarding/page.js +3 -3
  38. package/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
  39. package/.next/server/app/page.js +2 -2
  40. package/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/server/app/roots/[id]/chat/[topicId]/page.js +2 -2
  43. package/.next/server/app/roots/[id]/chat/[topicId]/page.js.nft.json +1 -1
  44. package/.next/server/app/roots/[id]/chat/[topicId]/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/roots/[id]/kb/[...slug]/page.js +2 -2
  46. package/.next/server/app/roots/[id]/kb/[...slug]/page.js.nft.json +1 -1
  47. package/.next/server/app/roots/[id]/kb/[...slug]/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/roots/[id]/page.js +3 -3
  49. package/.next/server/app/roots/[id]/page.js.nft.json +1 -1
  50. package/.next/server/app/roots/[id]/page_client-reference-manifest.js +1 -1
  51. package/.next/server/app/roots/[id]/workflows/[wfId]/page.js +2 -2
  52. package/.next/server/app/roots/[id]/workflows/[wfId]/page_client-reference-manifest.js +1 -1
  53. package/.next/server/app/roots/[id]/workflows/page.js +1 -1
  54. package/.next/server/app/roots/[id]/workflows/page.js.nft.json +1 -1
  55. package/.next/server/app/roots/[id]/workflows/page_client-reference-manifest.js +1 -1
  56. package/.next/server/app/roots/new/page.js +4 -4
  57. package/.next/server/app/roots/new/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app/settings/page.js +5 -5
  59. package/.next/server/app/settings/page.js.nft.json +1 -1
  60. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  61. package/.next/server/app/share/[id]/file/page.js +2 -2
  62. package/.next/server/app/share/[id]/file/page_client-reference-manifest.js +1 -1
  63. package/.next/server/app/share/[id]/page.js +2 -2
  64. package/.next/server/app/share/[id]/page.js.nft.json +1 -1
  65. package/.next/server/app/share/[id]/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/utilities/[scope]/[id]/page.js +2 -2
  67. package/.next/server/app/utilities/[scope]/[id]/page.js.nft.json +1 -1
  68. package/.next/server/app/utilities/[scope]/[id]/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/utilities/page.js +2 -2
  70. package/.next/server/app/utilities/page_client-reference-manifest.js +1 -1
  71. package/.next/server/app-paths-manifest.json +9 -9
  72. package/.next/server/chunks/285.js +3 -3
  73. package/.next/server/chunks/2959.js +1 -0
  74. package/.next/server/chunks/2995.js +1 -1
  75. package/.next/server/chunks/3332.js +1 -1
  76. package/.next/server/chunks/4514.js +3 -0
  77. package/.next/server/chunks/4812.js +1 -1
  78. package/.next/server/chunks/4925.js +1 -1
  79. package/.next/server/chunks/{3512.js → 5068.js} +2 -2
  80. package/.next/server/chunks/9098.js +1 -1
  81. package/.next/server/chunks/{6734.js → 9427.js} +1 -1
  82. package/.next/server/chunks/9538.js +1 -0
  83. package/.next/server/chunks/963.js +1 -0
  84. package/.next/server/middleware-build-manifest.js +1 -1
  85. package/.next/server/middleware-manifest.json +5 -5
  86. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  87. package/.next/server/pages/500.html +1 -1
  88. package/.next/server/server-reference-manifest.js +1 -1
  89. package/.next/server/server-reference-manifest.json +1 -1
  90. package/.next/static/chunks/2865-134f546f21ca4330.js +1 -0
  91. package/.next/static/chunks/4108.5abdb7812a13eafd.js +1 -0
  92. package/.next/static/chunks/5254-4196f25e56270de5.js +1 -0
  93. package/.next/static/chunks/5521-cbc665104c7e59d3.js +1 -0
  94. package/.next/static/chunks/8855-9b941d2b78f398ce.js +1 -0
  95. package/.next/static/chunks/8871-2948840b33c0863d.js +1 -0
  96. package/.next/static/chunks/app/layout-d4cf24375db6d793.js +1 -0
  97. package/.next/static/chunks/app/onboarding/page-7303664b62ccc24a.js +1 -0
  98. package/.next/static/chunks/app/page-97d312db91d569f7.js +1 -0
  99. package/.next/static/chunks/app/roots/[id]/chat/[topicId]/page-123f60a544619a3c.js +1 -0
  100. package/.next/static/chunks/app/roots/[id]/kb/[...slug]/{page-7d17b4e6a5231f56.js → page-e253597edb1b2440.js} +1 -1
  101. package/.next/static/chunks/app/roots/[id]/page-91a8de6a1c79f8a3.js +1 -0
  102. package/.next/static/chunks/app/roots/[id]/workflows/[wfId]/page-4300a52e163883df.js +1 -0
  103. package/.next/static/chunks/app/roots/new/page-6b104aad46a38173.js +1 -0
  104. package/.next/static/chunks/app/settings/page-afe1b80f7f45c5eb.js +1 -0
  105. package/.next/static/chunks/app/share/[id]/page-a5fb565bd892d4df.js +1 -0
  106. package/.next/static/chunks/app/utilities/[scope]/[id]/page-eb713a2b5209942c.js +1 -0
  107. package/.next/static/chunks/app/utilities/page-b7f30c151c42a27c.js +1 -0
  108. package/.next/static/chunks/{webpack-2b0eab4ccdf44f63.js → webpack-bddc3babcbc30dd7.js} +1 -1
  109. package/.next/static/css/60e9b6cdf1283e83.css +1 -0
  110. package/.next/trace +47 -47
  111. package/package.json +1 -2
  112. package/.next/server/chunks/1.js +0 -3
  113. package/.next/server/chunks/2192.js +0 -1
  114. package/.next/server/chunks/7215.js +0 -1
  115. package/.next/server/chunks/9944.js +0 -1
  116. package/.next/static/chunks/1082-326e649fb24d4945.js +0 -1
  117. package/.next/static/chunks/3736-f4e42d6d38be50b0.js +0 -1
  118. package/.next/static/chunks/4108.ca0bdf3cbf3c56cc.js +0 -1
  119. package/.next/static/chunks/7482-7ef26030a10ce14f.js +0 -1
  120. package/.next/static/chunks/8944-c4f2406ecd61094f.js +0 -1
  121. package/.next/static/chunks/9415-eb6b5d4c2de3a7c0.js +0 -1
  122. package/.next/static/chunks/app/layout-85eb1fd21dab0895.js +0 -1
  123. package/.next/static/chunks/app/onboarding/page-2013bd8124b9162e.js +0 -1
  124. package/.next/static/chunks/app/page-558a224e13ffb52c.js +0 -1
  125. package/.next/static/chunks/app/roots/[id]/chat/[topicId]/page-b42f03fd58669d12.js +0 -1
  126. package/.next/static/chunks/app/roots/[id]/page-4aab5266f432e37e.js +0 -1
  127. package/.next/static/chunks/app/roots/[id]/workflows/[wfId]/page-1ee3320bf5744efc.js +0 -1
  128. package/.next/static/chunks/app/roots/new/page-df8d2c1f0c64c37a.js +0 -1
  129. package/.next/static/chunks/app/settings/page-fdba798d9e243ad3.js +0 -1
  130. package/.next/static/chunks/app/share/[id]/page-818a451d05e08d26.js +0 -1
  131. package/.next/static/chunks/app/utilities/[scope]/[id]/page-2cee09cc2ab9b5e8.js +0 -1
  132. package/.next/static/chunks/app/utilities/page-44a51522b347f13e.js +0 -1
  133. package/.next/static/css/4b367c1d0fa99b78.css +0 -1
  134. package/packages/utilities/learn-anything/README.md +0 -41
  135. package/packages/utilities/learn-anything/actions/_json.ts +0 -191
  136. package/packages/utilities/learn-anything/actions/_store.ts +0 -248
  137. package/packages/utilities/learn-anything/actions/buildModule.ts +0 -488
  138. package/packages/utilities/learn-anything/actions/explainSelection.ts +0 -65
  139. package/packages/utilities/learn-anything/actions/generateOutline.ts +0 -170
  140. package/packages/utilities/learn-anything/actions/generateQuiz.ts +0 -72
  141. package/packages/utilities/learn-anything/actions/generateTrainer.ts +0 -106
  142. package/packages/utilities/learn-anything/actions/refreshCourseCard.ts +0 -76
  143. package/packages/utilities/learn-anything/actions/tutorAsk.ts +0 -93
  144. package/packages/utilities/learn-anything/article-view.tsx +0 -464
  145. package/packages/utilities/learn-anything/manifest.json +0 -42
  146. package/packages/utilities/learn-anything/ui.tsx +0 -1589
  147. /package/.next/static/{fhVNqfmJl5Mdfhyhg6orp → IGuuMcet1qtGZQCP2MEn4}/_buildManifest.js +0 -0
  148. /package/.next/static/{fhVNqfmJl5Mdfhyhg6orp → 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
- * "Learn Anything" — universal AI tutor / course builder.
18
- *
19
- * UI state machine:
20
- * list → user's existing courses + "New course" button
21
- * wizard → topic → agent-driven Q&A → ready
22
- * outline → preview of generated modules → "Start"
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">🎓 Learn Anything</h1>
116
- <span className="text-xs text-slate-500">
117
- personal AI tutor
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
- ← Back to courses
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: {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>🎯 What do you want to learn?</CardTitle>
236
- </CardHeader>
237
- <CardContent className="space-y-2">
238
- <Input
239
- value={topic}
240
- onChange={(e) => setTopic(e.target.value)}
241
- placeholder="e.g. watercolor painting, python for backend, spanish from scratch"
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
- Build course →
254
- </Button>
255
- </CardContent>
256
- </Card>
257
-
258
- <Card>
259
- <CardHeader>
260
- <CardTitle>📚 My courses ({courses.length})</CardTitle>
261
- </CardHeader>
262
- <CardContent>
263
- {courses.length === 0 ? (
264
- <p className="text-sm text-slate-500">
265
- None yet. Enter a topic above — Reflex will ask 3-4 questions
266
- and assemble a program.
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} modules
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
- Building the course program… this takes 20-40 seconds.
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
- Topic:
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="your answer…"
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
- Answer →
476
- </Button>
477
- <Button
478
- type="button"
479
- variant="outline"
480
- disabled={busy}
481
- onClick={() => void buildOutline(history)}
482
- >
483
- Enough, build the course
484
- </Button>
485
- <Button type="button" variant="outline" onClick={onCancel}>
486
- Cancel
487
- </Button>
488
- </div>
489
- </>
490
- ) : (
491
- <p className="text-sm text-slate-500">Thinking about the next question…</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>🧭 Program for course "{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} min)
523
- </span>
524
- </li>
525
- ))}
526
- </ol>
527
- <Button onClick={onStart}>Start learning →</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
- Progress: {done} of {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
- quiz {p.quizScore}%
587
- </Badge>
588
- )}
589
- <span className="text-[10px] text-slate-400">
590
- ~{m.estMinutes} min
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
- 📖 Gathering materials, articles, videos, diagrams… 30-60 seconds.
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()}>Prepare module →</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>🖼 Illustrations</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 || "Open fullscreen"}
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>📐 Diagrams</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>🎬 Videos</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>📑 Sources</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>📝 Homework</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>✅ Quiz check</CardTitle>
1064
- </CardHeader>
1065
- <CardContent className="space-y-3">
1066
- {!quiz ? (
1067
- <Button onClick={() => void startQuiz()} disabled={quizBusy}>
1068
- {quizBusy ? "Preparing…" : "Generate quiz"}
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
- Check
1128
- </Button>
1129
- ) : (
1130
- <Badge variant="default" className="text-xs">
1131
- Score:{" "}
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>🕹 Trainer</CardTitle>
1149
- </CardHeader>
1150
- <CardContent className="space-y-2">
1151
- <Input
1152
- value={trainerIdea}
1153
- onChange={(e) => setTrainerIdea(e.target.value)}
1154
- placeholder="trainer idea (optional) — Reflex will invent one if empty"
1155
- />
1156
- <Button onClick={() => void makeTrainer()} disabled={trainerBusy}>
1157
- {trainerBusy ? "Building…" : "Generate interactive trainer"}
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
- ✓ Mark as completed
1169
- </Button>
1170
- <Button
1171
- onClick={() => void onProgress({ completed: false })}
1172
- variant="outline"
1173
- >
1174
- Unmark
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
- 🕹 Trainer · {title}
1199
- <Button
1200
- type="button"
1201
- variant="outline"
1202
- className="ml-auto"
1203
- onClick={onClose}
1204
- >
1205
- Close
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 `✨ Explain` (default action) and `❓ Ask a question`
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="Explanation"
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 }}>Note</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="Close"
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
- ✨ Explain
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="Ask your own question about the selected fragment"
1480
- >
1481
- ❓ Ask a question
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="For example: 'what does this law have to do with it?' or 'give me an example'"
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
- Ask →
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 ? "Thinking about the question…" : "Explaining…"}</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
- ❓ Another question
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;