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
|
@@ -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
|
}
|
|
@@ -65,6 +65,21 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
|
|
|
65
65
|
out.push({ kind: "checkpoint", scope: "global", key: id, value });
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
// Emit deletions so the server tombstones unchecked checkpoints; without
|
|
69
|
+
// this the next snapshot fetch would resurrect them from the DB.
|
|
70
|
+
for (const id of Object.keys(prev.checkpoints)) {
|
|
71
|
+
if (!next.checkpoints[id]) {
|
|
72
|
+
out.push({ kind: "checkpoint", scope: "global", key: id, value: null });
|
|
73
|
+
// Family D: drop the matching kind:"verification" telemetry
|
|
74
|
+
// row so a re-attempt isn't pre-poisoned by the previous
|
|
75
|
+
// failure feedback. Scope comes from the feedback entry
|
|
76
|
+
// populated by SSE.
|
|
77
|
+
const feedback = prev.verificationFeedback[id];
|
|
78
|
+
if (feedback) {
|
|
79
|
+
out.push({ kind: "verification", scope: feedback.scope, key: id, value: null });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
68
83
|
for (const [k, value] of Object.entries(next.prefs)) {
|
|
69
84
|
if ((prev.prefs as Record<string, unknown>)[k] !== value) {
|
|
70
85
|
out.push({ kind: "pref", scope: "global", key: k, value });
|
|
@@ -88,6 +103,59 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
|
|
|
88
103
|
return out;
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Apply one server-side progress entry to a mutable state object.
|
|
108
|
+
* Shared by the initial snapshot fetch and the SSE per-event path.
|
|
109
|
+
* Mutates in place — callers replace the store atom after batching.
|
|
110
|
+
*/
|
|
111
|
+
function applyEntryInto(state: ProgressState, e: ProgressEntry): void {
|
|
112
|
+
if (e.kind === "step") {
|
|
113
|
+
state.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
|
|
114
|
+
| "incomplete"
|
|
115
|
+
| "complete";
|
|
116
|
+
} else if (e.kind === "quiz") {
|
|
117
|
+
state.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
|
|
118
|
+
} else if (e.kind === "checkpoint") {
|
|
119
|
+
if (e.value == null) {
|
|
120
|
+
delete state.checkpoints[e.key];
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
state.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
|
|
124
|
+
} else if (e.kind === "pref") {
|
|
125
|
+
(state.prefs as Record<string, unknown>)[e.key] = e.value;
|
|
126
|
+
} else if (e.kind === "lastVisited") {
|
|
127
|
+
const v = e.value as unknown;
|
|
128
|
+
state.lastVisited[e.scope] =
|
|
129
|
+
typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
|
|
130
|
+
} else if (e.kind === "tutorial") {
|
|
131
|
+
const v = (e.value as { ts?: number }) ?? {};
|
|
132
|
+
const marker = state.tutorials[e.scope] ?? {};
|
|
133
|
+
if (e.key === "started") marker.started = v.ts;
|
|
134
|
+
else if (e.key === "completed") marker.completed = v.ts;
|
|
135
|
+
state.tutorials[e.scope] = marker;
|
|
136
|
+
} else if (e.kind === "verification") {
|
|
137
|
+
if (e.value == null) {
|
|
138
|
+
delete state.verificationFeedback[e.key];
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const v = e.value as {
|
|
142
|
+
pass: boolean;
|
|
143
|
+
failingCheckIndex?: number;
|
|
144
|
+
reason?: string;
|
|
145
|
+
hint?: string;
|
|
146
|
+
ts?: number;
|
|
147
|
+
};
|
|
148
|
+
state.verificationFeedback[e.key] = {
|
|
149
|
+
scope: e.scope as `${string}/${string}`,
|
|
150
|
+
pass: !!v.pass,
|
|
151
|
+
failingCheckIndex: v.failingCheckIndex,
|
|
152
|
+
reason: v.reason,
|
|
153
|
+
hint: v.hint,
|
|
154
|
+
ts: v.ts ?? Date.now(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
91
159
|
/**
|
|
92
160
|
* Same shape as the local store, but mirrors writes to /api/progress with
|
|
93
161
|
* debounced batching and an offline queue.
|
|
@@ -142,30 +210,7 @@ export function createRemoteStore(): ProgressStore {
|
|
|
142
210
|
entries: Array<{ kind: string; scope: string; key: string; value: unknown }>;
|
|
143
211
|
};
|
|
144
212
|
const merged: ProgressState = { ...emptyState(), ...state };
|
|
145
|
-
for (const e of entries)
|
|
146
|
-
if (e.kind === "step") {
|
|
147
|
-
merged.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
|
|
148
|
-
| "incomplete"
|
|
149
|
-
| "complete";
|
|
150
|
-
} else if (e.kind === "quiz") {
|
|
151
|
-
merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
|
|
152
|
-
} else if (e.kind === "checkpoint") {
|
|
153
|
-
merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
|
|
154
|
-
} else if (e.kind === "pref") {
|
|
155
|
-
(merged.prefs as Record<string, unknown>)[e.key] = e.value;
|
|
156
|
-
} else if (e.kind === "lastVisited") {
|
|
157
|
-
// Tolerate both new ({step, ts}) and legacy (string) shapes.
|
|
158
|
-
const v = e.value as unknown;
|
|
159
|
-
merged.lastVisited[e.scope] =
|
|
160
|
-
typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
|
|
161
|
-
} else if (e.kind === "tutorial") {
|
|
162
|
-
const v = (e.value as { ts?: number }) ?? {};
|
|
163
|
-
const marker = merged.tutorials[e.scope] ?? {};
|
|
164
|
-
if (e.key === "started") marker.started = v.ts;
|
|
165
|
-
else if (e.key === "completed") marker.completed = v.ts;
|
|
166
|
-
merged.tutorials[e.scope] = marker;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
213
|
+
for (const e of entries) applyEntryInto(merged, e);
|
|
169
214
|
state = merged;
|
|
170
215
|
writeStorage(state);
|
|
171
216
|
for (const fn of subscribers) fn(state);
|
|
@@ -173,6 +218,30 @@ export function createRemoteStore(): ProgressStore {
|
|
|
173
218
|
// ignore — local data still drives the UI
|
|
174
219
|
}
|
|
175
220
|
})();
|
|
221
|
+
|
|
222
|
+
// Live sync: subscribe to per-learner SSE so MCP-driven writes
|
|
223
|
+
// (or another tab via the cookie POST) show up immediately. The
|
|
224
|
+
// standard EventSource auto-reconnects on transient drops.
|
|
225
|
+
if (typeof EventSource !== "undefined") {
|
|
226
|
+
try {
|
|
227
|
+
const es = new EventSource("/api/progress/events", { withCredentials: true });
|
|
228
|
+
es.addEventListener("message", (ev) => {
|
|
229
|
+
try {
|
|
230
|
+
const entry = JSON.parse(ev.data) as ProgressEntry;
|
|
231
|
+
const next: ProgressState = { ...state };
|
|
232
|
+
applyEntryInto(next, entry);
|
|
233
|
+
state = next;
|
|
234
|
+
writeStorage(state);
|
|
235
|
+
for (const fn of subscribers) fn(state);
|
|
236
|
+
channel?.postMessage({ type: "set", state });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.warn("[handzon] sse parse failed:", e);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
} catch {
|
|
242
|
+
// ignore — polling-via-mount still keeps things eventually consistent
|
|
243
|
+
}
|
|
244
|
+
}
|
|
176
245
|
}
|
|
177
246
|
|
|
178
247
|
return {
|
|
@@ -16,10 +16,32 @@ export interface TutorialMarker {
|
|
|
16
16
|
completed?: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Family D verification feedback delivered via SSE from
|
|
21
|
+
* `submit_verification` failures. Keyed by the checkpoint id (which
|
|
22
|
+
* equals `verify.id`). Carries the step scope so the diff can emit
|
|
23
|
+
* a tombstone with the right scope when the learner unchecks.
|
|
24
|
+
*/
|
|
25
|
+
export interface VerificationFeedbackEntry {
|
|
26
|
+
scope: StepKey;
|
|
27
|
+
pass: boolean;
|
|
28
|
+
failingCheckIndex?: number;
|
|
29
|
+
reason?: string;
|
|
30
|
+
hint?: string;
|
|
31
|
+
ts: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export type ProgressState = {
|
|
20
35
|
steps: Record<StepKey, "incomplete" | "complete">;
|
|
21
36
|
quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
|
|
22
37
|
checkpoints: Record<string, { ts: number }>;
|
|
38
|
+
/**
|
|
39
|
+
* Latest verification verdict per checkpoint id. `pass: true`
|
|
40
|
+
* entries hang around as evidence; the Family D UI only renders
|
|
41
|
+
* the inline hint block on `pass: false`. Cleared when the
|
|
42
|
+
* learner unchecks the matching checkpoint.
|
|
43
|
+
*/
|
|
44
|
+
verificationFeedback: Record<string, VerificationFeedbackEntry>;
|
|
23
45
|
prefs: {
|
|
24
46
|
packageManager?: "npm" | "pnpm" | "yarn" | "bun";
|
|
25
47
|
os?: "macos" | "linux" | "windows";
|
|
@@ -50,6 +72,7 @@ export const emptyState = (): ProgressState => ({
|
|
|
50
72
|
steps: {},
|
|
51
73
|
quizzes: {},
|
|
52
74
|
checkpoints: {},
|
|
75
|
+
verificationFeedback: {},
|
|
53
76
|
prefs: {},
|
|
54
77
|
lastVisited: {},
|
|
55
78
|
tutorials: {},
|
|
@@ -8,6 +8,7 @@ interface ProgressApi {
|
|
|
8
8
|
markStepIncomplete: (tutorial: string, step: string) => void;
|
|
9
9
|
recordQuiz: (questionId: string, chosen: number[], correct: boolean) => void;
|
|
10
10
|
recordCheckpoint: (checkpointId: string) => void;
|
|
11
|
+
removeCheckpoint: (checkpointId: string) => void;
|
|
11
12
|
setPref: <K extends keyof ProgressState["prefs"]>(
|
|
12
13
|
key: K,
|
|
13
14
|
value: ProgressState["prefs"][K],
|
|
@@ -56,6 +57,17 @@ export function useProgress(): ProgressApi {
|
|
|
56
57
|
...s,
|
|
57
58
|
checkpoints: { ...s.checkpoints, [checkpointId]: { ts: Date.now() } },
|
|
58
59
|
})),
|
|
60
|
+
removeCheckpoint: (checkpointId: string) =>
|
|
61
|
+
store.set((s) => {
|
|
62
|
+
const hadCheckpoint = !!s.checkpoints[checkpointId];
|
|
63
|
+
const hadFeedback = !!s.verificationFeedback[checkpointId];
|
|
64
|
+
if (!hadCheckpoint && !hadFeedback) return s;
|
|
65
|
+
const nextCheckpoints = { ...s.checkpoints };
|
|
66
|
+
delete nextCheckpoints[checkpointId];
|
|
67
|
+
const nextFeedback = { ...s.verificationFeedback };
|
|
68
|
+
delete nextFeedback[checkpointId];
|
|
69
|
+
return { ...s, checkpoints: nextCheckpoints, verificationFeedback: nextFeedback };
|
|
70
|
+
}),
|
|
59
71
|
setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
|
|
60
72
|
store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
|
|
61
73
|
setLastVisited: (tutorial: string, step: string) =>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { render } from "astro:content";
|
|
3
3
|
import TutorialLayout from "../layouts/TutorialLayout.astro";
|
|
4
4
|
import ChatButton from "../components/ai/ChatButton.tsx";
|
|
5
|
+
import OpenInAgent from "../components/ai/OpenInAgent.tsx";
|
|
6
|
+
import SelectionAsk from "../components/ai/SelectionAsk.tsx";
|
|
7
|
+
import StepHelp from "../components/ai/StepHelp.tsx";
|
|
5
8
|
import { parseStepId } from "../lib/content.ts";
|
|
6
9
|
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
7
10
|
import { mdxComponents } from "../lib/mdx-components.ts";
|
|
@@ -61,7 +64,15 @@ const initialContext = buildContext({
|
|
|
61
64
|
repoUrl={repoUrl}
|
|
62
65
|
>
|
|
63
66
|
<Content components={components} />
|
|
67
|
+
{aiConfig.enabled && aiConfig.autoStepHelp && (
|
|
68
|
+
<StepHelp client:visible stepTitle={currentStep.data.title} />
|
|
69
|
+
)}
|
|
70
|
+
<OpenInAgent client:visible />
|
|
71
|
+
|
|
64
72
|
{aiConfig.enabled && (
|
|
65
|
-
|
|
73
|
+
<>
|
|
74
|
+
<ChatButton client:idle config={aiConfig} context={initialContext} />
|
|
75
|
+
<SelectionAsk client:idle />
|
|
76
|
+
</>
|
|
66
77
|
)}
|
|
67
78
|
</TutorialLayout>
|
package/src/pages/paths.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* export const getStaticPaths = getTutorialLandingPaths;
|
|
6
6
|
* export const getStaticPaths = getTutorialStepPaths;
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
10
|
+
import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
|
|
10
11
|
|
|
11
12
|
export async function getTutorialLandingPaths() {
|
|
12
13
|
const tutorials = await getTutorials();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* GitHub is the only provider in 0.2; email/password and others are
|
|
11
11
|
* out of scope (see plan: github-auth_d52529d5).
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import GitHub from "@auth/core/providers/github";
|
|
15
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
|
15
16
|
import { defineConfig } from "auth-astro";
|
|
16
17
|
import { accounts, sessions, users, verificationTokens } from "./schema.ts";
|
|
17
18
|
|
|
@@ -6,14 +6,7 @@
|
|
|
6
6
|
* The `learners` row that maps a signed-in user to local progress lives
|
|
7
7
|
* in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
integer,
|
|
11
|
-
pgTable,
|
|
12
|
-
primaryKey,
|
|
13
|
-
text,
|
|
14
|
-
timestamp,
|
|
15
|
-
uuid,
|
|
16
|
-
} from "drizzle-orm/pg-core";
|
|
9
|
+
import { integer, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
17
10
|
|
|
18
11
|
export const users = pgTable("users", {
|
|
19
12
|
id: uuid("id").primaryKey().defaultRandom(),
|
package/src/server/auth.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AstroCookieSetOptions, AstroCookies } from "astro";
|
|
|
2
2
|
import { and, eq, isNull } from "drizzle-orm";
|
|
3
3
|
import { getAuthedUser } from "./auth/session.ts";
|
|
4
4
|
import { getDb } from "./db/client.ts";
|
|
5
|
-
import { learners, progressEntries } from "./db/schema.ts";
|
|
5
|
+
import { learnerApiTokens, learners, progressEntries } from "./db/schema.ts";
|
|
6
6
|
|
|
7
7
|
const COOKIE = "tt-device";
|
|
8
8
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
|
@@ -61,11 +61,7 @@ export async function getOrCreateLearner(
|
|
|
61
61
|
// Anonymous path — unchanged from pre-auth behaviour.
|
|
62
62
|
let deviceId = cookies.get(COOKIE)?.value;
|
|
63
63
|
if (deviceId) {
|
|
64
|
-
const found = await db
|
|
65
|
-
.select()
|
|
66
|
-
.from(learners)
|
|
67
|
-
.where(eq(learners.deviceId, deviceId))
|
|
68
|
-
.limit(1);
|
|
64
|
+
const found = await db.select().from(learners).where(eq(learners.deviceId, deviceId)).limit(1);
|
|
69
65
|
if (found[0]) return { id: found[0].id, deviceId };
|
|
70
66
|
}
|
|
71
67
|
deviceId = randomDeviceId();
|
|
@@ -125,3 +121,86 @@ async function maybeClaimDeviceProgress(
|
|
|
125
121
|
await tx.delete(learners).where(eq(learners.id, orphan.id));
|
|
126
122
|
});
|
|
127
123
|
}
|
|
124
|
+
|
|
125
|
+
const PAT_PREFIX = "hzn_pat_";
|
|
126
|
+
const PAT_RANDOM_BYTES = 32;
|
|
127
|
+
|
|
128
|
+
/** Hash a raw PAT string to its database form. SHA-256 hex. */
|
|
129
|
+
export async function hashPat(raw: string): Promise<string> {
|
|
130
|
+
const data = new TextEncoder().encode(raw);
|
|
131
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
132
|
+
return Array.from(new Uint8Array(digest))
|
|
133
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
134
|
+
.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generate a fresh PAT to show the learner once. The settings page is
|
|
139
|
+
* the only caller — the API surface never sees the raw token after
|
|
140
|
+
* mint, and only the hash hits the database.
|
|
141
|
+
*/
|
|
142
|
+
export function generatePat(): string {
|
|
143
|
+
const bytes = new Uint8Array(PAT_RANDOM_BYTES);
|
|
144
|
+
crypto.getRandomValues(bytes);
|
|
145
|
+
// Base64url without padding — agent config files want short, copy/pasteable tokens.
|
|
146
|
+
const b64 = btoa(String.fromCharCode(...bytes))
|
|
147
|
+
.replace(/\+/g, "-")
|
|
148
|
+
.replace(/\//g, "_")
|
|
149
|
+
.replace(/=+$/, "");
|
|
150
|
+
return `${PAT_PREFIX}${b64}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve a bearer token presented on an incoming request to the
|
|
155
|
+
* learner that owns it. Returns null when no token, the token is
|
|
156
|
+
* unknown, or it has expired. `last_used_at` is touched
|
|
157
|
+
* asynchronously so a slow DB write doesn't add latency to MCP calls.
|
|
158
|
+
*
|
|
159
|
+
* Used by the MCP endpoint; the cookie-based progress endpoint stays
|
|
160
|
+
* on getOrCreateLearner so the same-origin guard remains effective.
|
|
161
|
+
*/
|
|
162
|
+
export async function resolveBearerLearner(
|
|
163
|
+
request: Request,
|
|
164
|
+
): Promise<{ learnerId: string; scopes: string[] } | null> {
|
|
165
|
+
const header = request.headers.get("authorization") ?? request.headers.get("Authorization");
|
|
166
|
+
if (!header) return null;
|
|
167
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
168
|
+
if (!match) return null;
|
|
169
|
+
const raw = match[1]!;
|
|
170
|
+
if (!raw.startsWith(PAT_PREFIX)) return null;
|
|
171
|
+
|
|
172
|
+
const db = getDb();
|
|
173
|
+
const hash = await hashPat(raw);
|
|
174
|
+
const rows = await db
|
|
175
|
+
.select()
|
|
176
|
+
.from(learnerApiTokens)
|
|
177
|
+
.where(eq(learnerApiTokens.tokenHash, hash))
|
|
178
|
+
.limit(1);
|
|
179
|
+
const token = rows[0];
|
|
180
|
+
if (!token) return null;
|
|
181
|
+
if (token.expiresAt && token.expiresAt.getTime() < Date.now()) return null;
|
|
182
|
+
|
|
183
|
+
const learnerRow = await db
|
|
184
|
+
.select({ id: learners.id })
|
|
185
|
+
.from(learners)
|
|
186
|
+
.where(eq(learners.userId, token.userId))
|
|
187
|
+
.limit(1);
|
|
188
|
+
const learner = learnerRow[0];
|
|
189
|
+
if (!learner) return null;
|
|
190
|
+
|
|
191
|
+
// Fire-and-forget last-used touch. Failures are logged-but-ignored;
|
|
192
|
+
// an audit log is more useful than blocking the call.
|
|
193
|
+
void db
|
|
194
|
+
.update(learnerApiTokens)
|
|
195
|
+
.set({ lastUsedAt: new Date() })
|
|
196
|
+
.where(eq(learnerApiTokens.id, token.id))
|
|
197
|
+
.catch((e) => {
|
|
198
|
+
console.warn("[handzon] failed to update PAT last_used_at:", e);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const scopes = token.scopes
|
|
202
|
+
.split(",")
|
|
203
|
+
.map((s) => s.trim())
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
return { learnerId: learner.id, scopes };
|
|
206
|
+
}
|
package/src/server/db/schema.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { users } from "../auth/schema.ts";
|
|
|
13
13
|
|
|
14
14
|
// Re-export the Auth.js tables so consumers (and drizzle-kit) see one
|
|
15
15
|
// schema barrel.
|
|
16
|
-
export {
|
|
16
|
+
export { accounts, sessions, users, verificationTokens } from "../auth/schema.ts";
|
|
17
17
|
|
|
18
18
|
export const learners = pgTable(
|
|
19
19
|
"learners",
|
|
@@ -40,6 +40,59 @@ export const learners = pgTable(
|
|
|
40
40
|
}),
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Personal access tokens for the per-user MCP surface.
|
|
45
|
+
*
|
|
46
|
+
* Created by the settings/tokens page in the scaffold template. Stored
|
|
47
|
+
* as SHA-256 hashes of `hzn_pat_<32 base64url bytes>` strings — the
|
|
48
|
+
* raw token is shown to the learner once at mint time and never
|
|
49
|
+
* persisted. The MCP v2 OAuth proxy reuses this table, so the row
|
|
50
|
+
* shape is forward-compatible with DCR-minted tokens.
|
|
51
|
+
*
|
|
52
|
+
* scopes: comma-separated; v1 known values are "progress:read" and
|
|
53
|
+
* "progress:write". Catalog reads don't require any scope beyond a
|
|
54
|
+
* valid token.
|
|
55
|
+
*/
|
|
56
|
+
export const learnerApiTokens = pgTable("learner_api_tokens", {
|
|
57
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
58
|
+
userId: uuid("user_id")
|
|
59
|
+
.notNull()
|
|
60
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
61
|
+
name: text("name").notNull(),
|
|
62
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
63
|
+
scopes: text("scopes").notNull(),
|
|
64
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
65
|
+
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
66
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Help-bridge inbox: pending help requests posted by the agent on
|
|
71
|
+
* the learner's machine (`request_help` MCP tool). ChatPanel reads
|
|
72
|
+
* pending rows on open, prepends them as a user turn, and marks
|
|
73
|
+
* them consumed so the next open doesn't re-replay them.
|
|
74
|
+
*/
|
|
75
|
+
export const helpRequests = pgTable(
|
|
76
|
+
"help_requests",
|
|
77
|
+
{
|
|
78
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
79
|
+
learnerId: uuid("learner_id")
|
|
80
|
+
.notNull()
|
|
81
|
+
.references(() => learners.id, { onDelete: "cascade" }),
|
|
82
|
+
tutorialSlug: text("tutorial_slug").notNull(),
|
|
83
|
+
stepSlug: text("step_slug").notNull(),
|
|
84
|
+
query: text("query").notNull(),
|
|
85
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
86
|
+
consumedAt: timestamp("consumed_at", { withTimezone: true }),
|
|
87
|
+
},
|
|
88
|
+
(table) => ({
|
|
89
|
+
byLearnerPending: index("help_requests_by_learner_pending").on(
|
|
90
|
+
table.learnerId,
|
|
91
|
+
table.consumedAt,
|
|
92
|
+
),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
43
96
|
export const progressEntries = pgTable(
|
|
44
97
|
"progress_entries",
|
|
45
98
|
{
|