Skip to content

Commit f748440

Browse files
Michael Borckclaude
andcommitted
Add learning strategies, audio input/output, and UX polish
Learning strategies: - Five teaching strategies: Explain, Quiz me, Socratic, Devil's advocate, Perspectives. Selectable on landing page and switchable mid-conversation. - "Make me think" toggle appends reflection prompts to every response. - Strategy prompts use forceful instructions for small local models. Audio: - Text-to-speech via speechSynthesis (system voices, male/female, offline). Speaker icon on each tutor response, auto-read option in settings. - Speech-to-text via Web Speech API (default, online) or Transformers.js Whisper (opt-in, fully local, 40MB model download). Mic button in chat. - Audio settings section with voice selection, auto-read toggle, and STT provider choice with plain-English trade-off explanation. UX improvements: - Landing page reworked: value proposition subtitle, topic suggestions below input, strategy picker, search toggle, notes with radio buttons for "add to web search" vs "use only my notes". - Warning when teaching without grounded sources. - About page rewritten with README content. - Reduced top margin on landing page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 908c773 commit f748440

11 files changed

Lines changed: 1689 additions & 201 deletions

File tree

app/about/page.tsx

Lines changed: 106 additions & 81 deletions
Large diffs are not rendered by default.

app/page.tsx

Lines changed: 110 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import {
1111
ReconnectInterval,
1212
} from "eventsource-parser";
1313
import { getSystemPrompt } from "@/utils/utils";
14+
import { getStrategyById, nudgePrompt } from "@/utils/strategies";
1415
import Chat from "@/components/Chat";
1516

16-
const AUTO_SUMMARISE_THRESHOLD = 20000; // chars — roughly 5K tokens
17+
const AUTO_SUMMARISE_THRESHOLD = 20000;
1718

1819
type Phase = "landing" | "preparing" | "chat";
1920

@@ -29,10 +30,29 @@ export default function Home() {
2930
const [ageGroup, setAgeGroup] = useState("Middle School");
3031
const [customText, setCustomText] = useState("");
3132

33+
// Strategy state
34+
const [strategyId, setStrategyId] = useState("explain");
35+
const [nudgeEnabled, setNudgeEnabled] = useState(false);
36+
37+
// Search toggle (landing page override)
38+
const [searchWeb, setSearchWeb] = useState(true);
39+
40+
// Audio settings
41+
const [audioSettings, setAudioSettings] = useState({
42+
voiceGender: "female" as "male" | "female",
43+
autoRead: false,
44+
sttProvider: "web" as "web" | "whisper",
45+
});
46+
47+
// Keep parsed sources for rebuilding prompt on strategy change
48+
const [parsedSources, setParsedSources] = useState<
49+
{ fullContent: string }[]
50+
>([]);
51+
3252
// Preparation phase steps
3353
const [prepSteps, setPrepSteps] = useState<PrepStep[]>([]);
3454

35-
// Load default education level from settings on mount
55+
// Load defaults from settings on mount
3656
useEffect(() => {
3757
try {
3858
const savedSettings = localStorage.getItem("studybuddy-settings");
@@ -41,9 +61,17 @@ export default function Home() {
4161
if (settings.defaultEducationLevel) {
4262
setAgeGroup(settings.defaultEducationLevel);
4363
}
64+
// If search is disabled in settings, default the toggle off
65+
if (settings.searchEngine === "disabled") {
66+
setSearchWeb(false);
67+
}
68+
// Load audio settings
69+
if (settings.voiceGender) setAudioSettings((prev) => ({ ...prev, voiceGender: settings.voiceGender }));
70+
if (settings.autoRead !== undefined) setAudioSettings((prev) => ({ ...prev, autoRead: settings.autoRead }));
71+
if (settings.sttProvider) setAudioSettings((prev) => ({ ...prev, sttProvider: settings.sttProvider }));
4472
}
4573
} catch (error) {
46-
console.warn("Failed to load default education level:", error);
74+
console.warn("Failed to load settings:", error);
4775
}
4876
}, []);
4977

@@ -93,30 +121,47 @@ export default function Home() {
93121
setter((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)));
94122
};
95123

