From 0d0d936e7b5cd41a31773069540b1ddb8836ef44 Mon Sep 17 00:00:00 2001 From: Agando Date: Sun, 18 Oct 2026 14:05:18 +0200 Subject: newOS4: interactive AI course (terminal module, portal, atlas, telemetry, daily maintenance+cron) + free store card --- modules/ext/course.js | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 modules/ext/course.js (limited to 'modules/ext') diff --git a/modules/ext/course.js b/modules/ext/course.js new file mode 100644 index 0000000..3879cb5 --- /dev/null +++ b/modules/ext/course.js @@ -0,0 +1,323 @@ +class CourseModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.BASE = '/spaces/newos4/course/'; + this.STATE_KEY = 'webx11_course_progress_v1'; + this._manifest = null; + this._atlas = null; + this._chapters = {}; + this._active = null; + this._capOut = ''; + this._capActive = false; + } + + getHelpInfo() { + return { + 'course': 'course [start|list|next|open |check|hint|progress|reset|ask |project [id]|cmds [q]] — Interactive newOS4 course' + }; + } + + registerCommands() { + this.commands['course'] = (args) => this.handle(args || []); + this._warm(); + } + + _v() { + const mod = localStorage.getItem('webx11_cache_ver') || '1'; + return mod + '-' + new Date().toISOString().slice(0, 10); + } + + async _warm() { + try { await this._load(); await this._installSkills(); } catch (e) { /* offline: commands report on use */ } + } + + async _load(force) { + if (this._manifest && !force) return this._manifest; + const bust = this._v(); + const [m, a] = await Promise.all([ + fetch(this.BASE + 'course.json?v=' + bust).then(r => r.json()), + fetch(this.BASE + 'atlas.json?v=' + bust).then(r => r.json()).catch(() => null) + ]); + this._manifest = m; + this._atlas = a; + return m; + } + + async _chapter(id) { + if (this._chapters[id]) return this._chapters[id]; + const ch = await fetch(this.BASE + 'chapters/' + id + '.json?v=' + this._v()).then(r => r.json()); + this._chapters[id] = ch; + return ch; + } + + _progress() { + try { return JSON.parse(localStorage.getItem(this.STATE_KEY)) || { completed: {}, exercisePass: {}, xp: 0 }; } + catch (e) { return { completed: {}, exercisePass: {}, xp: 0 }; } + } + + _save(p) { localStorage.setItem(this.STATE_KEY, JSON.stringify(p)); } + + _printLesson(l, chTitle) { + const t = this.terminal; + t.print('┌─ ' + chTitle + ' › ' + l.title + ' ─', 'header'); + t.print(' 🎯 ' + (l.objective || ''), 'info'); + t.print(' ⏱ ' + (l.minutes || 5) + ' min · commands: ' + (l.commands || []).join(', '), 'text-muted'); + t.print(''); + for (const b of (l.blocks || [])) { + if (b.type === 'cmd') t.print(' $ ' + b.text, 'success'); + else if (b.type === 'code') t.print(' ' + b.text, 'info'); + else if (b.type === 'tip') t.print(' 💡 ' + b.text, 'warning'); + else if (b.type === 'warn') t.print(' ⚠️ ' + b.text, 'error'); + else t.print(' ' + b.text, ''); + } + if (l.exercise) { + t.print(''); + t.print(' 🏋️ Exercise: ' + l.exercise.instruction, 'info'); + t.print(' Type the command(s); completion is detected automatically (or use `course check`).', 'text-muted'); + } + if (l.quiz) { + t.print(''); + t.print(' ❓ ' + l.quiz.q, 'info'); + (l.quiz.options || []).forEach((o, i) => t.print(' ' + (i + 1) + ') ' + o, 'text-muted')); + t.print(' Answer with: `course quiz `', 'text-muted'); + } + } + + async _open(lessonId) { + const m = await this._load(); + for (const ch of m.chapters || []) { + const c = await this._chapter(ch.id); + const l = (c.lessons || []).find(x => x.id === lessonId); + if (!l) continue; + this._active = { lessonId, chapterId: ch.id, chapterTitle: ch.title, lesson: l }; + this._printLesson(l, ch.title); + this._track('lesson_open', { lesson: lessonId }); + return true; + } + this.terminal.print('Lesson not found: ' + lessonId, 'error'); + return false; + } + + _flat(m) { + return (m.chapters || []).map(ch => ({ id: ch.id, title: ch.title, icon: ch.icon || '📘', level: ch.level || 'beginner', lessons: ch.lessons || [] })); + } + + async handle(args) { + const sub = (args[0] || 'start').toLowerCase(); + let m; + try { m = await this._load(); } + catch (e) { return this.terminal.print('Course content unreachable (' + e.message + '). Try `course list` again.', 'error'); } + const p = this._progress(); + if (sub === 'list' || sub === 'chapters') { + this.terminal.print('📚 newOS4 Course — v' + m.version + ' (' + m.updated + ')', 'header'); + for (const ch of this._flat(m)) { + const done = ch.lessons.filter(l => p.completed[l.id]).length; + this.terminal.print(' ' + ch.icon + ' ' + ch.id + ' · ' + ch.title + ' [' + done + '/' + ch.lessons.length + ']', done ? 'success' : ''); + } + this.terminal.print(' course start | open | next | progress', 'text-muted'); + return; + } + if (sub === 'start' || sub === 'next') { + for (const ch of this._flat(m)) for (const l of ch.lessons) if (!p.completed[l.id]) return this._open(l.id); + return this.terminal.print('🎉 Course complete! Try `course project` or `course cmds `.', 'success'); + } + if (sub === 'open') { if (!args[1]) return this.terminal.print('Usage: course open ', 'warning'); return this._open(args[1]); } + if (sub === 'hint') { + if (!this._active) return this.terminal.print('Open a lesson first: `course start`.', 'warning'); + return this.terminal.print('💡 ' + (this._active.lesson.exercise?.hint || 'No hint for this lesson.'), 'warning'); + } + if (sub === 'progress') { + const total = this._flat(m).reduce((n, ch) => n + ch.lessons.length, 0); + const done = Object.keys(p.completed).length; + this.terminal.print('📈 Progress: ' + done + '/' + total + ' lessons · ' + (p.xp || 0) + ' XP', 'header'); + const next = this._flat(m).flatMap(c => c.lessons).find(l => !p.completed[l.id]); + if (next) this.terminal.print(' Next: `course open ' + next.id + '` — ' + next.title, 'info'); + return; + } + if (sub === 'reset') { + if (args[1] !== '--confirm') return this.terminal.print('This erases course progress. Run: course reset --confirm', 'warning'); + localStorage.removeItem(this.STATE_KEY); this._active = null; return this.terminal.print('Course progress reset.', 'success'); + } + if (sub === 'quiz') { + if (!this._active || !this._active.lesson.quiz) return this.terminal.print('No quiz open.', 'warning'); + const pick = parseInt(args[1], 10) - 1; + const ok = pick === this._active.lesson.quiz.answer; + if (ok) this._complete(this._active.lessonId, 5); + return this.terminal.print(ok ? '✅ Correct (+5 XP)' : '❌ Not quite — re-read with `course open ' + this._active.lessonId + '`.', ok ? 'success' : 'error'); + } + if (sub === 'check') return this._manualCheck(); + if (sub === 'cmds') return this._cmdSearch(args.slice(1).join(' ')); + if (sub === 'project') return this._project(args[1]); + if (sub === 'ask') return this._ask(args.slice(1).join(' ')); + this.terminal.print('Usage: ' + this.getHelpInfo().course, 'warning'); + } + + _complete(lessonId, xp) { + const p = this._progress(); + if (!p.completed[lessonId]) { + p.completed[lessonId] = new Date().toISOString(); + p.xp = (p.xp || 0) + (xp || 10); + this._save(p); + this.terminal.print('✅ Lesson ' + lessonId + ' complete (+' + (xp || 10) + ' XP)', 'success'); + this._track('lesson_complete', { lesson: lessonId }); + } + } + + _cmdSearch(q) { + if (!this._atlas) return this.terminal.print('Atlas not loaded yet — retry in a moment.', 'warning'); + const items = Object.entries(this._atlas.commands); + const hit = q ? items.filter(([c, e]) => (c + ' ' + e.desc + ' ' + e.module + ' ' + e.category).toLowerCase().includes(q.toLowerCase())) : items; + this.terminal.print('🔎 ' + (q ? hit.length + ' match(es)' : items.length + ' commands') + ' — `course cmds ` to filter', 'header'); + for (const [c, e] of hit.slice(0, 40)) this.terminal.print(' ' + c.padEnd(18) + ' ' + (e.desc || e.module).slice(0, 90), 'text-muted'); + } + + onCommandStart(cmd, args) { + if (cmd === 'course') return; + if (this._capActive) { try { this.terminal._popPrintCapture(); } catch (e) {} this._capActive = false; } + this._capOut = ''; + this._capActive = true; + try { this.terminal._pushPrintCapture(txt => { this._capOut = txt; }); } catch (e) { this._capActive = false; } + } + + onCommandExecuted(cmd, args) { + if (this._capActive) { try { this.terminal._popPrintCapture(); } catch (e) {} this._capActive = false; } + if (!this._active || !this._active.lesson.exercise || cmd === 'course') return; + const ex = this._active.lesson.exercise; + const line = (cmd + (args.length ? ' ' + args.join(' ') : '')).trim(); + if (!ex.accept.some(pat => this._match(pat, line))) return; + this._verify(ex, line); + } + + _match(pattern, line) { + const esc = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp('^' + esc + '$', 'i').test(line); + } + + _verify(ex, line) { + const check = ex.check || { type: 'command' }; + let ok = false, detail = ''; + if (check.type === 'command' || check.type === 'manual') ok = true; + else if (check.type === 'file') { + if (!check.path) { detail = 'exercise misconfigured: check.path missing'; } + else { + let exists = false; + try { const c = this.terminal.fs.readFile(check.path); exists = (c !== null && c !== undefined); } catch (e) { exists = false; } + ok = exists; detail = ok ? '' : 'file not found: ' + check.path; + } + } else if (check.type === 'output') { + ok = (this._capOut || '').toLowerCase().includes((check.contains || '').toLowerCase()); + detail = ok ? '' : 'expected output to contain "' + check.contains + '"'; + } + if (ok) this._complete(this._active.lessonId, 10); + else { this.terminal.print(' ✗ Not done yet — ' + (detail || 'keep trying'), 'warning'); this._track('exercise_fail', { lesson: this._active.lessonId, command: line.slice(0, 120) }); } + } + + _manualCheck() { + if (!this._active) return this.terminal.print('Open a lesson first: `course start`.', 'warning'); + const ex = this._active.lesson.exercise; + if (!ex) return this.terminal.print('This lesson has no exercise.', 'warning'); + this._verify(ex, '(manual check)'); + } + + async _ask(q) { + if (!q) return this.terminal.print('Usage: course ask ', 'warning'); + const l = this._active && this._active.lesson; + const sys = 'You are the newOS4 course tutor. Answer in <=120 words. Use ONLY the course context and command atlas below. ' + + 'If something is not covered, say so and point to `help ` or the relevant lesson — never invent commands.\n\n' + + (l ? this._lessonContext(l) : this._atlasContext(q)); + const model = (this.terminal.ollamaManager && this.terminal.ollamaManager.currentModel) || 'gemma4:31b-cloud'; + const tok = localStorage.getItem('webx11_session_id') || ''; + const headers = { 'Content-Type': 'application/json' }; + if (tok) headers.Authorization = ['Bearer', tok].join(' '); + this.terminal.print('🤖 thinking…', 'text-muted'); + try { + const r = await fetch('/ai/v1/chat/completions', { + method: 'POST', headers, + body: JSON.stringify({ model, stream: false, messages: [{ role: 'system', content: sys }, { role: 'user', content: q }] }) + }); + if (!r.ok) throw new Error('HTTP ' + r.status + (r.status === 401 ? ' — log in to use the AI tutor' : '')); + const d = await r.json(); + const ans = (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || '(empty answer)'; + this.terminal.print('🎓 ' + ans.trim(), 'info'); + this._track('question', { q: q.slice(0, 240), lesson: l ? l.id : null }); + } catch (e) { + this.terminal.print('AI tutor unavailable: ' + e.message + '. Use `ai ` as a fallback.', 'warning'); + } + } + + _lessonContext(l) { + const lines = ['LESSON: ' + l.title, 'OBJECTIVE: ' + l.objective, 'COMMANDS: ' + (l.commands || []).join(', ')]; + for (const b of (l.blocks || [])) lines.push(b.type.toUpperCase() + ': ' + b.text); + if (l.exercise) lines.push('EXERCISE: ' + l.exercise.instruction + ' | HINT: ' + l.exercise.hint); + if (this._atlas) for (const c of (l.commands || [])) { const e = this._atlas.commands[c]; if (e) lines.push('ATLAS ' + c + ': ' + e.desc + ' [' + e.tier + ']'); } + return lines.join('\n').slice(0, 6000); + } + + _atlasContext(q) { + if (!this._atlas) return 'No atlas loaded.'; + const hits = Object.entries(this._atlas.commands) + .filter(([c, e]) => (c + ' ' + e.desc).toLowerCase().includes((q || '').toLowerCase().split(/\s+/)[0] || '')) + .slice(0, 8).map(([c, e]) => c + ': ' + e.desc); + return 'COMMAND ATLAS (top matches):\n' + hits.join('\n'); + } + + async _project(id) { + const m = await this._load(); + const ids = m.projects || []; + if (!id) return this.terminal.print('🛠 Projects: ' + (ids.join(', ') || 'none yet') + ' — `course project `', 'info'); + const pr = await fetch(this.BASE + 'projects/' + id + '.json?v=' + this._v()).then(r => r.json()); + this.terminal.print('🛠 ' + pr.title + ' (' + pr.minutes + ' min) — ' + pr.goal, 'header'); + (pr.steps || []).forEach((s, i) => this.terminal.print(' ' + (i + 1) + '. ' + s.instruction + (s.command ? ' → ' + s.command : ''), '')); + this._track('project_open', { project: id }); + } + + async _installSkills() { + const fs = this.terminal && this.terminal.fs; + const m = this._manifest; + if (!fs || !m) return; + const key = 'webx11_course_skills_ver'; + if (localStorage.getItem(key) === m.version) return; + try { + fs.mkdirRecursive('/skills'); + const index = fs.fsReadJSON('/skills/index.json') || {}; + let n = 0; + for (const ch of m.chapters || []) { + const c = this._chapters[ch.id] || await this._chapter(ch.id); + const body = (c.lessons || []).map(l => '## ' + l.title + '\n' + (l.blocks || []) + .filter(b => b.type === 'text' || b.type === 'warn' || b.type === 'tip') + .map(b => b.text).join('\n')).join('\n'); + const name = 'course-' + ch.id.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); + const content = '---\nname: ' + name + '\ndescription: newOS4 course — ' + ch.title + + '\ntags: [course, newos4]\ncategory: learning\n---\n' + body; + fs.writeFile('/skills/' + name + '.md', content); + index[name] = { + name, description: 'newOS4 course — ' + ch.title, + tags: ['course', 'newos4'], category: 'learning', + created: (index[name] && index[name].created) || new Date().toISOString(), + updated: new Date().toISOString() + }; + n++; + } + fs.fsWriteJSON('/skills/index.json', index); + localStorage.setItem(key, m.version); + if (n) this.terminal.print('📚 Course skills synced (' + n + ') — the AI can use them via `load_skills`.', 'success'); + } catch (e) { /* VFS busy — retried on next load */ } + } + + _track(type, detail) { + try { + const sid = localStorage.getItem('webx11_course_sid') || (Math.random().toString(36).slice(2) + Date.now().toString(36)); + localStorage.setItem('webx11_course_sid', sid); + if (localStorage.getItem('webx11_course_telemetry') === 'off') return; + fetch('/spaces/newos4/api/course/event.php', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, + body: JSON.stringify({ sid, type, detail, version: (this._manifest && this._manifest.version) || '' }) + }).catch(() => {}); + } catch (e) {} + } +} + +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new CourseModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/course.js'); +} -- cgit v1.2.3