Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ npm run build # tsc -b + vite build (tsc only type-checks src/, NOT te
npm run lint # ESLint over everything (7 known react-hooks/exhaustive-deps warnings, all pre-existing upstream)
npm test # vitest (happy-dom). CI runs `test:coverage`
npm run validate:imscc -- file.imscc # offline structural check of a Canvas cartridge
GEMINI_API_KEY=... npm run smoke:gemini # live check of the Gemini backend (key never printed)

# Canvas-importable course from a topic + a Canvas template, headless
npx tsx scripts/canvas-course.ts --topic "..." --template ./template.imscc --chapters 8 --output ./out
Expand Down Expand Up @@ -56,7 +57,7 @@ src/
├── types/ # course.ts (all data interfaces), template.ts, outline.ts, generation.ts
├── store/ # Zustand
│ ├── courseStore.ts # course state (persisted to IndexedDB via idbStorage)
│ ├── apiStore.ts # API keys, provider, research backend, advancedMode (persisted)
│ ├── apiStore.ts # API keys, provider, research backend, advancedMode (persisted); selectActiveLlm()
│ ├── templateStore.ts # uploaded Canvas templates (parsed + raw .imscc blob)
│ └── uiStore.ts # transient UI/generation state, in-flight map
├── pages/ # Landing, Setup, Syllabus, Research, Build, Export, TemplatePreview
Expand All @@ -66,7 +67,7 @@ src/
│ ├── setup/, syllabus/, export/, layout/, shared/
├── prompts/ # prompt builders (syllabus, chapter, quizzes, slides, templateChapter…)
├── services/
│ ├── llm/ # provider router: anthropic.ts, ollama.ts, index.ts (the real streaming layer)
│ ├── llm/ # provider router: anthropic.ts, gemini.ts, ollama.ts, index.ts (the real streaming layer)
│ ├── claude/ # SDK client, model ids, thinking budgets; streaming.ts is a thin facade over llm/
│ ├── research/ # backends: anthropic (web search), tavily, wikipedia; runResearch() dispatch
│ ├── academic/ # Crossref / Semantic Scholar / Unpaywall source enrichment
Expand All @@ -85,7 +86,9 @@ tests/ # vitest; fixtures/ holds real Canvas-made .imsc

