handzon-core 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/collections.ts +102 -3
- package/src/components/ai/ChatButton.tsx +52 -4
- package/src/components/ai/ChatPanel.tsx +95 -27
- package/src/components/ai/CopyStep.tsx +44 -0
- package/src/components/ai/OpenInAgent.tsx +55 -0
- package/src/components/ai/SelectionAsk.tsx +98 -0
- package/src/components/ai/StepHelp.tsx +31 -0
- package/src/components/auth/UserMenu.tsx +6 -4
- package/src/components/home/ActiveFilterChips.tsx +2 -5
- package/src/components/home/FilterBar.tsx +9 -11
- package/src/components/home/Pagination.tsx +1 -3
- package/src/components/home/ResumeRail.tsx +3 -1
- package/src/components/mdx/Checkpoint.tsx +73 -4
- package/src/components/mdx/CopyPrompt.astro +10 -0
- package/src/components/mdx/CopyPrompt.tsx +56 -0
- package/src/components/mdx/HelpMe.astro +10 -0
- package/src/components/mdx/HelpMe.tsx +29 -0
- package/src/components/mdx/Playground.tsx +61 -9
- package/src/components/mdx/Quiz.tsx +18 -0
- package/src/components/ui/Dropdown.tsx +2 -10
- package/src/components/ui/MultiSelect.tsx +3 -17
- package/src/index.ts +27 -27
- package/src/layouts/TutorialLayout.astro +19 -0
- package/src/lib/ai/assist.ts +81 -0
- package/src/lib/ai/prompts.ts +126 -0
- package/src/lib/ai/stepData.ts +74 -0
- package/src/lib/mdx-components.ts +4 -0
- package/src/lib/progress/remote.ts +93 -24
- package/src/lib/progress/types.ts +23 -0
- package/src/lib/progress/useProgress.ts +12 -0
- package/src/pages/TutorialStep.astro +12 -1
- package/src/pages/paths.ts +2 -1
- package/src/server/auth/config.ts +2 -1
- package/src/server/auth/schema.ts +1 -8
- package/src/server/auth.ts +85 -6
- package/src/server/db/schema.ts +54 -1
- package/src/server/handlers/helpInbox.ts +45 -0
- package/src/server/handlers/mcp.ts +72 -0
- package/src/server/handlers/progress.ts +8 -29
- package/src/server/handlers/progressEvents.ts +68 -0
- package/src/server/handlers/tutorialStats.ts +6 -3
- package/src/server/mcp/protocol.ts +99 -0
- package/src/server/mcp/server.ts +94 -0
- package/src/server/mcp/tools.ts +175 -0
- package/src/server/mcp/writeTools.ts +407 -0
- package/src/server/progress.ts +86 -0
- package/src/server/progressBus.ts +51 -0
- package/src/server/tokens.ts +80 -0
- package/src/server/verify/evaluator.ts +134 -0
- package/src/types/ai.ts +6 -0
- package/styles/components/assist.css +101 -0
- package/styles/components/checkpoint.css +29 -0
- package/styles/components.css +1 -0
|
@@ -21,7 +21,12 @@ interface FilterState {
|
|
|
21
21
|
|
|
22
22
|
function parseCsv(value: string | null): Set<string> {
|
|
23
23
|
if (!value) return new Set();
|
|
24
|
-
return new Set(
|
|
24
|
+
return new Set(
|
|
25
|
+
value
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((s) => s.trim())
|
|
28
|
+
.filter(Boolean),
|
|
29
|
+
);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
function readUrlState(): FilterState {
|
|
@@ -61,11 +66,9 @@ function applyFilters(state: FilterState) {
|
|
|
61
66
|
let visible = 0;
|
|
62
67
|
cards.forEach((card) => {
|
|
63
68
|
const matchesQ = !q || card.dataset.search!.includes(q);
|
|
64
|
-
const matchesLevel =
|
|
65
|
-
state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
69
|
+
const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
66
70
|
const cardTags = (card.dataset.tags ?? "").split(",");
|
|
67
|
-
const matchesTag =
|
|
68
|
-
state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
71
|
+
const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
69
72
|
const show = matchesQ && matchesLevel && matchesTag;
|
|
70
73
|
if (show) {
|
|
71
74
|
card.removeAttribute("data-filter-hidden");
|
|
@@ -95,12 +98,7 @@ function applyFilters(state: FilterState) {
|
|
|
95
98
|
window.dispatchEvent(new CustomEvent("hz:filter-changed"));
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
export default function FilterBar({
|
|
99
|
-
difficulties,
|
|
100
|
-
tags,
|
|
101
|
-
difficultyCounts,
|
|
102
|
-
tagCounts,
|
|
103
|
-
}: Props) {
|
|
101
|
+
export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
|
|
104
102
|
const [state, setState] = useState<FilterState>(readUrlState);
|
|
105
103
|
|
|
106
104
|
useEffect(() => {
|
|
@@ -19,9 +19,7 @@ interface Props {
|
|
|
19
19
|
*/
|
|
20
20
|
function applyPagination(page: number, pageSize: number): number {
|
|
21
21
|
const visibleByFilter = Array.from(
|
|
22
|
-
document.querySelectorAll<HTMLElement>(
|
|
23
|
-
"[data-search]:not([data-filter-hidden])",
|
|
24
|
-
),
|
|
22
|
+
document.querySelectorAll<HTMLElement>("[data-search]:not([data-filter-hidden])"),
|
|
25
23
|
);
|
|
26
24
|
const start = (page - 1) * pageSize;
|
|
27
25
|
const end = start + pageSize;
|
|
@@ -44,7 +44,9 @@ export default function ResumeRail({ tutorials }: Props) {
|
|
|
44
44
|
<span className="rr-prefix">Continue</span>
|
|
45
45
|
<span className="rr-title">{mostRecent.title}</span>
|
|
46
46
|
<span className="rr-step">/ {mostRecent.step}</span>
|
|
47
|
-
<span className="rr-arrow" aria-hidden="true"
|
|
47
|
+
<span className="rr-arrow" aria-hidden="true">
|
|
48
|
+
→
|
|
49
|
+
</span>
|
|
48
50
|
</a>
|
|
49
51
|
);
|
|
50
52
|
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { Check } from "lucide-react";
|
|
2
|
-
import { useEffect, useId, useState } from "react";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
|
|
3
4
|
import { useProgress } from "../../lib/progress/useProgress";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Time an unchecked checkpoint must be on-screen before we surface
|
|
8
|
+
* the "Stuck?" nudge. Long enough that learners actively working
|
|
9
|
+
* won't see it, short enough that genuinely stuck learners do.
|
|
10
|
+
*/
|
|
11
|
+
const STUCK_DELAY_MS = 45_000;
|
|
12
|
+
|
|
5
13
|
interface Props {
|
|
6
14
|
label: string;
|
|
7
15
|
id?: string;
|
|
@@ -27,23 +35,84 @@ function useRoute() {
|
|
|
27
35
|
export default function Checkpoint({ label, id }: Props) {
|
|
28
36
|
const reactId = useId();
|
|
29
37
|
const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
|
|
30
|
-
const { state, recordCheckpoint, markStepComplete } =
|
|
38
|
+
const { state, recordCheckpoint, removeCheckpoint, markStepComplete, markStepIncomplete } =
|
|
39
|
+
useProgress();
|
|
31
40
|
const route = useRoute();
|
|
32
41
|
const done = !!state.checkpoints[checkpointId];
|
|
42
|
+
const aiEnabled = useAiEnabled();
|
|
43
|
+
const [stuck, setStuck] = useState(false);
|
|
44
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
|
|
46
|
+
// Show the "Stuck?" nudge after STUCK_DELAY_MS of an unchecked
|
|
47
|
+
// checkpoint being on-screen. Resets the timer if the checkpoint
|
|
48
|
+
// scrolls back off screen. Fires once — once shown, stays shown
|
|
49
|
+
// until the learner ticks the checkpoint.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (done || !aiEnabled || stuck) return;
|
|
52
|
+
const el = rootRef.current;
|
|
53
|
+
if (!el) return;
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
const observer = new IntersectionObserver(
|
|
56
|
+
(entries) => {
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
if (e.isIntersecting) {
|
|
59
|
+
if (timer === null) timer = setTimeout(() => setStuck(true), STUCK_DELAY_MS);
|
|
60
|
+
} else if (timer !== null) {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
timer = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{ threshold: 0.5 },
|
|
67
|
+
);
|
|
68
|
+
observer.observe(el);
|
|
69
|
+
return () => {
|
|
70
|
+
observer.disconnect();
|
|
71
|
+
if (timer !== null) clearTimeout(timer);
|
|
72
|
+
};
|
|
73
|
+
}, [done, aiEnabled, stuck]);
|
|
33
74
|
|
|
34
75
|
function onToggle() {
|
|
35
|
-
if (done)
|
|
76
|
+
if (done) {
|
|
77
|
+
removeCheckpoint(checkpointId);
|
|
78
|
+
if (route) markStepIncomplete(route.tutorial, route.step);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
36
81
|
recordCheckpoint(checkpointId);
|
|
37
82
|
if (route) markStepComplete(route.tutorial, route.step);
|
|
38
83
|
}
|
|
39
84
|
|
|
85
|
+
// Family D: inline failure feedback from a submit_verification call.
|
|
86
|
+
// SSE pushes the verification row into state.verificationFeedback;
|
|
87
|
+
// we render it under the checkpoint label when present and the
|
|
88
|
+
// checkpoint isn't ticked yet. Cleared on the next pass or when
|
|
89
|
+
// the learner unchecks the checkpoint.
|
|
90
|
+
const feedback = state.verificationFeedback[checkpointId];
|
|
91
|
+
const showFeedback = !done && feedback && !feedback.pass;
|
|
92
|
+
|
|
40
93
|
return (
|
|
41
|
-
<div className={done ? "checkpoint is-done" : "checkpoint"}>
|
|
94
|
+
<div ref={rootRef} className={done ? "checkpoint is-done" : "checkpoint"}>
|
|
42
95
|
<button type="button" onClick={onToggle} aria-pressed={done}>
|
|
43
96
|
<span className="checkpoint-box">{done && <Check size={16} />}</span>
|
|
44
97
|
<span>{label}</span>
|
|
45
98
|
</button>
|
|
46
99
|
{done && <span className="checkpoint-msg">Step complete</span>}
|
|
100
|
+
{stuck && !done && (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="checkpoint-nudge"
|
|
104
|
+
onClick={() => dispatchAssist({ kind: "checkpoint", label })}
|
|
105
|
+
>
|
|
106
|
+
Stuck? Ask the tutor →
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
{showFeedback && (
|
|
110
|
+
<div className="checkpoint-feedback" role="status">
|
|
111
|
+
<strong>Check failed</strong>
|
|
112
|
+
{feedback.reason && <p>{feedback.reason}</p>}
|
|
113
|
+
{feedback.hint && <p className="checkpoint-feedback-hint">{feedback.hint}</p>}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
47
116
|
</div>
|
|
48
117
|
);
|
|
49
118
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Check, ClipboardCopy } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { readStepData, renderTemplate } from "../../lib/ai/stepData";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/**
|
|
7
|
+
* Templated prompt body. Supports `{{tutorialTitle}}`,
|
|
8
|
+
* `{{tutorialSlug}}`, `{{stepTitle}}`, `{{stepSlug}}`, and
|
|
9
|
+
* `{{stepSource}}` placeholders. Unknown placeholders are left
|
|
10
|
+
* untouched so typos surface.
|
|
11
|
+
*/
|
|
12
|
+
template: string;
|
|
13
|
+
/** Override the default button label. */
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Family B touchpoint: author-placed "Copy prompt" button that
|
|
19
|
+
* always works (no aiConfig required). Substitutes step + tutorial
|
|
20
|
+
* data into the template at click time so it stays in sync if the
|
|
21
|
+
* author edits the step.
|
|
22
|
+
*/
|
|
23
|
+
export default function CopyPrompt({ template, label }: Props) {
|
|
24
|
+
const [copied, setCopied] = useState(false);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
async function copy() {
|
|
28
|
+
setError(null);
|
|
29
|
+
const data = readStepData();
|
|
30
|
+
const body = data ? renderTemplate(template, data) : template;
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(body);
|
|
33
|
+
setCopied(true);
|
|
34
|
+
window.setTimeout(() => setCopied(false), 1800);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="hz-copy-prompt"
|
|
44
|
+
onClick={() => void copy()}
|
|
45
|
+
aria-live="polite"
|
|
46
|
+
title={error ?? undefined}
|
|
47
|
+
>
|
|
48
|
+
{copied ? (
|
|
49
|
+
<Check size={14} aria-hidden="true" />
|
|
50
|
+
) : (
|
|
51
|
+
<ClipboardCopy size={14} aria-hidden="true" />
|
|
52
|
+
)}
|
|
53
|
+
<span>{copied ? "Copied" : (label ?? "Copy prompt")}</span>
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LifeBuoy } from "lucide-react";
|
|
2
|
+
import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** Free-form topic appended to the seed message ("I'm stuck on …"). */
|
|
6
|
+
topic?: string;
|
|
7
|
+
/** Override the default button label. */
|
|
8
|
+
label?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Author-placed inline button that opens the in-app tutor pre-seeded
|
|
13
|
+
* with an "unstuck" intent. Renders nothing when `aiConfig.enabled`
|
|
14
|
+
* is false on the host page.
|
|
15
|
+
*/
|
|
16
|
+
export default function HelpMe({ topic, label }: Props) {
|
|
17
|
+
const enabled = useAiEnabled();
|
|
18
|
+
if (!enabled) return null;
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
className="hz-helpme"
|
|
23
|
+
onClick={() => dispatchAssist({ kind: "unstuck", topic })}
|
|
24
|
+
>
|
|
25
|
+
<LifeBuoy size={14} aria-hidden="true" />
|
|
26
|
+
<span>{label ?? (topic ? `Stuck on ${topic}?` : "Stuck? Ask the tutor")}</span>
|
|
27
|
+
</button>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
SandpackCodeEditor,
|
|
3
|
+
SandpackConsole,
|
|
4
|
+
SandpackLayout,
|
|
5
|
+
type SandpackPredefinedTemplate,
|
|
6
|
+
SandpackPreview,
|
|
7
|
+
SandpackProvider,
|
|
8
|
+
useSandpack,
|
|
9
|
+
} from "@codesandbox/sandpack-react";
|
|
10
|
+
import { Sparkles } from "lucide-react";
|
|
11
|
+
import { dispatchAssist, useAiTool } from "../../lib/ai/assist";
|
|
2
12
|
|
|
3
13
|
interface Props {
|
|
4
14
|
template?: SandpackPredefinedTemplate;
|
|
@@ -6,6 +16,42 @@ interface Props {
|
|
|
6
16
|
dependencies?: Record<string, string>;
|
|
7
17
|
height?: number;
|
|
8
18
|
showConsole?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Whether to render the "Ask AI to fix" button in the toolbar.
|
|
21
|
+
* Driven by `aiConfig.tools.suggestPlaygroundEdit` on the host
|
|
22
|
+
* page; the button still hides itself client-side when the tutor
|
|
23
|
+
* isn't enabled.
|
|
24
|
+
*/
|
|
25
|
+
askAiToFix?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Toolbar button rendered inside SandpackProvider so useSandpack
|
|
30
|
+
* can read the *current* file contents (post-edit) at click time.
|
|
31
|
+
*/
|
|
32
|
+
function AskAiButton() {
|
|
33
|
+
const toolEnabled = useAiTool("suggestPlaygroundEdit");
|
|
34
|
+
const { sandpack } = useSandpack();
|
|
35
|
+
if (!toolEnabled) return null;
|
|
36
|
+
|
|
37
|
+
function onClick() {
|
|
38
|
+
const files: Record<string, string> = {};
|
|
39
|
+
for (const [path, entry] of Object.entries(sandpack.files)) {
|
|
40
|
+
// Hidden files are author scaffolding (e.g. config) the learner
|
|
41
|
+
// didn't touch — exclude them so the prompt focuses on what
|
|
42
|
+
// they're actually editing.
|
|
43
|
+
if (entry.hidden) continue;
|
|
44
|
+
files[path] = entry.code;
|
|
45
|
+
}
|
|
46
|
+
dispatchAssist({ kind: "playgroundFix", files });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<button type="button" className="hz-helpme hz-playground-ask" onClick={onClick}>
|
|
51
|
+
<Sparkles size={14} aria-hidden="true" />
|
|
52
|
+
<span>Ask AI to fix</span>
|
|
53
|
+
</button>
|
|
54
|
+
);
|
|
9
55
|
}
|
|
10
56
|
|
|
11
57
|
export default function Playground({
|
|
@@ -14,21 +60,27 @@ export default function Playground({
|
|
|
14
60
|
dependencies,
|
|
15
61
|
height = 480,
|
|
16
62
|
showConsole = true,
|
|
63
|
+
askAiToFix = true,
|
|
17
64
|
}: Props) {
|
|
18
65
|
return (
|
|
19
66
|
<div className="playground" style={{ height }}>
|
|
20
|
-
<
|
|
67
|
+
<SandpackProvider
|
|
21
68
|
template={template}
|
|
22
69
|
files={files}
|
|
23
70
|
customSetup={dependencies ? { dependencies } : undefined}
|
|
24
71
|
theme="dark"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
72
|
+
>
|
|
73
|
+
<SandpackLayout>
|
|
74
|
+
<SandpackCodeEditor style={{ height }} />
|
|
75
|
+
<SandpackPreview style={{ height }} showOpenInCodeSandbox={false} />
|
|
76
|
+
</SandpackLayout>
|
|
77
|
+
{showConsole && <SandpackConsole />}
|
|
78
|
+
{askAiToFix && (
|
|
79
|
+
<div className="playground-toolbar">
|
|
80
|
+
<AskAiButton />
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</SandpackProvider>
|
|
32
84
|
</div>
|
|
33
85
|
);
|
|
34
86
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Check, X } from "lucide-react";
|
|
2
2
|
import { useId, useState } from "react";
|
|
3
|
+
import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
|
|
3
4
|
import { useProgress } from "../../lib/progress/useProgress";
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
@@ -15,6 +16,7 @@ export default function Quiz({ question, options, answer, explanation, id, multi
|
|
|
15
16
|
const reactId = useId();
|
|
16
17
|
const questionId = id ?? `quiz:${reactId}:${question.slice(0, 40)}`;
|
|
17
18
|
const { state, recordQuiz } = useProgress();
|
|
19
|
+
const aiEnabled = useAiEnabled();
|
|
18
20
|
|
|
19
21
|
const previous = state.quizzes[questionId];
|
|
20
22
|
const [chosen, setChosen] = useState<number[]>(previous?.chosen ?? []);
|
|
@@ -97,6 +99,22 @@ export default function Quiz({ question, options, answer, explanation, id, multi
|
|
|
97
99
|
)}
|
|
98
100
|
</div>
|
|
99
101
|
{submitted && explanation && <p className="quiz-exp">{explanation}</p>}
|
|
102
|
+
{submitted && previous && !previous.correct && aiEnabled && (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="hz-helpme"
|
|
106
|
+
onClick={() =>
|
|
107
|
+
dispatchAssist({
|
|
108
|
+
kind: "quizFix",
|
|
109
|
+
question,
|
|
110
|
+
chosen: previous.chosen.map((i) => options[i] ?? `(option ${i})`),
|
|
111
|
+
correct: Array.from(correctSet).map((i) => options[i] ?? `(option ${i})`),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
Why is this wrong?
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
100
118
|
</fieldset>
|
|
101
119
|
);
|
|
102
120
|
}
|
|
@@ -65,21 +65,13 @@ export default function Dropdown<V extends string = string>({
|
|
|
65
65
|
</Select.Trigger>
|
|
66
66
|
|
|
67
67
|
<Select.Portal>
|
|
68
|
-
<Select.Content
|
|
69
|
-
className="hz-dd-content"
|
|
70
|
-
position="popper"
|
|
71
|
-
sideOffset={6}
|
|
72
|
-
>
|
|
68
|
+
<Select.Content className="hz-dd-content" position="popper" sideOffset={6}>
|
|
73
69
|
<Select.ScrollUpButton className="hz-dd-scroll">
|
|
74
70
|
<ChevronUp size={14} aria-hidden="true" />
|
|
75
71
|
</Select.ScrollUpButton>
|
|
76
72
|
<Select.Viewport className="hz-dd-viewport">
|
|
77
73
|
{options.map((opt) => (
|
|
78
|
-
<Select.Item
|
|
79
|
-
key={opt.value}
|
|
80
|
-
value={opt.value}
|
|
81
|
-
className="hz-dd-item"
|
|
82
|
-
>
|
|
74
|
+
<Select.Item key={opt.value} value={opt.value} className="hz-dd-item">
|
|
83
75
|
{opt.icon && <span className="hz-dd-icon">{opt.icon}</span>}
|
|
84
76
|
<Select.ItemText>{opt.label}</Select.ItemText>
|
|
85
77
|
<Select.ItemIndicator className="hz-dd-check">
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import * as Popover from "@radix-ui/react-popover";
|
|
2
2
|
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
3
|
-
import {
|
|
4
|
-
type KeyboardEvent,
|
|
5
|
-
type ReactNode,
|
|
6
|
-
useEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
11
4
|
|
|
12
5
|
export interface MultiSelectOption {
|
|
13
6
|
value: string;
|
|
@@ -64,10 +57,7 @@ export default function MultiSelect({
|
|
|
64
57
|
// Sort by count desc so "weighty" facets bubble up. Stable
|
|
65
58
|
// alphabetical secondary sort keeps neighbours predictable.
|
|
66
59
|
const sorted = useMemo(
|
|
67
|
-
() =>
|
|
68
|
-
[...options].sort(
|
|
69
|
-
(a, b) => b.count - a.count || a.label.localeCompare(b.label),
|
|
70
|
-
),
|
|
60
|
+
() => [...options].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
|
|
71
61
|
[options],
|
|
72
62
|
);
|
|
73
63
|
|
|
@@ -158,11 +148,7 @@ export default function MultiSelect({
|
|
|
158
148
|
</button>
|
|
159
149
|
</Popover.Trigger>
|
|
160
150
|
<Popover.Portal>
|
|
161
|
-
<Popover.Content
|
|
162
|
-
className="hz-ms-content"
|
|
163
|
-
align="start"
|
|
164
|
-
sideOffset={6}
|
|
165
|
-
>
|
|
151
|
+
<Popover.Content className="hz-ms-content" align="start" sideOffset={6}>
|
|
166
152
|
{searchable && (
|
|
167
153
|
<label className="hz-ms-search">
|
|
168
154
|
<Search size={14} aria-hidden="true" />
|
package/src/index.ts
CHANGED
|
@@ -8,48 +8,48 @@
|
|
|
8
8
|
* and types.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
// AI client (browser-side BYOK + streaming chat to handzon-ai).
|
|
12
|
+
export {
|
|
13
|
+
type ChatMessage,
|
|
14
|
+
clearLearnerKey,
|
|
15
|
+
loadLearnerKey,
|
|
16
|
+
saveLearnerKey,
|
|
17
|
+
streamChat,
|
|
18
|
+
} from "./lib/ai/client.ts";
|
|
19
|
+
export { type AssistantContext, buildContext } from "./lib/ai/context.ts";
|
|
20
|
+
export {
|
|
21
|
+
type AssistantIntent,
|
|
22
|
+
type AssistantPrompt,
|
|
23
|
+
buildAssistantPrompt,
|
|
24
|
+
} from "./lib/ai/prompts.ts";
|
|
11
25
|
// Content collection helpers (built on top of astro:content).
|
|
12
26
|
export {
|
|
13
|
-
parseStepId,
|
|
14
|
-
getTutorials,
|
|
15
|
-
getTutorialBySlug,
|
|
16
|
-
getStepsForTutorial,
|
|
17
27
|
getStep,
|
|
28
|
+
getStepsForTutorial,
|
|
29
|
+
getTutorialBySlug,
|
|
30
|
+
getTutorials,
|
|
31
|
+
parseStepId,
|
|
32
|
+
type StepEntry,
|
|
18
33
|
sumDurations,
|
|
19
34
|
type TutorialEntry,
|
|
20
|
-
type StepEntry,
|
|
21
35
|
} from "./lib/content.ts";
|
|
22
|
-
|
|
23
36
|
// MDX component map used by .astro pages rendering tutorial content.
|
|
24
37
|
export { mdxComponents } from "./lib/mdx-components.ts";
|
|
25
|
-
|
|
26
|
-
// Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
|
|
27
|
-
export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
|
|
28
|
-
|
|
29
|
-
// AI client (browser-side BYOK + streaming chat to handzon-ai).
|
|
30
|
-
export {
|
|
31
|
-
streamChat,
|
|
32
|
-
loadLearnerKey,
|
|
33
|
-
saveLearnerKey,
|
|
34
|
-
clearLearnerKey,
|
|
35
|
-
type ChatMessage,
|
|
36
|
-
} from "./lib/ai/client.ts";
|
|
37
|
-
|
|
38
|
-
export { buildContext, type AssistantContext } from "./lib/ai/context.ts";
|
|
39
|
-
|
|
40
38
|
// Progress store (localStorage + optional server sync).
|
|
41
39
|
export { getStore } from "./lib/progress/local.ts";
|
|
42
|
-
export {
|
|
43
|
-
useProgress,
|
|
44
|
-
useProgressAfterMount,
|
|
45
|
-
} from "./lib/progress/useProgress.ts";
|
|
46
40
|
export {
|
|
47
41
|
emptyState,
|
|
48
|
-
type ProgressState,
|
|
49
|
-
type StepKey,
|
|
50
42
|
type LastVisitedEntry,
|
|
43
|
+
type ProgressState,
|
|
51
44
|
type ProgressStore,
|
|
45
|
+
type StepKey,
|
|
52
46
|
} from "./lib/progress/types.ts";
|
|
47
|
+
export {
|
|
48
|
+
useProgress,
|
|
49
|
+
useProgressAfterMount,
|
|
50
|
+
} from "./lib/progress/useProgress.ts";
|
|
51
|
+
// Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
|
|
52
|
+
export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
|
|
53
53
|
|
|
54
54
|
// AI config type (consumers provide concrete values; framework consumes shape).
|
|
55
55
|
export type { AiConfig } from "./types/ai.ts";
|
|
@@ -82,9 +82,28 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
|
82
82
|
id="tt-route"
|
|
83
83
|
data-tutorial-slug={tutorial.id}
|
|
84
84
|
data-step-slug={currentStepSlug}
|
|
85
|
+
data-tutorial-title={tutorial.data.title}
|
|
85
86
|
data-tutorial-steps={JSON.stringify(stepSlugs)}
|
|
86
87
|
hidden
|
|
87
88
|
></div>
|
|
89
|
+
{/*
|
|
90
|
+
Raw MDX source of the current step + selected metadata. CopyPrompt,
|
|
91
|
+
"Copy step as Markdown", deep-link row, and the BYOA touchpoints
|
|
92
|
+
read this at click time. Replacing "<" with "<" inside JSON
|
|
93
|
+
keeps a stray "</script>" or "<!--" in the step body from
|
|
94
|
+
terminating the script tag early.
|
|
95
|
+
*/}
|
|
96
|
+
<script
|
|
97
|
+
id="tt-step-data"
|
|
98
|
+
type="application/json"
|
|
99
|
+
set:html={JSON.stringify({
|
|
100
|
+
tutorialSlug: tutorial.id,
|
|
101
|
+
tutorialTitle: tutorial.data.title,
|
|
102
|
+
stepSlug: currentStepSlug,
|
|
103
|
+
stepTitle: currentStep.data.title,
|
|
104
|
+
stepSource: currentStep.body ?? "",
|
|
105
|
+
}).replace(/</g, "\\u003c")}
|
|
106
|
+
></script>
|
|
88
107
|
<script>
|
|
89
108
|
// Record last-visited + popularity markers from a data marker
|
|
90
109
|
// (set in the markup above). Using a normal <script> + relative
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { AssistantIntent } from "./prompts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Document-scoped event that Family A touchpoints (HelpMe, quiz wrong
|
|
6
|
+
* answer, checkpoint nudge, selection ask, playground fix, step
|
|
7
|
+
* footer) dispatch to ask the in-app tutor to open with a pre-seeded
|
|
8
|
+
* intent. ChatButton is the single listener — it owns the context
|
|
9
|
+
* and config needed to render the intent into seedMessages.
|
|
10
|
+
*/
|
|
11
|
+
export const ASSIST_EVENT = "handzon:assist";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Dispatched by ChatButton once on mount, and reflected as
|
|
15
|
+
* `document.documentElement.dataset.handzonAi = "ready"` for islands
|
|
16
|
+
* that mount after the chat (read the flag) or before (await the
|
|
17
|
+
* event). Family A islands use `useAiEnabled()` to hide themselves
|
|
18
|
+
* when the tutor isn't on this page.
|
|
19
|
+
*/
|
|
20
|
+
export const ASSIST_READY_EVENT = "handzon:ai-ready";
|
|
21
|
+
|
|
22
|
+
export interface AssistEventDetail {
|
|
23
|
+
intent: AssistantIntent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fire a touchpoint intent. ChatButton receives, builds the prompt
|
|
28
|
+
* with the live AssistantContext it already holds, and opens the
|
|
29
|
+
* panel with the seed user turn.
|
|
30
|
+
*/
|
|
31
|
+
export function dispatchAssist(intent: AssistantIntent): void {
|
|
32
|
+
if (typeof document === "undefined") return;
|
|
33
|
+
const detail: AssistEventDetail = { intent };
|
|
34
|
+
document.dispatchEvent(new CustomEvent(ASSIST_EVENT, { detail }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reactive flag: is the in-app tutor mounted on this page? Family A
|
|
39
|
+
* islands return null when false so the affordance is hidden
|
|
40
|
+
* entirely when `aiConfig.enabled === false`.
|
|
41
|
+
*/
|
|
42
|
+
export function useAiEnabled(): boolean {
|
|
43
|
+
const [enabled, setEnabled] = useState(false);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (document.documentElement.dataset.handzonAi === "ready") {
|
|
46
|
+
setEnabled(true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const onReady = () => setEnabled(true);
|
|
50
|
+
document.addEventListener(ASSIST_READY_EVENT, onReady);
|
|
51
|
+
return () => document.removeEventListener(ASSIST_READY_EVENT, onReady);
|
|
52
|
+
}, []);
|
|
53
|
+
return enabled;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reactive flag: is a specific tool (config.tools.X) enabled by the
|
|
58
|
+
* host page's aiConfig? Returns false until ChatButton publishes the
|
|
59
|
+
* tools manifest on mount. Family A islands that gate on a tool
|
|
60
|
+
* (Playground's "Ask AI to fix") use this in addition to
|
|
61
|
+
* useAiEnabled.
|
|
62
|
+
*/
|
|
63
|
+
export function useAiTool(name: string): boolean {
|
|
64
|
+
const enabled = useAiEnabled();
|
|
65
|
+
const [tools, setTools] = useState<Record<string, boolean>>({});
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
function read() {
|
|
68
|
+
const raw = document.documentElement.dataset.handzonAiTools;
|
|
69
|
+
if (!raw) return;
|
|
70
|
+
try {
|
|
71
|
+
setTools(JSON.parse(raw));
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
read();
|
|
77
|
+
document.addEventListener(ASSIST_READY_EVENT, read);
|
|
78
|
+
return () => document.removeEventListener(ASSIST_READY_EVENT, read);
|
|
79
|
+
}, []);
|
|
80
|
+
return enabled && tools[name] === true;
|
|
81
|
+
}
|