diff --git a/db.js b/db.js index 5d2692c..daa9e25 100644 --- a/db.js +++ b/db.js @@ -244,11 +244,6 @@ const seedSetting = (key, val) => { if (!db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key)) db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?)`).run(key, val); }; -seedSetting('scan_primary', 'local'); -seedSetting('ollama_host', 'http://localhost:11434'); -seedSetting('ollama_model', 'llava'); -seedSetting('cloud_endpoint', 'https://api.openai.com/v1'); -seedSetting('cloud_model', 'gpt-4o'); // Seed bundled word lists on first run if (db.prepare(`SELECT COUNT(*) AS n FROM lists`).get().n === 0) { diff --git a/public/admin.html b/public/admin.html index d19830b..64cbd18 100644 --- a/public/admin.html +++ b/public/admin.html @@ -202,18 +202,6 @@ - -
Used by "Scan a page" in the lesson/quiz editor. Configure one or both providers β the primary is tried first and the other is used as automatic fallback.
- diff --git a/public/admin.js b/public/admin.js index 209cd1c..8f78f94 100644 --- a/public/admin.js +++ b/public/admin.js @@ -95,7 +95,7 @@ function showPanel(name) { const loaders = { kids: loadKids, courses: loadCourses, planner: loadPlannerPanel, grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, - settings: loadOllamaConfig, + settings: () => {}, }; if (loaders[name]) loaders[name](); if (name === 'course-detail') openCourseDetail(currentCourseId); @@ -580,7 +580,6 @@ async function openItemEditor(unitId, itemId) { $('#ie-delete').hidden = true; } updateItemFieldVisibility(); - resetScanSection(); resetImportSection(); showPanel('item-editor'); } @@ -595,7 +594,6 @@ function updateItemFieldVisibility() { $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; $('#ie-field-flashcards').hidden = type !== 'flashcards'; $('#ie-field-worksheet').hidden = type !== 'worksheet'; - $('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; $('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards' || type === 'worksheet'; } @@ -659,10 +657,6 @@ document.querySelectorAll('.rte-youtube-btn').forEach((btn) => { }); }); -// ---------- scan a page (photo -> lesson/quiz content via local Ollama) ---------- - -let scanImageBase64 = null; - function resetImportSection() { $('#import-local-input').value = ''; $('#import-local-name').textContent = ''; @@ -670,87 +664,6 @@ function resetImportSection() { $('#import-status').textContent = ''; } -function resetScanSection() { - scanImageBase64 = null; - $('#scan-file-input').value = ''; - $('#scan-preview').hidden = true; - $('#scan-preview').src = ''; - $('#scan-go-btn').disabled = true; - $('#scan-status').textContent = ''; -} - -function fileToScanImage(file, maxDim = 1600, quality = 0.85) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(new Error('Could not read that file')); - reader.onload = () => { - const img = new Image(); - img.onerror = () => reject(new Error('Could not read that image')); - img.onload = () => { - let { width, height } = img; - if (width > maxDim || height > maxDim) { - const scale = maxDim / Math.max(width, height); - width = Math.round(width * scale); - height = Math.round(height * scale); - } - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - canvas.getContext('2d').drawImage(img, 0, 0, width, height); - resolve(canvas.toDataURL('image/jpeg', quality)); - }; - img.src = reader.result; - }; - reader.readAsDataURL(file); - }); -} - -$('#scan-pick-btn').addEventListener('click', () => $('#scan-file-input').click()); - -$('#scan-file-input').addEventListener('change', async () => { - const file = $('#scan-file-input').files[0]; - if (!file) return; - try { - const dataUrl = await fileToScanImage(file); - scanImageBase64 = dataUrl.split(',')[1]; - $('#scan-preview').src = dataUrl; - $('#scan-preview').hidden = false; - $('#scan-go-btn').disabled = false; - $('#scan-status').textContent = ''; - } catch (err) { - $('#scan-status').textContent = `β ${err.message}`; - } -}); - -$('#scan-go-btn').addEventListener('click', async () => { - if (!scanImageBase64) return; - const mode = $('#ie-type').value === 'quiz' ? 'quiz' : 'lesson'; - $('#scan-go-btn').disabled = true; - $('#scan-status').textContent = 'π Reading the pageβ¦ this can take a minute or two on local hardware.'; - try { - const result = await api('/api/admin/scan', { method: 'POST', body: { image: scanImageBase64, mode } }); - if (result.title) $('#ie-title').value = result.title; - const providerLabel = result._provider === 'cloud' ? 'cloud API' : 'local Ollama'; - const fallbackNote = result._fallback ? ` (primary failed β used ${providerLabel} as fallback: ${result._primaryError})` : ` via ${providerLabel}`; - if (mode === 'lesson') { - $('#ie-body-lesson').innerHTML = result.body || ''; - $('#scan-status').textContent = `β Scanned${fallbackNote}. Review the text below before saving.`; - } else { - $('#ie-questions').innerHTML = ''; - questionCount = 0; - // addQuestionRow expects correct_answer (snake_case); scan returns correctAnswer (camelCase) β normalize here. - (result.questions || []).forEach((q) => addQuestionRow({ ...q, correct_answer: q.correctAnswer })); - const blanks = (result.questions || []).filter((q) => !q.correctAnswer).length; - $('#scan-status').textContent = `β Found ${result.questions?.length || 0} question(s)${fallbackNote}` + - (blanks ? ` β ${blanks} need${blanks === 1 ? 's' : ''} a correct answer` : '') + '. Review before saving.'; - } - } catch (err) { - $('#scan-status').textContent = `β ${err.message}`; - } finally { - $('#scan-go-btn').disabled = false; - } -}); - // ---------- import from local .docx file ---------- $('#import-browse-btn').addEventListener('click', () => $('#import-local-input').click()); @@ -2421,44 +2334,3 @@ $('#pin-change-form').addEventListener('submit', async (e) => { } }); -// ---------- Scan config (settings panel) ---------- - -async function loadOllamaConfig() { - const cfg = await api('/api/admin/scan-config'); - document.querySelector(`input[name="scan_primary"][value="${cfg.primary || 'local'}"]`).checked = true; - $('#ollama-host').value = cfg.ollama_host || ''; - $('#ollama-model').value = cfg.ollama_model || ''; - $('#ollama-test-result').textContent = ''; - $('#ollama-cloud-model').value = cfg.ollama_cloud_model || ''; - $('#ollama-cloud-api-key').value = ''; - $('#ollama-cloud-key-status').textContent = cfg.ollama_cloud_has_key ? 'π Key saved' : 'No key saved'; -} - -$('#scan-config-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const primary = document.querySelector('input[name="scan_primary"]:checked')?.value || 'local'; - const body = { - primary, - ollama_host: $('#ollama-host').value.trim(), - ollama_model: $('#ollama-model').value.trim(), - ollama_cloud_model: $('#ollama-cloud-model').value.trim(), - }; - const ollamaCloudKey = $('#ollama-cloud-api-key').value.trim(); - if (ollamaCloudKey) body.ollama_cloud_api_key = ollamaCloudKey; - await api('/api/admin/scan-config', { method: 'POST', body }); - if (ollamaCloudKey) { $('#ollama-cloud-api-key').value = ''; $('#ollama-cloud-key-status').textContent = 'π Key saved'; } - msg('Scan settings saved.'); -}); - -$('#ollama-test-btn').addEventListener('click', async () => { - $('#ollama-test-result').textContent = 'Testingβ¦'; - const r = await api('/api/admin/ollama-test', { method: 'POST' }); - if (r.reachable && r.hasModel) { - $('#ollama-test-result').textContent = `β Connected β "${esc($('#ollama-model').value)}" is available.`; - } else if (r.reachable) { - $('#ollama-test-result').textContent = - `β οΈ Connected but "${esc($('#ollama-model').value)}" isn't pulled. Available: ${esc(r.availableModels.join(', ') || '(none)')}. Run: ollama pull ${esc($('#ollama-model').value)}`; - } else { - $('#ollama-test-result').textContent = `β ${esc(r.error)}`; - } -}); diff --git a/server.js b/server.js index 174eeda..862d1a1 100644 --- a/server.js +++ b/server.js @@ -1975,197 +1975,8 @@ app.get('/api/week-report/:studentId', requirePin, (req, res) => { res.json({ student: student.name, start, tasks, graded, spellingTests }); }); -// ---------- page scanning: photo -> lesson/quiz content ---------- - const getSetting = (key) => db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key)?.value; -const SCAN_PROMPTS = { - lesson: `You are an OCR engine. Your only job is to copy the printed text from this image exactly as it appears β word for word, letter for letter. Do NOT summarize, paraphrase, rewrite, or shorten anything. Do NOT add explanations or commentary. - -Rules: -- Copy every word of the body text exactly as printed. -- Preserve paragraph breaks (use a blank line between paragraphs). -- If there is a heading or title printed on the page, put it in "title". Otherwise use a very short descriptive label. -- Skip page numbers, running headers/footers, and captions under illustrations β copy body text only. -- If a word is hard to read, give your best exact guess β never skip it or replace it with a summary. - -Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences: -{"title": "the heading printed on the page", "body": "the complete word-for-word text of the page, paragraphs separated by a blank line"}`, - - quiz: `You are an OCR engine helping digitize a quiz or test page. Copy every question and every answer choice exactly as printed β word for word. Do NOT summarize, rewrite, or skip any question. - -For each question extract: -- "type": "mc" if it has lettered/numbered answer choices, "tf" if it is true/false, "short" if it expects a written answer -- "prompt": the exact question text as printed -- "choices": for "mc", an array of the exact answer choice texts as printed (empty array for "tf" or "short") -- "correctAnswer": copy the marked answer ONLY if one is clearly indicated (circled, checked, starred, bolded, or in an answer key on the page). For "tf" use exactly "true" or "false". If no answer is marked, use "". -- "points": 1 unless the page prints a different value - -Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences: -{"title": "the quiz title as printed, or a short label if none", "questions": [{"type": "mc", "prompt": "...", "choices": ["...", "..."], "correctAnswer": "...", "points": 1}]}`, -}; - -function parseVisionJson(text) { - try { return JSON.parse(text); } catch { /* fall through */ } - const match = String(text).match(/\{[\s\S]*\}/); - if (!match) throw new Error('Model didn\'t return usable JSON. Try again or try a different model.'); - return JSON.parse(match[0]); -} - -// Local Ollama provider -async function callOllama(imageBase64, mode) { - const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, ''); - const model = getSetting('ollama_model') || 'llava'; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 180_000); - let res; - try { - res = await fetch(`${host}/api/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, prompt: SCAN_PROMPTS[mode], images: [imageBase64], stream: false, format: 'json' }), - signal: controller.signal, - }); - } catch (err) { - if (err.name === 'AbortError') throw new Error('Ollama timed out (3 min). Try a smaller photo or a faster model.'); - throw new Error(`Couldn't reach Ollama at ${host} β is it running? (${err.message})`); - } finally { clearTimeout(timer); } - if (!res.ok) { - const text = await res.text().catch(() => ''); - if (res.status === 404) throw new Error(`Model "${model}" not found on Ollama. Run: ollama pull ${model}`); - throw new Error(`Ollama error (${res.status}): ${text.slice(0, 200)}`); - } - const data = await res.json(); - return parseVisionJson(data.response); -} - -// Ollama Cloud provider β routes through local Ollama with :cloud model tag. -// Cloud models are pulled via local Ollama (e.g. `ollama pull gemma4:cloud`) and -// automatically offloaded to Ollama's cloud infrastructure. -async function callOllamaCloud(imageBase64, mode) { - const apiKey = getSetting('ollama_cloud_api_key') || ''; - const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, ''); - // Ensure model has :cloud suffix so Ollama offloads it - const baseModel = (getSetting('ollama_cloud_model') || 'gemma4').replace(/:cloud$/, ''); - const model = `${baseModel}:cloud`; - if (!apiKey) throw new Error('Ollama Cloud API key not set β add it in Settings.'); - - // Ollama reads OLLAMA_API_KEY from env; we inject it per-request via Authorization header - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 180_000); - let res; - try { - // Auth is handled by Ollama via OLLAMA_API_KEY env var β do not send it as a header - res = await fetch(`${host}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: SCAN_PROMPTS[mode], images: [imageBase64] }], - stream: false, - }), - signal: controller.signal, - }); - } catch (err) { - if (err.name === 'AbortError') throw new Error('Ollama Cloud timed out (3 min).'); - throw new Error(`Couldn't reach local Ollama at ${host} β is it running? (${err.message})`); - } finally { clearTimeout(timer); } - if (!res.ok) { - const text = await res.text().catch(() => ''); - if (res.status === 404) throw new Error(`Cloud model "${model}" not found. Run: ollama pull ${model}`); - throw new Error(`Ollama Cloud error (${res.status}): ${text.slice(0, 300)}`); - } - const data = await res.json(); - const content = data.message?.content; - if (!content) throw new Error('Ollama Cloud returned an empty response.'); - return parseVisionJson(content); -} - -// Try primary provider, fall back to the other -app.post('/api/admin/scan', requirePin, async (req, res) => { - const { image, mode } = req.body; - if (!image) return res.status(400).json({ error: 'No image provided' }); - if (!SCAN_PROMPTS[mode]) return res.status(400).json({ error: 'mode must be "lesson" or "quiz"' }); - - const primary = getSetting('scan_primary') || 'local'; - const providerFns = { local: callOllama, ollama_cloud: callOllamaCloud }; - const order = primary === 'ollama_cloud' - ? ['ollama_cloud', 'local'] - : ['local', 'ollama_cloud']; - const isConfigured = { - local: true, - ollama_cloud: !!getSetting('ollama_cloud_api_key'), - }; - - const errors = []; - for (const provider of order) { - if (!isConfigured[provider]) continue; - try { - const result = await providerFns[provider](image, mode); - return res.json({ ...result, _provider: provider, _fallback: provider !== primary, _primaryError: errors[0] || null }); - } catch (err) { - errors.push(`${provider}: ${err.message}`); - } - } - return res.status(502).json({ error: errors.join(' | ') || 'No scan providers configured.' }); -}); - -app.get('/api/admin/scan-config', requirePin, (req, res) => { - res.json({ - primary: getSetting('scan_primary') || 'local', - ollama_host: getSetting('ollama_host'), - ollama_model: getSetting('ollama_model'), - ollama_cloud_model: getSetting('ollama_cloud_model') || '', - ollama_cloud_has_key: !!(getSetting('ollama_cloud_api_key')), - }); -}); - -app.post('/api/admin/scan-config', requirePin, (req, res) => { - const { primary, ollama_host, ollama_model, ollama_cloud_model, ollama_cloud_api_key } = req.body; - const set = (k, v) => { - if (v === undefined) return; - const s = String(v).trim(); - if (s) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(k, s); - }; - set('scan_primary', primary); - set('ollama_host', ollama_host); - set('ollama_model', ollama_model); - set('ollama_cloud_model', ollama_cloud_model); - if (ollama_cloud_api_key !== undefined) - db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_cloud_api_key', String(ollama_cloud_api_key).trim()); - res.json({ ok: true }); -}); - -// Keep backward-compat routes for existing ollama-config/test calls -app.get('/api/admin/ollama-config', requirePin, (req, res) => { - res.json({ host: getSetting('ollama_host'), model: getSetting('ollama_model') }); -}); -app.post('/api/admin/ollama-config', requirePin, (req, res) => { - const { host, model } = req.body; - if (String(host || '').trim()) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_host', String(host).trim()); - if (String(model || '').trim()) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_model', String(model).trim()); - res.json({ ok: true }); -}); - -app.post('/api/admin/ollama-test', requirePin, async (req, res) => { - const host = (getSetting('ollama_host') || '').replace(/\/+$/, ''); - const model = getSetting('ollama_model'); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5000); - try { - const r = await fetch(`${host}/api/tags`, { signal: controller.signal }); - if (!r.ok) throw new Error(`Ollama responded with HTTP ${r.status}`); - const { models } = await r.json(); - const names = (models || []).map((m) => m.name); - const hasModel = names.some((n) => n === model || n.startsWith(`${model}:`)); - res.json({ reachable: true, hasModel, availableModels: names }); - } catch (err) { - res.json({ reachable: false, error: err.name === 'AbortError' ? 'Timed out reaching Ollama' : err.message }); - } finally { - clearTimeout(timer); - } -}); - // ---------- local TTS (Piper) ---------- app.get('/api/tts/status', (_req, res) => res.json({ available: PIPER_OK }));