handzon-core 0.7.0 → 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 +97 -3
- package/src/components/ai/ChatButton.tsx +51 -3
- package/src/components/ai/ChatPanel.tsx +86 -23
- 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/mdx/Checkpoint.tsx +66 -2
- 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/index.ts +5 -0
- 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 +86 -25
- package/src/lib/progress/types.ts +23 -0
- package/src/lib/progress/useProgress.ts +8 -4
- package/src/pages/TutorialStep.astro +12 -1
- package/src/server/auth.ts +84 -1
- package/src/server/db/schema.ts +53 -0
- package/src/server/handlers/helpInbox.ts +45 -0
- package/src/server/handlers/mcp.ts +72 -0
- package/src/server/handlers/progress.ts +7 -51
- package/src/server/handlers/progressEvents.ts +68 -0
- 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
|
@@ -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
|
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,11 @@ export {
|
|
|
17
17
|
streamChat,
|
|
18
18
|
} from "./lib/ai/client.ts";
|
|
19
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";
|
|
20
25
|
// Content collection helpers (built on top of astro:content).
|
|
21
26
|
export {
|
|
22
27
|
getStep,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ChatMessage } from "./client";
|
|
2
|
+
import type { AssistantContext } from "./context";
|
|
3
|
+
|
|
4
|
+
export type AssistantIntent =
|
|
5
|
+
| { kind: "unstuck"; topic?: string }
|
|
6
|
+
| {
|
|
7
|
+
kind: "quizFix";
|
|
8
|
+
question: string;
|
|
9
|
+
chosen: string[];
|
|
10
|
+
correct: string[];
|
|
11
|
+
}
|
|
12
|
+
| { kind: "checkpoint"; label: string }
|
|
13
|
+
| { kind: "selection"; text: string }
|
|
14
|
+
| { kind: "playgroundFix"; files: Record<string, string> }
|
|
15
|
+
| { kind: "explainStep" }
|
|
16
|
+
| { kind: "recap" };
|
|
17
|
+
|
|
18
|
+
export interface AssistantPrompt {
|
|
19
|
+
/** Ready-to-render Markdown a learner can paste anywhere. */
|
|
20
|
+
markdown: string;
|
|
21
|
+
/** Seed messages for ChatPanel — first user turn, no assistant turn. */
|
|
22
|
+
seedMessages: ChatMessage[];
|
|
23
|
+
/** URL-encoded prompt for each external agent. */
|
|
24
|
+
deepLinks: {
|
|
25
|
+
cursor: string;
|
|
26
|
+
claude: string;
|
|
27
|
+
chatgpt: string;
|
|
28
|
+
vscode: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function header(ctx: AssistantContext): string {
|
|
33
|
+
return [`Tutorial: ${ctx.tutorial.title}`, `Step: ${ctx.currentStep.title}`].join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderIntent(ctx: AssistantContext, intent: AssistantIntent): string {
|
|
37
|
+
switch (intent.kind) {
|
|
38
|
+
case "unstuck":
|
|
39
|
+
return intent.topic
|
|
40
|
+
? `I'm stuck on "${intent.topic}". Help me figure out what to do next without giving the whole answer.`
|
|
41
|
+
: `I'm stuck on this step. Help me figure out what to do next without giving the whole answer.`;
|
|
42
|
+
case "quizFix":
|
|
43
|
+
return [
|
|
44
|
+
`I got a quiz question wrong and want to understand why.`,
|
|
45
|
+
``,
|
|
46
|
+
`Question: ${intent.question}`,
|
|
47
|
+
`I chose: ${intent.chosen.join(", ") || "(nothing)"}`,
|
|
48
|
+
`Correct answer: ${intent.correct.join(", ")}`,
|
|
49
|
+
``,
|
|
50
|
+
`Explain why my choice is wrong without restating the correct answer verbatim — help me see what concept I'm missing.`,
|
|
51
|
+
].join("\n");
|
|
52
|
+
case "checkpoint":
|
|
53
|
+
return `I'm not sure how to complete this checkpoint: "${intent.label}". Walk me through what to try next.`;
|
|
54
|
+
case "selection":
|
|
55
|
+
return [
|
|
56
|
+
`I have a question about this part of the tutorial:`,
|
|
57
|
+
``,
|
|
58
|
+
"```",
|
|
59
|
+
intent.text,
|
|
60
|
+
"```",
|
|
61
|
+
``,
|
|
62
|
+
`Can you explain it in context of where I am?`,
|
|
63
|
+
].join("\n");
|
|
64
|
+
case "playgroundFix": {
|
|
65
|
+
const files = Object.entries(intent.files)
|
|
66
|
+
.map(([path, body]) => `### \`${path}\`\n\n\`\`\`\n${body}\n\`\`\``)
|
|
67
|
+
.join("\n\n");
|
|
68
|
+
return [
|
|
69
|
+
`My playground code isn't working as expected. Here are the current files:`,
|
|
70
|
+
``,
|
|
71
|
+
files,
|
|
72
|
+
``,
|
|
73
|
+
`What's wrong, and what should I change? Point me at the bug; don't just rewrite the file.`,
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
case "explainStep":
|
|
77
|
+
return [
|
|
78
|
+
`Walk me through what this step is asking me to do and why it matters.`,
|
|
79
|
+
``,
|
|
80
|
+
`Step content:`,
|
|
81
|
+
``,
|
|
82
|
+
ctx.currentStep.source,
|
|
83
|
+
].join("\n");
|
|
84
|
+
case "recap":
|
|
85
|
+
return `Give me a one-paragraph recap of what I've learned so far in this tutorial.`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildMarkdown(ctx: AssistantContext, intent: AssistantIntent): string {
|
|
90
|
+
return [header(ctx), "", renderIntent(ctx, intent)].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cursor's anysphere deep link expects a `text` query param.
|
|
95
|
+
* Claude.ai and ChatGPT use `?q=...`. VS Code can launch a chat
|
|
96
|
+
* extension via the `vscode://` scheme; we use the generic
|
|
97
|
+
* `vscode://GitHub.copilot-chat/chat?prompt=...` form, which is
|
|
98
|
+
* the closest thing to a stable contract today.
|
|
99
|
+
*/
|
|
100
|
+
function buildDeepLinks(prompt: string): AssistantPrompt["deepLinks"] {
|
|
101
|
+
const enc = encodeURIComponent(prompt);
|
|
102
|
+
return {
|
|
103
|
+
cursor: `cursor://anysphere.cursor-deeplink/prompt?text=${enc}`,
|
|
104
|
+
claude: `https://claude.ai/new?q=${enc}`,
|
|
105
|
+
chatgpt: `https://chat.openai.com/?q=${enc}`,
|
|
106
|
+
vscode: `vscode://GitHub.copilot-chat/chat?prompt=${enc}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render an assistant context + a small intent payload into the three
|
|
112
|
+
* surfaces every touchpoint needs: chat seed messages, copyable
|
|
113
|
+
* markdown, and per-agent deep links. Each Family A/B touchpoint is a
|
|
114
|
+
* one-liner around this function.
|
|
115
|
+
*/
|
|
116
|
+
export function buildAssistantPrompt(
|
|
117
|
+
context: AssistantContext,
|
|
118
|
+
intent: AssistantIntent,
|
|
119
|
+
): AssistantPrompt {
|
|
120
|
+
const markdown = buildMarkdown(context, intent);
|
|
121
|
+
return {
|
|
122
|
+
markdown,
|
|
123
|
+
seedMessages: [{ role: "user", content: markdown }],
|
|
124
|
+
deepLinks: buildDeepLinks(markdown),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AssistantContext } from "./context";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the per-step JSON payload that TutorialLayout emits into
|
|
5
|
+
* `<script id="tt-step-data" type="application/json">`. Family B
|
|
6
|
+
* touchpoints (CopyPrompt, deep-link row, copy-step button, …) need
|
|
7
|
+
* the raw MDX source and tutorial/step titles at click time without
|
|
8
|
+
* having every island accept them as props.
|
|
9
|
+
*/
|
|
10
|
+
export interface StepData {
|
|
11
|
+
tutorialSlug: string;
|
|
12
|
+
tutorialTitle: string;
|
|
13
|
+
stepSlug: string;
|
|
14
|
+
stepTitle: string;
|
|
15
|
+
stepSource: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readStepData(): StepData | null {
|
|
19
|
+
if (typeof document === "undefined") return null;
|
|
20
|
+
const node = document.getElementById("tt-step-data");
|
|
21
|
+
if (!node?.textContent) return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(node.textContent) as StepData;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render a CopyPrompt template by substituting `{{placeholder}}`
|
|
31
|
+
* tokens with the values from step data. Unknown placeholders are
|
|
32
|
+
* left untouched so authors notice typos.
|
|
33
|
+
*/
|
|
34
|
+
export function renderTemplate(template: string, data: StepData): string {
|
|
35
|
+
const map: Record<string, string> = {
|
|
36
|
+
tutorialTitle: data.tutorialTitle,
|
|
37
|
+
tutorialSlug: data.tutorialSlug,
|
|
38
|
+
stepTitle: data.stepTitle,
|
|
39
|
+
stepSlug: data.stepSlug,
|
|
40
|
+
stepSource: data.stepSource,
|
|
41
|
+
};
|
|
42
|
+
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (raw, key) => {
|
|
43
|
+
return key in map ? map[key] : raw;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Construct a minimal AssistantContext from client-side step data
|
|
49
|
+
* for Family B touchpoints that need to call buildAssistantPrompt
|
|
50
|
+
* without the build-time context that ChatButton holds. Fields we
|
|
51
|
+
* don't know client-side (difficulty, tags, outline, prior steps,
|
|
52
|
+
* progress) come back empty; the intents that Family B uses
|
|
53
|
+
* (explainStep, recap) only read tutorial + currentStep.
|
|
54
|
+
*/
|
|
55
|
+
export function contextFromStepData(data: StepData): AssistantContext {
|
|
56
|
+
return {
|
|
57
|
+
tutorial: {
|
|
58
|
+
slug: data.tutorialSlug,
|
|
59
|
+
title: data.tutorialTitle,
|
|
60
|
+
description: "",
|
|
61
|
+
difficulty: "",
|
|
62
|
+
tags: [],
|
|
63
|
+
},
|
|
64
|
+
outline: [],
|
|
65
|
+
currentStep: {
|
|
66
|
+
slug: data.stepSlug,
|
|
67
|
+
title: data.stepTitle,
|
|
68
|
+
source: data.stepSource,
|
|
69
|
+
},
|
|
70
|
+
priorSteps: [],
|
|
71
|
+
progress: { completed: [], quizzes: [], checkpoints: [] },
|
|
72
|
+
references: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import Callout from "../components/mdx/Callout.astro";
|
|
2
2
|
import Checkpoint from "../components/mdx/Checkpoint.astro";
|
|
3
|
+
import CopyPrompt from "../components/mdx/CopyPrompt.astro";
|
|
3
4
|
import Diff from "../components/mdx/Diff.astro";
|
|
4
5
|
import Download from "../components/mdx/Download.astro";
|
|
5
6
|
import Embed from "../components/mdx/Embed.astro";
|
|
6
7
|
import File from "../components/mdx/File.astro";
|
|
7
8
|
import FileTree from "../components/mdx/FileTree.astro";
|
|
9
|
+
import HelpMe from "../components/mdx/HelpMe.astro";
|
|
8
10
|
import Hint from "../components/mdx/Hint.astro";
|
|
9
11
|
import Mermaid from "../components/mdx/Mermaid.astro";
|
|
10
12
|
import Playground from "../components/mdx/Playground.astro";
|
|
@@ -43,5 +45,7 @@ export function mdxComponents() {
|
|
|
43
45
|
Quiz,
|
|
44
46
|
Checkpoint,
|
|
45
47
|
Playground,
|
|
48
|
+
HelpMe,
|
|
49
|
+
CopyPrompt,
|
|
46
50
|
};
|
|
47
51
|
}
|