diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-30 06:00:47 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-30 06:00:47 +0200 |
| commit | e3fb2d434c4f7317944b7c77781599528d766a39 (patch) | |
| tree | 8f9f9c94b19d46f42f8086adb6b68ee07dfce5c6 | |
| parent | d570f86177c514ae888fb63fc0bb0da7dd4d5097 (diff) | |
auto: 2026-05-30_06:00 changes
| -rw-r--r-- | main.html | 39 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 113 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-system.js | 2 | ||||
| -rw-r--r-- | modules/core/ollama-manager.js | 22 | ||||
| -rw-r--r-- | modules/core/terminal.js | 65 | ||||
| -rw-r--r-- | modules/ext/editor.js | 4 | ||||
| -rw-r--r-- | modules/ext/tutorial.js | 277 | ||||
| -rw-r--r-- | packages.json | 8 |
9 files changed, 430 insertions, 102 deletions
@@ -222,6 +222,15 @@ touch-action: manipulation; } .qa-btn:active { background: var(--border-color); } + @keyframes confetti-fall { + 0% { transform: translateY(-10vh) rotate(0deg); opacity: 1; } + 100% { transform: translateY(110vh) rotate(720deg); opacity: 0; } + } + .confetti-piece { + position: fixed; top: -5vh; z-index: 9999; + font-size: 24px; pointer-events: none; user-select: none; + animation: confetti-fall 3s ease-in forwards; + } #status-bar { border-top: 1px solid var(--border-color); padding: 4px 10px; @@ -801,6 +810,36 @@ </div> </div> + <!-- Welcome Tour Modal --> + <div id="welcome-modal" class="modal-overlay"> + <div class="modal-content" style="max-width:500px;text-align:center;"> + <div class="modal-header"> + <span>🚀 Welcome to newOS4</span> + </div> + <div class="modal-body" style="padding:24px;"> + <div style="font-size:3em;margin-bottom:12px;">🖥️</div> + <h2 style="margin:0 0 8px;color:var(--accent-color);">Let's Get Started</h2> + <p style="color:var(--text-muted);margin-bottom:16px;line-height:1.6;"> + newOS4 is a terminal-centric browser OS with AI, filesystem, and modules. + Take the <strong>2-minute guided tour</strong> to learn the basics. + </p> + <div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;"> + <button class="btn" onclick="if(webx11&&webx11.tutorialModule)webx11.tutorialModule.startTutorial();else document.getElementById('welcome-modal').style.display='none'" + style="border-color:var(--success-color);color:var(--success-color);padding:12px 28px;font-size:1em;"> + 🎮 Start Tutorial + </button> + <button class="btn" onclick="if(webx11)webx11.dismissWelcome()" + style="border-color:var(--text-muted);color:var(--text-muted);padding:12px 28px;font-size:1em;"> + Skip — I'll explore + </button> + </div> + <p style="font-size:0.78em;color:var(--text-muted);margin-top:16px;"> + Your progress is saved. Type <code>tutorial</code> anytime to resume. + </p> + </div> + </div> + </div> + <!-- Startup Modules Modal --> <div id="startup-modules" class="modal-overlay"> <div class="modal-content" style="max-width: 500px;"> diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 602ae3e..f6fadb5 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -543,42 +543,6 @@ RULES: } return null; } - _parseToolBlocksFromText(text) { - if (!text) return []; - const toolCalls = []; - // Match ```tool name(args) or ```tool\nname(args) blocks - const blockRegex = /```tool(?:\s*\n)?\s*(\w+)\s*({[^}]*})?\s*```/gi; - let match; - while ((match = blockRegex.exec(text)) !== null) { - const name = match[1]; - let args = {}; - try { - if (match[2]) args = JSON.parse(match[2]); - } catch (e) { - // If JSON parse fails, treat as raw string - args = match[2] ? { path: match[2].replace(/[{}]/g,'').trim() } : {}; - } - toolCalls.push({ - id: 'text_' + Date.now() + '_' + toolCalls.length, - type: 'function', - function: { name, arguments: args } - }); - } - // Also handle single-line format: ```tool get_status{} - const simpleRegex = /```tool\s+(\w+)\s*\{([^}]*)\}\s*```/gi; - while ((match = simpleRegex.exec(text)) !== null) { - // Avoid duplicates — skip if already caught by blockRegex - const name = match[1]; - if (!toolCalls.find(tc => tc.function.name === name)) { - toolCalls.push({ - id: 'text_' + Date.now() + '_' + toolCalls.length, - type: 'function', - function: { name, arguments: {} } - }); - } - } - return toolCalls; - } _trackToolFailure(toolName, error) { try { const key = 'webx11_tool_failures'; @@ -611,67 +575,7 @@ RULES: if (this._cancelled) return { finalAnswer: null, toolTurns }; if (res.error) { this.terminal.print(`⚠️ AI model error (${thinkTime}s): ${res.error}`, 'error'); return { finalAnswer: null, toolTurns }; } if (res.message && !res.tool_calls) { - let text = res.message.content || ''; - // Fallback: parse ```tool blocks from text when model writes them instead of native tool_calls - let parsedToolCalls = this._parseToolBlocksFromText(text); - if (parsedToolCalls.length > 0) { - this.terminal.print(`🔧 Parsed ${parsedToolCalls.length} tool call(s) from text block — turn ${toolTurns}/${this.MAX_TOOL_TURNS} (${thinkTime}s)`, 'info'); - // Extract any non-tool text as pending assistant content - const cleanText = text.replace(/```tool[\s\S]*?```/g, '').trim(); - if (cleanText) messages.push({ role: 'assistant', content: cleanText }); - messages.push({ role: 'assistant', content: '(calling tool via text block)' }); - let pendingText = cleanText || null; - const destructiveTools = parsedToolCalls.filter(tc => this.isDestructive(tc.function.name)); - const safeTools = parsedToolCalls.filter(tc => !this.isDestructive(tc.function.name)); - const safeResults = await Promise.all(safeTools.map(async (tc) => { - const fn = tc.function; - const t0 = Date.now(); - const toolResult = await this.runTool(fn.name, fn.arguments); - const t1 = Math.round((Date.now() - t0) / 1000); - return { fn, toolResult, t1 }; - })); - for (const { fn, toolResult, t1 } of safeResults) { - this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); - const obs = this._summarizeObservation(toolResult, 150); - const err = this._isToolError(toolResult); - if (err) { - this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${err} (${t1}s)`); - this._trackToolFailure(fn.name, err); - this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); - } else { - this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); - this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); - } - messages.push({ role: 'tool', content: toolResult }); - } - if (destructiveTools.length > 0) { - const approved = await this.confirmActionMulti(destructiveTools); - if (approved) { - for (const tc of destructiveTools) { - const fn = tc.function; - const t0 = Date.now(); - this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); - const toolResult = await this.runTool(fn.name, fn.arguments); - const t1 = Math.round((Date.now() - t0) / 1000); - const dObs = this._summarizeObservation(toolResult, 150); - const dErr = this._isToolError(toolResult); - if (dErr) { - this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`); - this._trackToolFailure(fn.name, dErr); - this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); - } else { - this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); - this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); - } - messages.push({ role: 'tool', content: toolResult }); - } - } else { - messages.push({ role: 'tool', content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied.` }); - } - } - if (pendingText) messages.push({ role: 'assistant', content: pendingText }); - continue; - } + const text = res.message.content || ''; this.terminal.printAIResponse(text || '(no response)'); if (text) this.terminal.ollamaManager.fireResponseHooks(text); this.terminal.print(`💬 AI answered (turn ${toolTurns}/${this.MAX_TOOL_TURNS}, ${thinkTime}s)`, 'info'); @@ -734,6 +638,21 @@ RULES: }); } } +// Validator: check safe tool results for consistency + if (safeResults.length > 0 && pendingText) { + const om = this.terminal.ollamaManager; + if (om && om.validatorEnabled && om.validatorModel) { + let anyInvalid = false; + for (const { fn, toolResult } of safeResults) { + const valid = await om.validateToolResult(fn.name, fn.arguments, toolResult, pendingText || ''); + if (!valid && !this._cancelled) { + anyInvalid = true; + this.terminal.print('🔍 Validator: ' + fn.name + ' result — AI note may not match (logged)', 'info'); + // Just log it — don't force retry (cloud models get 400 on retry) + } + } + } + } if (pendingText) { messages.push({ role: 'assistant', content: pendingText }); } diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 3a32f38..cc3a79c 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -156,7 +156,7 @@ // Phase 4: Ext modules (filtered) if (typeof webx11._loadExtModules === 'function') { - const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip']; + const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'tutorial']; const extSelected = selected ? extKeys.filter(k => isSelected(k + '.js', selected)) : extKeys; diff --git a/modules/core/module-system.js b/modules/core/module-system.js index b561ef2..29a9a87 100644 --- a/modules/core/module-system.js +++ b/modules/core/module-system.js @@ -207,7 +207,7 @@ ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-stop','ai-model'], search: ['search','s','ddg','duck','fetch','web','extract'], tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias', - 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand'], + 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand','tutorial'], system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs','export','unset','script','set','test'], net: ['server','servers','server-check','server-remove','monitor'], mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'], diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js index 34ece29..420cbc3 100644 --- a/modules/core/ollama-manager.js +++ b/modules/core/ollama-manager.js @@ -927,6 +927,28 @@ class OllamaManager { this.saveSettings(); } + async validateToolResult(toolName, args, result, aiAnswer) { + if (!this.validatorEnabled || !this.validatorModel) return true; + const endpoint = this.validatorEndpoint || this.endpoint; + const prompt = 'Tool called: ' + toolName + '(' + JSON.stringify(args) + ')\n' + + 'Tool returned: ' + ((result || '').substring(0, 500)) + '\n' + + 'AI answered: ' + ((aiAnswer || '').substring(0, 500)) + '\n' + + 'QUESTION: Is the AI answer consistent with the tool result?\nAnswer only YES or NO.'; + try { + const res = await fetch(endpoint + '/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.validatorModel, messages: [{ role: 'user', content: prompt }], stream: false }) + }); + const data = await res.json(); + const answer = (data.message?.content || '').trim().toUpperCase(); + return answer === 'YES'; + } catch (e) { + if (this.terminal) this.terminal.print('⚠️ Validator error: ' + e.message, 'warning'); + return true; + } + } + getModels() { return this.models; } addResponseHook(fn) { this.responseHooks.push(fn); } diff --git a/modules/core/terminal.js b/modules/core/terminal.js index 5e6db6a..de2f4a7 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -211,6 +211,7 @@ this.suggestions = []; this._isCdAutocomplete = false; this._autocompletePrefix = ''; + this._welcomeShown = false; this.initEventListeners(); this.updateNetworkStatus(); this.loadBuiltInModules(); @@ -226,6 +227,8 @@ soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '🔇 Muted' : '🔊 Sound'; } setTimeout(() => this.inputField.focus(), 100); + // First-run welcome check — fires after ext modules are loaded + setTimeout(() => this._checkFirstRun(), 2000); } initEventListeners() { this.inputField.addEventListener('keydown', (e) => { @@ -943,7 +946,7 @@ catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } } async _loadExtModules(filterKeys) { - const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; + const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell', 'tutorial']; const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'; try { const res = await fetch('packages.json?v=' + cacheVer); @@ -1048,11 +1051,34 @@ for (const modName in this.moduleManager.modules) { const mod = this.moduleManager.modules[modName]; if (mod.commands && mod.commands[cmd]) { - try { await mod.commands[cmd](args); return; } + try { await mod.commands[cmd](args); + // Notify tutorial module for quest tracking + if (this.tutorialModule && cmd !== 'tutorial') this.tutorialModule.onCommandExecuted(cmd, args); + return; } catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } } } - // No AI routing here — security manager handles that via checkAndRun + // Enhanced: check fuzzy suggestions before showing "not found" + const suggestion = this.security.suggest(cmd); + if (suggestion && suggestion.dist <= 2) { + this.print(`Command not found: ${cmd}`, 'error'); + this.print(`Did you mean: ${suggestion.cmd}? (yes) (no)`, 'info'); + // Render clickable yes/no + const out = this.outputContainer; + if (out) { + const yesSpan = document.createElement('span'); + yesSpan.textContent = ' [yes] '; + yesSpan.style.cssText = 'color:var(--success-color);cursor:pointer;text-decoration:underline;margin-right:12px'; + yesSpan.onclick = () => this.executeCommand(suggestion.cmd + (args.length ? ' ' + args.join(' ') : '')); + const noSpan = document.createElement('span'); + noSpan.textContent = ' [no] '; + noSpan.style.cssText = 'color:var(--error-color);cursor:pointer;text-decoration:underline'; + noSpan.onclick = () => { /* do nothing — user declined */ }; + const line = out.lastElementChild; + if (line) { line.appendChild(yesSpan); line.appendChild(noSpan); } + } + return; + } this.print(`Command not found: ${cmd}`, 'error'); } openFileBrowser() { @@ -1675,6 +1701,39 @@ } } catch (e) {} } + // ── First-run welcome ── + _checkFirstRun() { + if (localStorage.getItem('webx11_tutorial_complete') || localStorage.getItem('webx11_tutorial_skipped')) return; + // Check if tutorial module is loaded — retry if ext modules not ready yet + const tutMod = this.moduleManager.modules['TutorialModule']; + if (!tutMod) { + // Ext modules may still be loading; retry in 2s + if (!this._welcomeRetry) { + this._welcomeRetry = true; + setTimeout(() => this._checkFirstRun(), 2000); + } + return; + } + this.tutorialModule = tutMod; + // Only show welcome if user hasn't explicitly dismissed it + if (!localStorage.getItem('webx11_welcome_dismissed')) { + this.showWelcomeModal(); + } + } + showWelcomeModal() { + const modal = document.getElementById('welcome-modal'); + if (modal) { + modal.style.display = 'flex'; + this._welcomeShown = true; + } + } + dismissWelcome() { + const modal = document.getElementById('welcome-modal'); + if (modal) modal.style.display = 'none'; + localStorage.setItem('webx11_welcome_dismissed', '1'); + // Also mark tutorial as skipped so it doesn't reappear + if (this.tutorialModule) this.tutorialModule.skipTutorial(); + } log(level, source, message) { const d = new Date(); const ts = d.toISOString().split('T')[1].split('.')[0]; diff --git a/modules/ext/editor.js b/modules/ext/editor.js index c597dd5..8c3ac0f 100644 --- a/modules/ext/editor.js +++ b/modules/ext/editor.js @@ -44,6 +44,10 @@ class EditorModule { const ok = this.terminal.fs.writeFile(this._vfsPath, code); if (ok) { this.terminal.print('✅ Saved to ' + this._vfsPath, 'success'); + // Notify tutorial module for quest 4 (edit + save) + if (this.terminal.tutorialModule) { + this.terminal.tutorialModule.onFileSaved(this._vfsPath); + } } else { this.terminal.print('❌ Failed to save ' + this._vfsPath, 'error'); } diff --git a/modules/ext/tutorial.js b/modules/ext/tutorial.js new file mode 100644 index 0000000..8983322 --- /dev/null +++ b/modules/ext/tutorial.js @@ -0,0 +1,277 @@ +class TutorialModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.STORAGE_KEY = 'webx11_tutorial_state'; + this.COMPLETE_KEY = 'webx11_tutorial_complete'; + this.SKIP_KEY = 'webx11_tutorial_skipped'; + this._maxTries = 3; + this._initState(); + } + + _initState() { + // Restore saved state or start fresh + const saved = localStorage.getItem(this.STORAGE_KEY); + if (saved) { + try { + const s = JSON.parse(saved); + this._completed = new Set(s.completed || []); + this._activeIndex = s.activeIndex || 0; + this._tries = s.tries || {}; + this._started = s.started || false; + return; + } catch(e) { /* corrupt, reset */ } + } + this._completed = new Set(); + this._activeIndex = 0; + this._tries = {}; + this._started = false; + } + + _saveState() { + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify({ + completed: Array.from(this._completed), + activeIndex: this._activeIndex, + tries: this._tries, + started: this._started + })); + } catch(e) { /* quota */ } + } + + _getQuests() { + return [ + { id: 1, title: 'Your First Command', desc: 'Type `help` to see every command available in newOS4.', + check: (cmd) => cmd === 'help', hint: 'Just type: help' }, + { id: 2, title: 'Explore the Filesystem', desc: 'Type `ls` to list files in the current directory.', + check: (cmd) => cmd === 'ls', hint: 'Type: ls' }, + { id: 3, title: 'Create Something', desc: 'Use `touch hello.txt` to create your first file.', + check: (cmd) => cmd === 'touch', + hint: 'Type: touch hello.txt' }, + { id: 4, title: 'Write to a File', desc: 'Open the editor with `edit hello.txt`, type something, and click Save.', + check: (cmd, term) => { const c = term && term.fs && term.fs.readFile('hello.txt'); return c && c.length > 0; }, + hint: 'Type: edit hello.txt, then write something and click 💾 Save & Test' }, + { id: 5, title: 'Talk to the AI', desc: 'Type `ai "hello"` to greet the AI assistant.', + check: (cmd) => cmd === 'ai' || cmd === 'ask', + hint: 'Type: ai "hello"' }, + { id: 6, title: 'Check Your Progress', desc: 'Type `status` to see how the system is doing.', + check: (cmd) => cmd === 'status', + hint: 'Type: status' }, + ]; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'tutorial': 'tutorial [start|skip|status] — Interactive guided tour of newOS4 (6 quests)', + }; + } + + registerCommands() { + this.commands['tutorial'] = (args) => this._handleTutorial(args); + } + + // ── Public API called by terminal.js ── + + /** Called after every command execution to check quest progress */ + onCommandExecuted(cmd, args) { + if (!this._started || this.isComplete()) return; + const quests = this._getQuests(); + const current = quests[this._activeIndex]; + if (!current || this._completed.has(current.id)) return; + + // Special handling: quest 4 (edit) — don't count as wrong, wait for save + if (current.id === 4 && cmd === 'edit') { + this.terminal.print(' ✅ Editor opened! Write something and click Save.', 'success'); + return; // Don't fail, don't complete — wait for onFileSaved + } + + // Check if this command completes the current quest + if (current.check(cmd, this.terminal)) { + this._completeCurrentQuest(); + } else { + // Wrong command — increment try count + const count = (this._tries[current.id] || 0) + 1; + this._tries[current.id] = count; + this._saveState(); + if (count >= this._maxTries) { + this.terminal.print(`💡 Hint: ${current.hint}`, 'warning'); + this._tries[current.id] = 0; // reset so hint repeats + } + } + } + + /** Called by editor module after a file is saved via the modal */ + onFileSaved(path) { + if (!this._started || this.isComplete()) return; + const quests = this._getQuests(); + const current = quests[this._activeIndex]; + if (!current || current.id !== 4 || this._completed.has(current.id)) return; + + if (current.check('__file_saved__', this.terminal)) { + this._completeCurrentQuest(); + } + } + + _completeCurrentQuest() { + const quests = this._getQuests(); + const current = quests[this._activeIndex]; + this._completed.add(current.id); + const nextIdx = this._activeIndex + 1; + const next = quests[nextIdx]; + if (next) { + this._activeIndex = nextIdx; + this._saveState(); + this._showQuestNext(next); + } else { + this._activeIndex = nextIdx; + this._saveState(); + this._showCompletion(); + } + } + + isComplete() { + return localStorage.getItem(this.COMPLETE_KEY) !== null; + } + + isSkipped() { + return localStorage.getItem(this.SKIP_KEY) !== null; + } + + isStarted() { + return this._started; + } + + /** Called by welcome modal to begin the tutorial */ + startTutorial() { + this._started = true; + this._completed = new Set(); + this._activeIndex = 0; + this._tries = {}; + this._saveState(); + localStorage.removeItem(this.COMPLETE_KEY); + localStorage.removeItem(this.SKIP_KEY); + this.terminal.print('╔══════════════════════════════════════╗', 'success'); + this.terminal.print('║ 🎮 newOS4 Tutorial Started! ║', 'success'); + this.terminal.print('╚══════════════════════════════════════╝', 'success'); + this.terminal.print('Follow the quests below. Type the correct command to advance.', 'info'); + this._showQuestNext(this._getQuests()[0]); + } + + skipTutorial() { + localStorage.setItem(this.SKIP_KEY, '1'); + this._started = false; + this._completed = new Set(); + this._activeIndex = 0; + this._tries = {}; + localStorage.removeItem(this.STORAGE_KEY); + this.terminal.print('Tutorial skipped. Type `tutorial start` to begin anytime, or `help` to see all commands.', 'info'); + } + + /** Show status of tutorial progress */ + showStatus() { + const quests = this._getQuests(); + const total = quests.length; + const done = this._completed.size; + + if (!this._started && !this.isComplete()) { + this.terminal.print('📋 Tutorial has not been started. Type `tutorial start` to begin!', 'info'); + return; + } + if (this.isComplete()) { + this.terminal.print('🎉 Tutorial complete! All ' + total + ' quests finished.', 'success'); + return; + } + + this.terminal.print('📋 Tutorial Progress: ' + done + '/' + total + ' quests', 'info'); + quests.forEach((q, i) => { + const status = this._completed.has(q.id) ? '✅' : (i === this._activeIndex ? '👉' : '⬜'); + this.terminal.print(' ' + status + ' Quest ' + q.id + ': ' + q.title); + }); + if (this._activeIndex < total) { + this.terminal.print(''); + this._showQuestNext(quests[this._activeIndex]); + } + } + + // ── Internal ── + + _handleTutorial(args) { + if (!args || args.length === 0) { + // No args — show status or start if first time + if (!this._started && !this.isComplete()) { + this.terminal.print('╔══════════════════════════════════════╗', 'info'); + this.terminal.print('║ 🎮 newOS4 Interactive Tutorial ║', 'info'); + this.terminal.print('╚══════════════════════════════════════╝', 'info'); + this.terminal.print('Learn newOS4 by completing 6 short quests.', 'info'); + this.terminal.print(''); + this.terminal.print('Commands:', 'info'); + this.terminal.print(' tutorial start — Begin or restart the tutorial'); + this.terminal.print(' tutorial status — Show your progress'); + this.terminal.print(' tutorial skip — Dismiss the tutorial'); + return; + } + this.showStatus(); + return; + } + + const sub = args[0].toLowerCase(); + if (sub === 'start') this.startTutorial(); + else if (sub === 'skip') this.skipTutorial(); + else if (sub === 'status') this.showStatus(); + else this.terminal.print('Usage: tutorial [start|skip|status]', 'warning'); + } + + _showQuestNext(quest) { + this.terminal.print('── Quest ' + quest.id + '/' + this._getQuests().length + ': ' + quest.title + ' ──', 'info'); + this.terminal.print(' ' + quest.desc, 'text-muted'); + this.terminal.print(' 💡 ' + quest.hint, 'warning'); + } + + _showCompletion() { + this.terminal.print(''); + this.terminal.print('🎉🎉🎉 CONGRATULATIONS! 🎉🎉🎉', 'success'); + this.terminal.print('═'.repeat(40), 'success'); + this.terminal.print(' All 6 quests complete!', 'success'); + this.terminal.print(' You now know the basics of newOS4.', 'success'); + this.terminal.print('═'.repeat(40), 'success'); + this.terminal.print(''); + this.terminal.print('What next?', 'info'); + this.terminal.print(' • Explore commands: help', 'info'); + this.terminal.print(' • Full docs: help.html', 'info'); + this.terminal.print(' • AI assistant: ai \"your question\"', 'info'); + this.terminal.print(' • Schedule tasks: cron-every 60 echo hello', 'info'); + this.terminal.print(' • Install packages: wpm list', 'info'); + this.terminal.print(' • Manage files: edit, find, grep, files', 'info'); + + localStorage.setItem(this.COMPLETE_KEY, Date.now().toString()); + localStorage.removeItem(this.STORAGE_KEY); + + // Fire confetti + this._fireConfetti(); + } + + _fireConfetti() { + const emojis = ['🎉', '✨', '⭐', '🌟', '💫', '🎊', '🔷', '🟢', '🔶']; + const container = document.body; + if (!container) return; + for (let i = 0; i < 40; i++) { + const el = document.createElement('div'); + el.className = 'confetti-piece'; + el.textContent = emojis[i % emojis.length]; + el.style.left = (Math.random() * 100) + 'vw'; + el.style.animationDuration = (2 + Math.random() * 2) + 's'; + el.style.animationDelay = (Math.random() * 1.5) + 's'; + el.style.fontSize = (16 + Math.random() * 20) + 'px'; + container.appendChild(el); + setTimeout(() => el.remove(), 5000); + } + } +} + +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new TutorialModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/tutorial.js'); +} diff --git a/packages.json b/packages.json index 1cb5b97..614c9c5 100644 --- a/packages.json +++ b/packages.json @@ -132,6 +132,14 @@ "commands": ["export", "unset", "script", "set"], "category": "system" }, + "tutorial": { + "name": "Tutorial", + "version": "1.0.0", + "desc": "Interactive guided tour — 6 quests to learn newOS4 basics", + "url": "modules/ext/tutorial.js", + "commands": ["tutorial"], + "category": "onboarding" + }, "test-suite": { "name": "Test Suite", "version": "1.0.0", |