Remove page scanning / Ollama AI vision feature
Removes the scan-a-page workflow (photo → lesson/quiz content), all Ollama/cloud provider code, the scan settings panel, and the related DB seed values. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
61f9288aa5
commit
a10239ead9
4 files changed
+1
-371
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -202,18 +202,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Scan a page: visible for lesson + quiz types -->
|
||||
<div id="ie-scan-section" class="scan-card" hidden>
|
||||
<strong>📷 Scan a book page</strong>
|
||||
<input type="file" id="scan-file-input" accept="image/*" capture="environment" hidden>
|
||||
<div class="scan-controls">
|
||||
<button type="button" id="scan-pick-btn" class="secondary">Choose photo</button>
|
||||
<button type="button" id="scan-go-btn" disabled>🔍 Scan this page</button>
|
||||
</div>
|
||||
<img id="scan-preview" alt="Selected page photo" hidden>
|
||||
<p id="scan-status" class="hint"></p>
|
||||
</div>
|
||||
|
||||
<!-- Import from .docx file: visible for lesson, assignment, quiz types -->
|
||||
<div id="ie-import-section" class="scan-card" hidden>
|
||||
<strong>📂 Import from .docx</strong>
|
||||
@@ -515,42 +503,6 @@
|
||||
<button type="submit">Change PIN</button>
|
||||
</form>
|
||||
|
||||
<h2>Page scanning (AI vision)</h2>
|
||||
<p class="hint">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.</p>
|
||||
<form id="scan-config-form">
|
||||
<fieldset class="scan-provider-box">
|
||||
<legend>Primary provider</legend>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="scan_primary" value="local"> Local (Ollama) — runs on your machine, completely free & private
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="scan_primary" value="ollama_cloud"> Ollama Cloud — free hosted tier, requires API key
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="scan-provider-box">
|
||||
<legend>Local — Ollama</legend>
|
||||
<div class="inline-form">
|
||||
<input id="ollama-host" placeholder="http://localhost:11434" aria-label="Ollama host">
|
||||
<input id="ollama-model" placeholder="llava" aria-label="Vision model">
|
||||
<button type="button" id="ollama-test-btn" class="secondary">Test</button>
|
||||
</div>
|
||||
<p id="ollama-test-result" class="hint"></p>
|
||||
<p class="hint">Install a vision model: <code>ollama pull llava</code> or <code>ollama pull llava-llama3</code></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="scan-provider-box">
|
||||
<legend>Ollama Cloud (free tier)</legend>
|
||||
<div class="inline-form">
|
||||
<input id="ollama-cloud-model" placeholder="gemma4" aria-label="Ollama Cloud model">
|
||||
<input id="ollama-cloud-api-key" type="password" placeholder="Ollama Cloud API key" aria-label="Ollama Cloud API key">
|
||||
<span id="ollama-cloud-key-status" class="hint"></span>
|
||||
</div>
|
||||
<p class="hint">Runs through your local Ollama but offloads compute to Ollama's cloud. Get a free API key at <strong>ollama.com</strong> → Account → API Keys, then pull the model: <code>ollama pull gemma4:cloud</code>. Enter the base name only (e.g. <code>gemma4</code>) — <code>:cloud</code> is added automatically.</p>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Save scan settings</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+1
-129
@@ -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)}`;
|
||||
}
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
Reference in new issue
Block a user