- `tsconfig.app.json` has `erasableSyntaxOnly: true` — no parameter properties; use class field declarations. `noUnusedLocals` / `noUnusedParameters` are on — always run `npm run build`.
- **`tsc -b` only checks `src/`.** Test files are transpiled by vitest but not type-checked; keep them type-correct anyway.
- **Providers (Claude / Ollama Cloud) go through `src/services/llm/`.** `apiStore.provider` picks the backend. Call sites pass Anthropic model ids (`MODELS.opus` …); `resolveProvider` **ignores any `claude-*` model id on the Ollama path** and uses `apiStore.ollamaModel`. Never forward a Claude model id to Ollama.
- **Providers (Claude / Gemini / Ollama Cloud) go through `src/services/llm/`.** `apiStore.provider` picks the backend. Call sites pass Anthropic model ids (`MODELS.opus` …) and `claudeApiKey`; `resolveProvider` **ignores any `claude-*` model id on the Gemini/Ollama paths** and swaps in that provider's own key and model. Never forward a Claude model id to another provider.
- **Never gate on `claudeApiKey`.** "Can we generate?" is `selectActiveLlm(useApiStore()).hasKey` (also `.label` / `.keyValid`). Gating on the Claude key silently blocked Ollama/Gemini users (the syllabus never auto-started). Adding a provider = a backend in `services/llm/`, fields in `apiStore`, a pill + key in the Setup keys dialog, a case in `selectActiveLlm` and `resolveProvider`, CLI flags, and tests.
- **Gemini** (`services/llm/gemini.ts`) is called directly from the browser — Google answers CORS preflights for the `x-goog-api-key` header, so no proxy (unlike Ollama). Key goes in a header, never the URL. It ignores Anthropic server tools, so "Claude web search" research still needs a Claude key; Tavily/Wikipedia work. Thought summaries are requested only with a thinking budget and retried once without on a 400 that mentions thinking. Model ids change often — the model is a free-text setting. Unit tests mock `fetch`; `npm run smoke:gemini` (key from `GEMINI_API_KEY`) checks the live API.
- **Opus 4.8** uses adaptive thinking (`thinking: {type:'adaptive'}` + `output_config.effort`); manual `budget_tokens` is rejected on Opus ≥4.7. Sonnet/Haiku still use manual budgets. Pass `signal` for cancellation and don't retry an aborted call.
- **`advancedMode`** (apiStore) is "Canvas-focused" (Reading / Practice / Quizzes / Discussion) vs "Everything" (+ Challenge / Activities / Audio / Slides). It gates the visible tabs **and** batch / "Generate all" generation **and** the sidebar's N-of-M count — keep them in step.
- **Don't put an inline `onClose` in an effect dependency list.** `CodexModal` once re-focused the dialog on every keystroke because its effect depended on the caller's inline arrow; it now holds `onClose` in a ref. Same trap anywhere an effect side-effect (focus, scroll) follows a changing callback.
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A fork of [ClassBuild](https://github.com/jtangen/classbuild) by Jason Tangen, r
| Batch generation: "Generate All Canvas Modules" for the whole course at once | Build header |
| Export an `.imscc` that mirrors the template's structure: verbatim modules carry through untouched, pattern modules replaced by generated content, LTI links + web_resources passed through | "Export for Canvas (.imscc)" button |
| Upload a course-outline `.docx` and have an LLM extract title / description / course information / course materials → populates Canvas's Syllabus tab body at export time | Setup page |
| Pluggable LLM provider — **Anthropic (Claude)** or **Ollama Cloud** for course-content generation | Setup → Course-content provider |
| Pluggable LLM provider — **Anthropic (Claude)**, **Google Gemini** or **Ollama Cloud** for course-content generation | Setup → Course-content provider |
| Pluggable research backend — **Claude web search**, **Tavily**, or **Wikipedia** | Setup → Research backend |
| Advanced-mode toggle — hides multimedia outputs (slides, audio, infographic, weekly challenge, activities) by default to keep the Canvas-focused happy path tight | Setup page bottom |

Expand All @@ -48,6 +48,7 @@ Open [localhost:5173](http://localhost:5173).
| Provider | Required when… | What it does |
|---|---|---|
| Anthropic Claude | LLM provider = Anthropic, **OR** research backend = Claude web search | Course-content generation; Claude's built-in web search for the Research stage |
| Google Gemini | LLM provider = Gemini | Course-content generation, called straight from the browser (Google allows CORS — no proxy). Get a key at aistudio.google.com/apikey. The model id is editable (default `gemini-3.8-flash`). Doesn't run Claude's web-search tool, so pair it with Tavily or Wikipedia research. On Google's free tier prompts may be used to improve its products — check your plan |
| Ollama Cloud | LLM provider = Ollama Cloud | Course-content generation. Free tier doesn't include cloud models — see ollama.com/settings/keys |
| Tavily | Research backend = Tavily | Web search for the Research stage. Free tier covers ~1,000 searches/month |
| OpenAI | Advanced mode + you want slide images / chapter figures | Image generation (gpt-image-2, optional) |
Expand Down Expand Up @@ -154,12 +155,21 @@ Flags:
| `--length` | `standard` | `concise` / `standard` / `comprehensive` |
| `--cohort` | `60` | Class size |
| `--notes` | — | Additional learner context |
| `--provider` | `anthropic` | LLM provider — `anthropic` or `ollama`. With `ollama`, set `OLLAMA_API_KEY`; with `anthropic`, set `ANTHROPIC_API_KEY` |
| `--provider` | `anthropic` | LLM provider — `anthropic`, `gemini` or `ollama`. Set `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` or `OLLAMA_API_KEY` to match |
| `--ollama-model` | `gpt-oss:120b-cloud` | Ollama model used when `--provider ollama` |
| `--gemini-model` | `gemini-3.8-flash` | Gemini model used when `--provider gemini` |
| `--concurrency` | `3` | Parallel chapters during Canvas Module generation |
| `--syllabus` | — | Path to existing `syllabus.json` (skip regeneration) |
| `--skip-canvas-module` | `false` | Skip per-chapter content generation (for fast manifest-only test runs) |

Gemini example (`GEMINI_API_KEY` must be set in your environment):

```bash
npx tsx scripts/canvas-course.ts \n --provider gemini \n --topic "Statistics for Data Science" \n --template ./template.imscc \n --chapters 8 \n --output ./output/stats
```

Before relying on a Gemini model, run `npm run smoke:gemini` (reads `GEMINI_API_KEY` from your environment): it exercises plain text, streaming, thinking, structured-JSON quiz output and the error paths against the live API.

Ollama example:

```bash
Expand All @@ -172,7 +182,7 @@ OLLAMA_API_KEY=... npx tsx scripts/canvas-course.ts \
--output ./output/stats
```

If both `OLLAMA_API_KEY` and `ANTHROPIC_API_KEY` are set, outline-DOCX extraction always uses Claude (its instruction-following on the small JSON-extraction task is more reliable) regardless of `--provider`.
If `ANTHROPIC_API_KEY` is set alongside `--provider gemini` or `ollama`, outline-DOCX extraction always uses Claude (its instruction-following on the small JSON-extraction task is more reliable) regardless of `--provider`.

### Original ClassBuild CLI ([`scripts/generate-course.ts`](scripts/generate-course.ts))

Expand All @@ -195,6 +205,7 @@ src/
llm/
anthropic.ts # Anthropic SDK streaming
ollama.ts # Ollama Cloud NDJSON streaming
gemini.ts # Google Gemini SSE streaming (direct from the browser)
store/
templateStore.ts # uploaded templates (parsed + raw .imscc Blob)
pages/
Expand All @@ -209,7 +220,7 @@ src/

## Built with

React 19 · Vite 7 · TypeScript 5.9 · Tailwind CSS 4 · Zustand · JSZip · mammoth.js · Claude (Opus 4.8 / Sonnet 4.6 / Haiku 4.5) · Ollama Cloud · Tavily · OpenAI gpt-image-2 · ElevenLabs
React 19 · Vite 7 · TypeScript 5.9 · Tailwind CSS 4 · Zustand · JSZip · mammoth.js · Claude (Opus 4.8 / Sonnet 4.6 / Haiku 4.5) · Google Gemini · Ollama Cloud · Tavily · OpenAI gpt-image-2 · ElevenLabs

## Contributing

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"validate:imscc": "tsx tools/validate-imscc.ts"
"validate:imscc": "tsx tools/validate-imscc.ts",
"smoke:gemini": "tsx tools/smoke-gemini.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.74.0",
Expand Down
49 changes: 31 additions & 18 deletions scripts/canvas-course.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ const { values } = parseArgs({
concurrency: { type: 'string', default: '3' },
syllabus: { type: 'string' }, // path to existing syllabus.json (skip regeneration)
'skip-canvas-module': { type: 'boolean', default: false },
provider: { type: 'string', default: 'anthropic' }, // anthropic | ollama
provider: { type: 'string', default: 'anthropic' }, // anthropic | ollama | gemini
'ollama-model': { type: 'string', default: 'gpt-oss:120b-cloud' },
'gemini-model': { type: 'string', default: 'gemini-3.8-flash' },
},
strict: true,
});
Expand All @@ -80,13 +81,15 @@ if (!values.template) {
process.exit(1);
}

type Provider = 'anthropic' | 'ollama';
type Provider = 'anthropic' | 'ollama' | 'gemini';
const PROVIDER: Provider =
values.provider === 'ollama' ? 'ollama' : 'anthropic';
values.provider === 'ollama' ? 'ollama' : values.provider === 'gemini' ? 'gemini' : 'anthropic';
const OLLAMA_MODEL = values['ollama-model']!;
const GEMINI_MODEL = values['gemini-model']!;

const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;

if (PROVIDER === 'anthropic' && !ANTHROPIC_API_KEY) {
console.error('Error: ANTHROPIC_API_KEY environment variable is required for --provider anthropic');
Expand All @@ -96,12 +99,28 @@ if (PROVIDER === 'ollama' && !OLLAMA_API_KEY) {
console.error('Error: OLLAMA_API_KEY environment variable is required for --provider ollama');
process.exit(1);
}
if (PROVIDER === 'gemini' && !GEMINI_API_KEY) {
console.error('Error: GEMINI_API_KEY environment variable is required for --provider gemini');
process.exit(1);
}

/** Per-call provider overrides for the LLM router (the CLI never touches the browser store). */
function providerOptions(p: Provider) {
return {
provider: p,
ollamaApiKey: p === 'ollama' ? OLLAMA_API_KEY : undefined,
ollamaModel: p === 'ollama' ? OLLAMA_MODEL : undefined,
geminiApiKey: p === 'gemini' ? GEMINI_API_KEY : undefined,
geminiModel: p === 'gemini' ? GEMINI_MODEL : undefined,
};
}

// The outline-DOCX extraction step always runs on Claude when available
// (it's a one-shot JSON extraction that benefits from instruction-following
// quality). If Anthropic isn't configured, fall back to Ollama.
const OUTLINE_KEY = ANTHROPIC_API_KEY ?? OLLAMA_API_KEY!;
const OUTLINE_PROVIDER: Provider = ANTHROPIC_API_KEY ? 'anthropic' : 'ollama';
// quality). If Anthropic isn't configured, fall back to the chosen provider
// (its key was validated above).
const OUTLINE_PROVIDER: Provider = ANTHROPIC_API_KEY ? 'anthropic' : PROVIDER;
const OUTLINE_KEY = ANTHROPIC_API_KEY ?? (PROVIDER === 'gemini' ? GEMINI_API_KEY! : OLLAMA_API_KEY!);

const setup: CourseSetup = {
topic: values.topic,
Expand Down Expand Up @@ -136,11 +155,11 @@ function printUsage() {
' [--level advanced-undergrad] \\\n' +
' [--length concise|standard|comprehensive] \\\n' +
' [--notes "Additional learner context"] \\\n' +
' [--provider anthropic|ollama] \\\n' +
' [--ollama-model gpt-oss:120b-cloud] \\\n' +
' [--provider anthropic|ollama|gemini] \\\n' +
' [--ollama-model gpt-oss:120b-cloud] [--gemini-model gemini-3.8-flash] \\\n' +
' [--concurrency 3] \\\n' +
' [--syllabus ./existing-syllabus.json]\n' +
'\nFor Ollama: set OLLAMA_API_KEY (and optionally ANTHROPIC_API_KEY for\noutline extraction quality if you have it).',
'\nFor Ollama: set OLLAMA_API_KEY; for Gemini: set GEMINI_API_KEY (optionally also ANTHROPIC_API_KEY for\noutline extraction quality if you have it).',
);
}

Expand Down Expand Up @@ -218,9 +237,7 @@ async function main() {
const result = await extractOutlineFields({
apiKey: OUTLINE_KEY,
rawText,
provider: OUTLINE_PROVIDER,
ollamaApiKey: OUTLINE_PROVIDER === 'ollama' ? OLLAMA_API_KEY ?? undefined : undefined,
ollamaModel: OUTLINE_PROVIDER === 'ollama' ? OLLAMA_MODEL : undefined,
...providerOptions(OUTLINE_PROVIDER),
});
outlineFields = result.fields;
const found = Object.keys(outlineFields).filter(
Expand Down Expand Up @@ -248,9 +265,7 @@ async function main() {
messages: [{ role: 'user', content: userMessage }],
thinkingBudget: 'high',
maxTokens: 16000,
provider: PROVIDER,
ollamaApiKey: PROVIDER === 'ollama' ? OLLAMA_API_KEY! : undefined,
ollamaModel: PROVIDER === 'ollama' ? OLLAMA_MODEL : undefined,
...providerOptions(PROVIDER),
},
{},
);
Expand Down Expand Up @@ -282,9 +297,7 @@ async function main() {
courseTitle: syllabus.courseTitle,
courseOverview: syllabus.courseOverview,
examplePatternContent: template.examplePatternContent,
provider: PROVIDER,
ollamaApiKey: PROVIDER === 'ollama' ? OLLAMA_API_KEY! : undefined,
ollamaModel: PROVIDER === 'ollama' ? OLLAMA_MODEL : undefined,
...providerOptions(PROVIDER),
});
const target = chapters.find((c) => c.number === ch.number);
if (target) target.templateContent = content;
Expand Down
12 changes: 5 additions & 7 deletions src/components/setup/CourseOutlineUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { useCourseStore } from '../../store/courseStore';
import { useApiStore } from '../../store/apiStore';
import { useApiStore, selectActiveLlm } from '../../store/apiStore';
import { docxToText, extractOutlineFields } from '../../services/template/parseOutlineDocx';
import { Button } from '../shared/Button';
import { friendlyError } from '../../utils/errors';
Expand All @@ -22,25 +22,23 @@ const FIELD_HINTS: Record<keyof OutlineFields, string> = {

export function CourseOutlineUpload() {
const { outlineFields, outlineRawText, setOutlineFields, setOutlineRawText } = useCourseStore();
const { provider, claudeApiKey, ollamaApiKey } = useApiStore();
const llm = selectActiveLlm(useApiStore());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

const llmKey = provider === 'ollama' ? ollamaApiKey : claudeApiKey;

const handleFile = async (file: File) => {
if (!llmKey.trim()) {
setError('Add an API key (Claude or Ollama) before uploading — the outline is parsed by the active LLM provider.');
if (!llm.hasKey) {
setError(`Add your ${llm.label} API key before uploading — the outline is parsed by the active LLM provider.`);
return;
}
setBusy(true);
setError(null);
try {
const rawText = await docxToText(file);
setOutlineRawText(rawText);
const result = await extractOutlineFields({ apiKey: llmKey, rawText });
const result = await extractOutlineFields({ apiKey: llm.apiKey, rawText });
setOutlineFields(result.fields);
setOpen(true);
} catch (err) {
Expand Down
11 changes: 7 additions & 4 deletions src/pages/ExportPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo, useRef } from 'react';
import { motion } from 'framer-motion';
import { useCourseStore } from '../store/courseStore';
import { useApiStore } from '../store/apiStore';
import { useApiStore, selectActiveLlm } from '../store/apiStore';
import { useUiStore } from '../store/uiStore';
import { streamMessage } from '../services/claude/streaming';
import { MODELS } from '../services/claude/client';
Expand Down Expand Up @@ -89,7 +89,9 @@
export function ExportPage() {
const { syllabus, chapters, researchDossiers, addChapter, updateChapter, setSlideImage, setup, updateSetup, curriculumMap, setCurriculumMap, outlineFields } = useCourseStore();
const currentThemeId = (setup.themeId as ChapterThemeId | undefined) ?? DEFAULT_CHAPTER_THEME_ID;
const { claudeApiKey, openaiApiKey } = useApiStore();
const apiState = useApiStore();
const { claudeApiKey, openaiApiKey } = apiState;
const llm = selectActiveLlm(apiState);
const { isGenerating, setIsGenerating, setError, error } = useUiStore();
const [generatingChapter, setGeneratingChapter] = useState<number | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
Expand Down Expand Up @@ -121,7 +123,7 @@
} catch {
downloadFile(chapter.practiceQuizData, `quiz-${chapterNum}-${sanitizeFilename(chapter.title)}.txt`, 'text/plain');
}
}, [chapters, syllabus]);

Check warning on line 126 in src/pages/ExportPage.tsx

View workflow job for this annotation

GitHub Actions / Build + Test

React Hook useCallback has a missing dependency: 'setup.themeId'. Either include it or remove the dependency array

const handleDownloadSlides = useCallback(async (chapterNum: number) => {
const chapter = chapters.find(c => c.number === chapterNum);
Expand Down Expand Up @@ -524,7 +526,7 @@
curriculumMap.syllabusHash !== currentSyllabusHash;

const generateLearningOutcomes = useCallback(async () => {
if (!syllabus || !claudeApiKey || isGeneratingOutcomes) return;
if (!syllabus || !llm.hasKey || isGeneratingOutcomes) return;
setIsGeneratingOutcomes(true);
setOutcomesError(null);
outcomesStreamRef.current = '';
Expand Down Expand Up @@ -564,6 +566,7 @@
}, [
syllabus,
claudeApiKey,
llm.hasKey,
isGeneratingOutcomes,
currentSyllabusHash,
setCurriculumMap,
Expand Down Expand Up @@ -666,7 +669,7 @@
setIsGenerating(false);
setGeneratingChapter(null);
}
}, [syllabus, chapters, claudeApiKey, researchDossiers, setup.chapterLength, addChapter, updateChapter, isGenerating, setIsGenerating, setError]);

Check warning on line 672 in src/pages/ExportPage.tsx

View workflow job for this annotation

GitHub Actions / Build + Test

React Hook useCallback has missing dependencies: 'setup.educationLevel', 'setup.learnerNotes', 'setup.priorKnowledge', and 'setup.themeId'. Either include them or remove the dependency array

// --- Per-class bundle ---
//
Expand Down Expand Up @@ -1011,7 +1014,7 @@

const blob = await zip.generateAsync({ type: 'blob' });
saveAs(blob, `${courseName}.zip`);
}, [syllabus, chapters, setup.themeId, buildCurriculumMapCsv, curriculumMap]);

Check warning on line 1017 in src/pages/ExportPage.tsx

View workflow job for this annotation

GitHub Actions / Build + Test

React Hook useCallback has missing dependencies: 'openaiApiKey' and 'setSlideImage'. Either include them or remove the dependency array

const handlePublish = useCallback(async () => {
if (!syllabus || chapters.length === 0) return;
Expand Down Expand Up @@ -1406,7 +1409,7 @@
onGenerate={() => void generateLearningOutcomes()}
onDownloadHtml={handleDownloadOutcomesHtml}
onDownloadCsv={handleDownloadOutcomesCsv}
canGenerate={!!claudeApiKey && !isGenerating}
canGenerate={llm.hasKey && !isGenerating}
/>

<div className="cb-folio">— 05 —</div>
Expand Down
Loading
Loading