124+
// Build system prompt with current strategy/nudge settings
125+
const buildSystemPrompt = useCallback(
126+
(parsed: { fullContent: string }[], sid: string, nudge: boolean) => {
127+
const strategy = getStrategyById(sid);
128+
return getSystemPrompt(
129+
parsed,
130+
ageGroup,
131+
customText || undefined,
132+
strategy.prompt,
133+
nudge ? nudgePrompt : undefined,
134+
);
135+
},
136+
[ageGroup, customText],
137+
);
138+
96139
const handleInitialChat = async () => {
97140
const currentTopic = inputValue;
98141
setTopic(currentTopic);
99142
setInputValue("");
100143
setPhase("preparing");
101144
setLoading(true);
102145

103-
// Check if search is enabled
104-
let searchEnabled = true;
105-
try {
106-
const savedSettings = localStorage.getItem("studybuddy-settings");
107-
if (savedSettings) {
108-
const settings = JSON.parse(savedSettings);
109-
if (settings.searchEngine === "disabled") {
110-
searchEnabled = false;
146+
// Determine if we should search — landing page toggle AND settings
147+
let shouldSearch = searchWeb;
148+
if (shouldSearch) {
149+
try {
150+
const savedSettings = localStorage.getItem("studybuddy-settings");
151+
if (savedSettings) {
152+
const settings = JSON.parse(savedSettings);
153+
if (settings.searchEngine === "disabled") {
154+
shouldSearch = false;
155+
}
111156
}
112-
}
113-
} catch {}
157+
} catch {}
158+
}
114159

115160
// Build initial steps
116161
const steps: PrepStep[] = [
117162
{
118-
label: searchEnabled ? "Searching for sources..." : "Web search disabled",
119-
status: searchEnabled ? "active" : "skipped",
163+
label: shouldSearch ? "Searching for sources..." : "Web search off",
164+
status: shouldSearch ? "active" : "skipped",
120165
},
121166
{ label: "Reading web pages...", status: "waiting" },
122167
{ label: "Preparing your tutor...", status: "waiting" },
@@ -127,7 +172,7 @@ export default function Home() {
127172
let fetchedSources: { name: string; url: string }[] = [];
128173

129174
// Step 1: Search
130-
if (searchEnabled) {
175+
if (shouldSearch) {
131176
try {
132177
const sourcesResponse = await fetch("/api/getSources", {
133178
method: "POST",
@@ -162,7 +207,7 @@ export default function Home() {
162207
}
163208
updateStep(1, "done", setPrepSteps);
164209
} else {
165-
updateStep(1, fetchedSources.length === 0 && searchEnabled ? "skipped" : "skipped", setPrepSteps);
210+
updateStep(1, "skipped", setPrepSteps);
166211
}
167212

168213
// Auto-summarise if content is too large
@@ -171,7 +216,6 @@ export default function Home() {
171216
(customText?.length || 0);
172217

173218
if (totalContentSize > AUTO_SUMMARISE_THRESHOLD && parsed.length > 0) {
174-
// Insert a summarising step before "Preparing"
175219
setPrepSteps((prev) => [
176220
...prev.slice(0, 2),
177221
{ label: "Summarising sources to fit...", status: "active" },
@@ -196,50 +240,50 @@ export default function Home() {
196240
);
197241
}
198242

243+
// Store parsed sources for strategy switching
244+
setParsedSources(parsed);
245+
199246
// Step 3: Prepare tutor
200247
setPrepSteps((prev) =>
201248
prev.map((s, i) =>
202249
i === prev.length - 1 ? { ...s, status: "active" } : s,
203250
),
204251
);
205252

206-
// If no sources and no custom text, teach from knowledge
207253
const hasContent = parsed.length > 0 || customText;
254+
const strategy = getStrategyById(strategyId);
255+
const nudge = nudgeEnabled ? nudgePrompt : undefined;
256+
257+
const systemContent = hasContent
258+
? buildSystemPrompt(parsed, strategyId, nudgeEnabled)
259+
: `You are a professional interactive personal tutor. The student wants to learn about a topic at a ${ageGroup} level. You don't have specific source material for this topic, so teach from your own knowledge. Be upfront that you're teaching from general knowledge and may not have the latest information. Start by greeting the learner, giving a short overview, and asking what they want to learn about (in markdown numbers). Be interactive. Keep the first message short and concise. Please return answers in markdown.\n\n${strategy.prompt}${nudge ? "\n\n" + nudge : ""}`;
208260

209261
const initialMessage = [
210-
{
211-
role: "system",
212-
content: hasContent
213-
? getSystemPrompt(parsed, ageGroup, customText || undefined)
214-
: `You are a professional interactive personal tutor. The student wants to learn about a topic at a ${ageGroup} level. You don't have specific source material for this topic, so teach from your own knowledge. Be upfront that you're teaching from general knowledge and may not have the latest information. Start by greeting the learner, giving a short overview, and asking what they want to learn about (in markdown numbers). Be interactive and quiz them occasionally. Keep the first message short and concise. Please return answers in markdown.`,
215-
},
262+
{ role: "system", content: systemContent },
216263
{ role: "user", content: currentTopic },
217264
];
218265
setMessages(initialMessage);
219266

220-
// Transition to chat
221267
setPrepSteps((prev) =>
222268
prev.map((s, i) =>
223269
i === prev.length - 1 ? { ...s, status: "done" } : s,
224270
),
225271
);
226272

227-
// Brief pause so the user sees all steps complete
228273
await new Promise((r) => setTimeout(r, 400));
229274
setPhase("chat");
230275

231-
// Start streaming
232276
await handleChat(initialMessage);
233277
setLoading(false);
234278
};
235279

236-
const handleChat = async (messages?: { role: string; content: string }[]) => {
280+
const handleChat = async (msgs?: { role: string; content: string }[]) => {
237281
setLoading(true);
238282

239283
const chatRes = await fetch("/api/getChat", {
240284
method: "POST",
241285
headers: getHeaders(),
242-
body: JSON.stringify({ messages }),
286+
body: JSON.stringify({ messages: msgs }),
243287
});
244288

245289
if (!chatRes.ok) {
@@ -287,6 +331,31 @@ export default function Home() {
287331
setLoading(false);
288332
};
289333

334+
// Mid-conversation strategy change
335+
const handleStrategyChange = (newStrategyId: string) => {
336+
setStrategyId(newStrategyId);
337+
setMessages((prev) => {
338+
if (prev.length === 0) return prev;
339+
const newSystemContent =
340+
parsedSources.length > 0 || customText
341+
? buildSystemPrompt(parsedSources, newStrategyId, nudgeEnabled)
342+
: prev[0].content;
343+
return [{ ...prev[0], content: newSystemContent }, ...prev.slice(1)];
344+
});
345+
};
346+
347+
const handleNudgeChange = (enabled: boolean) => {
348+
setNudgeEnabled(enabled);
349+
setMessages((prev) => {
350+
if (prev.length === 0) return prev;
351+
const newSystemContent =
352+
parsedSources.length > 0 || customText
353+
? buildSystemPrompt(parsedSources, strategyId, enabled)
354+
: prev[0].content;
355+
return [{ ...prev[0], content: newSystemContent }, ...prev.slice(1)];
356+
});
357+
};
358+
290359
return (
291360
<>
292361
<Header />
@@ -302,6 +371,12 @@ export default function Home() {
302371
handleInitialChat={handleInitialChat}
303372
customText={customText}
304373
setCustomText={setCustomText}
374+
strategyId={strategyId}
375+
setStrategyId={setStrategyId}
376+
nudgeEnabled={nudgeEnabled}
377+
setNudgeEnabled={setNudgeEnabled}
378+
searchWeb={searchWeb}
379+
setSearchWeb={setSearchWeb}
305380
/>
306381
)}
307382

@@ -321,6 +396,11 @@ export default function Home() {
321396
topic={topic}
322397
sources={sources}
323398
hasCustomText={!!customText}
399+
strategyId={strategyId}
400+
onStrategyChange={handleStrategyChange}
401+
nudgeEnabled={nudgeEnabled}
402+
onNudgeChange={handleNudgeChange}
403+
audioSettings={audioSettings}
324404
/>
325405
</div>
326406
)}

app/settings/page.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ interface Settings {
1313
searchApiKey: string;
1414
searchUrl: string;
1515
defaultEducationLevel: string;
16+
voiceGender: string;
17+
autoRead: boolean;
18+
sttProvider: string;
1619
}
1720

1821
export default function SettingsPage() {
@@ -25,6 +28,9 @@ export default function SettingsPage() {
2528
searchApiKey: "",
2629
searchUrl: "",
2730
defaultEducationLevel: "Middle School",
31+
voiceGender: "female",
32+
autoRead: false,
33+
sttProvider: "web",
2834
});
2935

3036
const [saved, setSaved] = useState(false);
@@ -273,6 +279,9 @@ export default function SettingsPage() {
273279
searchApiKey: "",
274280
searchUrl: "",
275281
defaultEducationLevel: "Middle School",
282+
voiceGender: "female",
283+
autoRead: false,
284+
sttProvider: "web",
276285
});
277286
setModels([]);
278287
};
@@ -565,6 +574,99 @@ export default function SettingsPage() {
565574
</div>
566575
</div>
567576

577+
{/* Audio Section */}
578+
<div className="mb-10">
579+
<div className="flex items-center mb-6">
580+
<span className="inline-block h-px w-9 bg-accent mr-3"></span>
581+
<span className="text-xs font-medium uppercase tracking-widest text-ink-muted">
582+
Audio
583+
</span>
584+
</div>
585+
586+
<div className="mb-4">
587+
<label className={labelClasses}>
588+
Tutor voice
589+
</label>
590+
<select
591+
value={settings.voiceGender}
592+
onChange={(e) => setSettings({...settings, voiceGender: e.target.value})}
593+
className={inputClasses}
594+
>
595+
<option value="female">Female</option>
596+
<option value="male">Male</option>
597+
</select>
598+
<p className="mt-1 text-sm text-ink-quiet">
599+
Uses your computer&apos;s built-in voices. Quality varies by operating system.
600+
</p>
601+
</div>
602+
603+
<div className="mb-4">
604+
<label className="flex cursor-pointer items-center gap-2">
605+
<input
606+
type="checkbox"
607+
checked={settings.autoRead}
608+
onChange={(e) => setSettings({...settings, autoRead: e.target.checked})}
609+
className="h-3.5 w-3.5 cursor-pointer rounded-sm accent-accent"
610+
/>
611+
<span className="text-sm text-ink-muted">Read responses aloud automatically</span>
612+
</label>
613+
</div>
614+
615+
<div className="mb-4">
616+
<label className={labelClasses}>
617+
Voice input method
618+
</label>
619+
<select
620+
value={settings.sttProvider}
621+
onChange={(e) => setSettings({...settings, sttProvider: e.target.value})}
622+
className={inputClasses}
623+
>
624+
<option value="web">Online (Google, via browser)</option>
625+
<option value="whisper">Local (Whisper, private)</option>
626+
</select>
627+
<div className="mt-2 rounded-soft border border-hairline p-4 text-sm text-ink-muted" style={{ lineHeight: 1.7 }}>
628+
{settings.sttProvider === "web" ? (
629+
<>
630+
<strong className="text-ink">Online voice input</strong> uses your browser&apos;s
631+
built-in speech recognition (powered by Google). It&apos;s fast and accurate, but
632+
your spoken audio is sent to Google&apos;s servers for processing. No account is
633+
needed. This is the same technology used by voice typing in Google Docs.
634+
</>
635+
) : (
636+
<>
637+
<strong className="text-ink">Local voice input</strong> uses a Whisper speech
638+
recognition model that runs entirely on your computer. Your audio never leaves
639+
your machine. It requires a one-time download of about 40 MB. Transcription
640+
is slightly slower than the online option (a few seconds per phrase).
641+
<br /><br />
642+
The model will download automatically the first time you use the microphone
643+
button. You can also{" "}
644+
<button
645+
type="button"
646+
onClick={async () => {
647+
const { loadWhisperModel, isWhisperLoaded } = await import("@/utils/speech");
648+
if (isWhisperLoaded()) {
649+
alert("Whisper model is already downloaded and ready.");
650+
return;
651+
}
652+
alert("Downloading Whisper model (~40 MB). This may take a minute. Check the console for progress.");
653+
const ok = await loadWhisperModel((p) => {
654+
console.log("Whisper download:", p.status, p.progress ? Math.round(p.progress) + "%" : "", p.file || "");
655+
});
656+
if (ok) alert("Whisper model downloaded and ready to use.");
657+
else alert("Download failed. Check your internet connection and try again.");
658+
}}
659+
className="text-ink underline transition-colors duration-normal hover:text-accent"
660+
>
661+
download it now
662+
</button>
663+
.
664+
</>
665+
)}
666+
</div>
667+
</div>
668+
</div>
669+
568670
{/* Test Result Notification */}
569671
{testResult && (
570672
<div className={`mb-6 rounded-soft border p-4 ${

0 commit comments

Comments
 (0)