diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 5cfadab..347452a 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -269,9 +269,10 @@ function bumpUsage(field) { } /* Низкоуровневый вызов OpenAI-совместимого chat/completions. */ +/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */ async function callLLM(messages, maxTokens, override) { const cfg = override || llmConfig(); - if (typeof fetch !== 'function' || !cfg.on) return null; + if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); try { @@ -281,11 +282,11 @@ async function callLLM(messages, maxTokens, override) { body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 320, messages }), signal: ctrl.signal, }); - if (!r.ok) return null; + if (!r.ok) return { text: null, error: r.status === 429 ? 'rate_limit' : 'http', status: r.status }; const data = await r.json(); const text = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; - return text ? String(text).trim() : null; - } catch (e) { return null; } finally { clearTimeout(timer); } + return { text: text ? String(text).trim() : null, error: text ? null : 'empty' }; + } catch (e) { return { text: null, error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); } } /* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */ @@ -383,15 +384,23 @@ async function ask(req, res) { let context = pageCtx; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text; - let answer = null; - try { answer = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { answer = null; } + let r = { text: null, error: 'network' }; + try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { r = { text: null, error: 'network' }; } + const answer = r && r.text; if (answer) { bumpUsage('model_calls'); if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} } - } else { bumpUsage('faq'); } - - res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson, sources: answer ? rag.sources : [] }); + return res.json({ source: 'model', answer, answers: faqJson, sources: rag.sources }); + } + bumpUsage('faq'); + if (r && r.error === 'rate_limit') { + return res.json({ source: 'limit', answer: 'Сейчас слишком много запросов к ИИ за короткое время — подожди минутку и спроси снова. Память диалога не потеряется.', answers: faqJson, sources: [] }); + } + if (r && (r.error === 'timeout' || r.error === 'network' || r.error === 'http')) { + return res.json({ source: 'error', answer: 'Не получилось обратиться к ИИ. Попробуй ещё раз чуть позже.', answers: faqJson, sources: [] }); + } + res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); } /* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */ @@ -414,7 +423,8 @@ async function flashcardsFromText(req, res) { const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' + 'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' + 'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.'; - const raw = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400); + const rr = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400); + const raw = rr && rr.text; let cards = []; if (raw) { let s = raw.replace(/```(?:json)?/gi, '').trim(); diff --git a/frontend/admin.html b/frontend/admin.html index 3d85186..c0252ea 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1070,6 +1070,9 @@ + @@ -1545,6 +1548,13 @@ + +
+
Помощник «Квантик»
+
Настройки ИИ-помощника: модель, RAG по учебникам, кнопки на экзамене, статистика и качество ответов.
+
Загрузка…
+
+
Управление играми
@@ -2117,6 +2127,7 @@ + diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index d07fa3f..f2a9eb5 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -15,7 +15,7 @@ AdminCtx.isAdmin = isAdmin; /* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */ - const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games']; + const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant']; const lockSvg = ''; ADMIN_ONLY_TABS.forEach(id => { const el = document.getElementById(id); @@ -65,6 +65,7 @@ tpl: 'tpl', sims: 'sims', games: 'games', + assistant: 'assistant', sublog: 'sublog', access: 'access', }; diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js new file mode 100644 index 0000000..46edd6c --- /dev/null +++ b/frontend/js/admin/sections/assistant.js @@ -0,0 +1,124 @@ +'use strict'; +/* admin → «Помощник Квантик»: системный вкл/выкл + конфиг LLM (ключ/модель/тест), + * RAG, кнопки на экзамене, статистика использования и качество. */ +(function () { + 'use strict'; + let inited = false; + var IN = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)'; + var BTN = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)'; + + async function render() { + var host = document.getElementById('assistant-admin'); + if (!host) return; + host.innerHTML = ''; + + // ── Системный выключатель (feature 'assistant') ── + var feats = {}; + try { feats = await LS.api('/api/admin/features'); } catch (e) {} + var on = feats.assistant !== false; + var master = document.createElement('div'); + master.className = 'perm-card' + (on ? ' enabled' : ''); + master.innerHTML = + '
Помощник включён для всей системы
' + + '
Выключатель «Квантика» для всех пользователей. Выключено — помощник не загружается нигде.
' + + ''; + host.appendChild(master); + master.querySelector('#asst-master').addEventListener('change', function () { + var v = this.checked; + LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ assistant: v }) }) + .then(function () { master.classList.toggle('enabled', v); LS.toast(v ? 'Помощник включён' : 'Помощник выключен для всех', 'success'); }) + .catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); }); + }); + + // ── Конфиг модели ── + var card = document.createElement('div'); + card.id = 'asst-llm-card'; + card.className = 'perm-card'; + card.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px'; + card.innerHTML = + '
Модель (ИИ) для «Спроси Квантика»
' + + '
OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + host.appendChild(card); + if (window.lucide) lucide.createIcons(); + + var q = function (s) { return card.querySelector(s); }; + var cfg = {}; + try { cfg = await LS.adminGetAssistant(); } catch (e) {} + (cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; q('#asst-preset').appendChild(o); }); + q('#asst-url').value = cfg.url || ''; + q('#asst-model').value = cfg.model || ''; + q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; + + function setStatus() { + q('#asst-llm-status').innerHTML = cfg.active + ? '● Подключено — «Спроси» отвечает через ИИ' + : '○ Ключ не задан — работает обычный FAQ-режим'; + q('#asst-rag').checked = cfg.rag !== false; + q('#asst-exambtn').checked = !!cfg.examButtons; + q('#asst-chunks').textContent = (cfg.chunks || 0) + ' фрагментов учебников в индексе'; + var u = cfg.usage || {}, u30 = cfg.usage30 || {}; + q('#asst-usage').innerHTML = 'Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.'; + var f = cfg.feedback || {}; + q('#asst-quality').innerHTML = 'Оценки за 30 дней: ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + + (f.recent && f.recent.length ? '. Недавно не помогло: ' + f.recent.map(function (x) { return '«' + String(x.q || '').slice(0, 40) + '»'; }).join(', ') : ''); + } + setStatus(); + + q('#asst-preset').addEventListener('change', function () { var p = (cfg.presets || [])[Number(this.value)]; if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; } }); + q('#asst-save').addEventListener('click', async function () { + var body = { url: q('#asst-url').value, model: q('#asst-model').value }; + var k = q('#asst-key').value.trim(); if (k) body.key = k; + try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; setStatus(); } + catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); } + }); + q('#asst-test').addEventListener('click', async function () { + var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…'; + var body = { url: q('#asst-url').value, model: q('#asst-model').value }; + var k = q('#asst-key').value.trim(); if (k) body.key = k; + try { + var r = await LS.adminTestAssistant(body); + res.innerHTML = r && r.ok + ? '✓ Работает (' + (r.model || '') + '): ' + String(r.sample || 'ответ получен').replace(/' + : '✗ ' + String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200).replace(/'; + } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ''; } + }); + q('#asst-clearkey').addEventListener('click', async function () { + if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return; + try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); } catch (e) { LS.toast('Ошибка', 'error'); } + }); + q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); + q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); }); + q('#asst-reindex').addEventListener('click', async function () { + var btn = q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…'; + try { var r = await LS.adminReindexTextbooks(); cfg.chunks = (r && r.chunks) || 0; setStatus(); LS.toast('Готово: ' + cfg.chunks + ' фрагментов', 'success'); } + catch (e) { LS.toast('Ошибка индексации', 'error'); } + finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; } + }); + } + + window.AdminSections = window.AdminSections || {}; + window.AdminSections.assistant = { + init: async () => { if (inited) return; inited = true; await render(); }, + reload: render, + }; +})(); diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js index 1a1f904..69cd05f 100644 --- a/frontend/js/admin/sections/games.js +++ b/frontend/js/admin/sections/games.js @@ -16,7 +16,6 @@ { key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' }, { key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' }, { key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' }, - { key: 'assistant', label: 'Помощник «Квантик»', desc: 'Плавающий помощник: подсказки по разделам, напоминания и «Спроси Квантика»', icon: 'sparkles' }, ]; const FS_FEATURES = [ @@ -34,104 +33,8 @@ { key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' }, ]; - /* ── Конфиг LLM для помощника «Квантик» ── */ - var IN_STYLE = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)'; - var BTN_STYLE = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)'; - async function renderAssistantLlmCard(grid) { - if (!grid || document.getElementById('asst-llm-card')) return; - var wrap = document.createElement('div'); - wrap.id = 'asst-llm-card'; - wrap.className = 'perm-card'; - wrap.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-bottom:14px'; - wrap.innerHTML = - '
Помощник «Квантик» — модель (ИИ)
' + - '
Подключение LLM к «Спроси Квантика». OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' + - '
' + - '' + - '' + - '' + - '' + - '
' + - '' + - '' + - '' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '' + - '' + - '
' + - '
'; - grid.parentNode.insertBefore(wrap, grid); - if (window.lucide) lucide.createIcons(); - - var q = function (s) { return wrap.querySelector(s); }; - var cfg = {}; - try { cfg = await LS.adminGetAssistant(); } catch (e) {} - var presetSel = q('#asst-preset'); - (cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; presetSel.appendChild(o); }); - q('#asst-url').value = cfg.url || ''; - q('#asst-model').value = cfg.model || ''; - q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; - function setStatus() { - q('#asst-llm-status').innerHTML = cfg.active - ? '● Подключено — «Спроси» отвечает через ИИ' - : '○ Ключ не задан — работает обычный FAQ-режим'; - q('#asst-rag').checked = cfg.rag !== false; - q('#asst-exambtn').checked = !!cfg.examButtons; - q('#asst-chunks').textContent = (cfg.chunks || 0) + ' фрагментов учебников в индексе'; - var u = cfg.usage || {}, u30 = cfg.usage30 || {}; - q('#asst-usage').innerHTML = 'Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. ' + - 'За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.'; - } - setStatus(); - q('#asst-rag').addEventListener('change', function () { - LS.adminSaveAssistant({ rag: q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); - }); - q('#asst-exambtn').addEventListener('change', function () { - LS.adminSaveAssistant({ examButtons: q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); - }); - q('#asst-reindex').addEventListener('click', async function () { - var btn = q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…'; - try { var r = await LS.adminReindexTextbooks(); cfg.chunks = (r && r.chunks) || 0; setStatus(); LS.toast('Готово: ' + cfg.chunks + ' фрагментов', 'success'); } - catch (e) { LS.toast('Ошибка индексации', 'error'); } - finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; } - }); - presetSel.addEventListener('change', function () { - var p = (cfg.presets || [])[Number(presetSel.value)]; - if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; } - }); - q('#asst-save').addEventListener('click', async function () { - var body = { url: q('#asst-url').value, model: q('#asst-model').value }; - var k = q('#asst-key').value.trim(); if (k) body.key = k; - try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success'); - cfg = await LS.adminGetAssistant(); cfg.hasKey && (q('#asst-key').placeholder = 'Ключ сохранён — введите новый, чтобы заменить'); setStatus(); - } catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); } - }); - q('#asst-test').addEventListener('click', async function () { - var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…'; - var body = { url: q('#asst-url').value, model: q('#asst-model').value }; - var k = q('#asst-key').value.trim(); if (k) body.key = k; - try { - var r = await LS.adminTestAssistant(body); - res.innerHTML = r && r.ok - ? '✓ Работает (' + (r.model || '') + '): ' + (r.sample || 'ответ получен').replace(/' - : '✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().slice(0, 200).replace(/'; - } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ''; } - }); - q('#asst-clearkey').addEventListener('click', async function () { - if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return; - try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); } - catch (e) { LS.toast('Ошибка', 'error'); } - }); - } - async function loadGamesAdmin() { const grid = document.getElementById('games-features-grid'); - renderAssistantLlmCard(grid); try { const features = await LS.api('/api/admin/features'); grid.innerHTML = ''; diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 7c62fec..e292c79 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -280,9 +280,12 @@ '.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}', reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}', '@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}', - '.asst-bubble{position:absolute;left:0;bottom:64px;width:300px;max-width:78vw;background:#fff;border-radius:16px;', - ' box-shadow:0 18px 50px rgba(15,23,42,.22);padding:14px 16px;border:1px solid rgba(15,23,42,.07);', - ' opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .18s,transform .18s;}', + '.asst-bubble{position:absolute;left:0;bottom:66px;width:330px;max-width:88vw;background:#fff;border-radius:18px;', + ' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);', + ' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}', + '.asst-name-face{display:inline-block;width:20px;height:20px;vertical-align:-4px;margin-right:7px;}', + '.asst-name-face svg{width:100%;height:100%;display:block;}', + '.asst-memnote{font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:8px;}', '.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}', '.asst-x{position:absolute;top:8px;right:8px;width:26px;height:26px;border:none;background:transparent;color:#8a94a6;', ' cursor:pointer;border-radius:7px;font-size:18px;line-height:1;}', @@ -493,9 +496,10 @@ '' + '
'; openBubble( - '
Спроси Квантика' + (_chat.length ? '' : '') + '
' + + '
' + faceSVG('happy') + 'Спроси Квантика' + (_chat.length ? '' : '') + '
' + '
' + chips + modes + - '', {}); + '' + + '
Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.
', {}); var inp = bubble.querySelector('.asst-ask-in'); var chatEl = bubble.querySelector('.asst-chat'); var chipsEl = bubble.querySelector('.asst-chips'); @@ -540,9 +544,16 @@ (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }), ]).then(function (res) { ph.remove(); - var model = res[0] && res[0].answer; - var ans = (res[0] && res[0].answers) || []; - var sources = (res[0] && res[0].sources) || []; + var r0 = res[0] || {}; + // лимит/ошибка ИИ — не ломаем память диалога: убираем последний вопрос, показываем сообщение + if (r0.source === 'limit' || r0.source === 'error') { + _chat.pop(); + var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.'; + chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; return; + } + var model = r0.source === 'model' ? r0.answer : null; + var ans = r0.answers || []; + var sources = r0.sources || []; var found = (res[1] && res[1].results) || []; var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).'; _chat.push({ role: 'assistant', content: content });