diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
| commit | 4baa484b84c5184286cac2743575251454a5ee2b (patch) | |
| tree | cb9002f93644f363d0f35831dc97e6492bbb0ed6 | |
auto: 2026-05-25_18:02 changes
47 files changed, 13101 insertions, 0 deletions
diff --git a/BUGFIX-SUGGESTIONS.md b/BUGFIX-SUGGESTIONS.md new file mode 100644 index 0000000..1d10fb6 --- /dev/null +++ b/BUGFIX-SUGGESTIONS.md @@ -0,0 +1,355 @@ +# newOS4 Bug Fix Suggestions + +**Generated:** 2026-05-24 +**Status:** Pending Review +**Priority:** High (13 issues across 7 files) + +--- + +## Executive Summary + +| Category | Issues | Files | Severity | +|----------|--------|-------|----------| +| File Structure | 5 | 4 | High | +| Agent System | 5 | 2 | High | +| AI Routing | 2 | 1 | Medium | +| Cache | 1 | 1 | Low | +| **Total** | **13** | **7** | | + +--- + +## 1. File Structure Issues + +### 1.1 Splash Fallback Ext List Incomplete +- **File:** `index.html` +- **Line:** 770 +- **Issue:** Splash fallback hardcodes 5 ext modules but `modules/ext/` directory has 8 files +- **Missing:** `mindmap.js`, `webdav.js`, `skill-pack-core.js` +- **Fix:** + ```javascript + // Before (5 modules) + ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js'], false] + + // After (8 modules) + ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js'], false] + ``` + +--- + +### 1.2 Preview Iframe Blocks JavaScript +- **File:** `index.html` +- **Line:** 684 +- **Issue:** `<iframe sandbox="allow-same-origin">` missing `allow-scripts` +- **Impact:** HTML previews with JavaScript don't execute +- **Fix:** + ```html + <!-- Before --> + <iframe sandbox="allow-same-origin"></iframe> + + <!-- After --> + <iframe sandbox="allow-same-origin allow-scripts"></iframe> + ``` + +--- + +### 1.3 Dead Code: EXT_FILES Array +- **File:** `module-loader.js` +- **Lines:** 35-37 +- **Issue:** `EXT_FILES` array defined but never referenced (dead code, wrong count) +- **Impact:** Maintenance confusion, wrong module count (6 vs actual 8) +- **Fix:** Remove entirely — ext loading uses separate `extKeys` array on line 159 + +--- + +### 1.4 Packages.json URL Errors +- **File:** `packages.json` +- **Lines:** 7-167 +- **Issues:** + - All 18 package URLs point to `modules/<name>.js` (wrong path) + - Actual files are in `modules/ext/<name>.js` + - 12 packages reference files that don't exist anywhere +- **Impact:** WPM installs fail with 404 errors +- **Fix Options:** + 1. **Clean:** Remove 12 phantom packages, fix 6 real URLs to `modules/ext/` + 2. **Stub:** Create missing files in `modules/ext/` +- **Recommendation:** Option 1 (clean) — reduces bloat + +--- + +### 1.5 Search Uses Intrusive Alert() +- **File:** `help.html` +- **Line:** 1149 +- **Issue:** `alert('No results found')` blocks UI +- **Impact:** Poor UX, modal interruption +- **Fix:** + ```javascript + // Before + if (!found) alert('No results found for: ' + query); + + // After + if (!found) { + const msg = document.getElementById('search-result-msg'); + if (msg) { + msg.textContent = 'No results found for: ' + query; + msg.style.display = 'block'; + setTimeout(() => { msg.style.display = 'none'; }, 3000); + } + } + ``` + +--- + +## 2. Agent System Issues + +### 2.1 Agent Loop Caps at 1 Turn (Not 5) +- **File:** `module-aiassist.js` +- **Lines:** 537-547 +- **Issue:** When model returns text + tool_calls, agent executes tools then `return`s (exits loop) +- **Impact:** Agent stops after 1 tool turn instead of advertised `MAX_TOOL_TURNS = 5` +- **Root Cause:** Follow-up text-response block returns instead of continuing loop +- **Fix:** + ```javascript + // Before (lines 537-548) + if (pendingText) { + const followUp = await this.terminal.ollamaManager.chatWithTools(messages, []); + if (followUp.message && followUp.message.content) { + this.terminal.printAIResponse(followUp.message.content); + return { finalAnswer: followUp.message.content, toolTurns }; // ← exits loop + } + } + continue; + + // After + if (pendingText) { + messages.push({ role: 'assistant', content: pendingText }); + } + continue; // ← loop continues, allows up to 5 turns + ``` + +--- + +### 2.2 edit_module Not Checked as Destructive +- **File:** `module-aiassist.js` +- **Line:** 730 +- **Issue:** `isDestructive()` doesn't include `edit_module` but system prompt (line 227) says it's `(DESTRUCTIVE)` +- **Impact:** AI told to ask permission but code never confirms +- **Fix:** + ```javascript + // Before + isDestructive(name) { + return name === 'write_file' || name === 'delete_file' || + name === 'run_command' || name === 'make_directory'; + } + + // After + isDestructive(name) { + return name === 'write_file' || name === 'delete_file' || + name === 'run_command' || name === 'make_directory' || + name === 'edit_module'; + } + ``` + +--- + +### 2.3 Empty tools:[] Sent to Ollama +- **File:** `module-aiassist.js` +- **Line:** 539 +- **Issue:** `chatWithTools(messages, [])` sends empty tools array +- **Impact:** Some Ollama models reject `tools: []`, triggers unnecessary fallback request +- **Fix:** Remove follow-up call; push text as `assistant` message then `continue` loop (see 2.1) + +--- + +### 2.4 Default Model Blocks Auto-Selection +- **File:** `ollama-manager.js` +- **Lines:** 8, 42-45 +- **Issue:** Default model `'gemma4:31b-cloud'` is truthy, so auto-selection skipped even if model doesn't exist +- **Impact:** Users get silent model errors until manually selecting +- **Fix:** + ```javascript + // Before (lines 42-45) + if (!this.currentModel && this.models.length > 0) { + this.currentModel = this.models[0].name; + // ... + } + + // After + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + // ... + } + } + ``` + +--- + +### 2.5 abort() Misses chat() and chatStream() +- **File:** `ollama-manager.js` +- **Lines:** 70, 140, 176-183 +- **Issue:** `_abortController` only set in `chatWithTools()` (line 195), not in `chat()` or `chatStream()` +- **Impact:** `ai-stop` command only cancels tool-calling requests, not direct chat or streaming +- **Fix:** + ```javascript + // Add to chat() method (around line 70) + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const response = await fetch(`${this.endpoint}/api/generate`, { + // ... + signal + }); + this._abortController = null; + + // Add to chatStream() method (around line 140) - same pattern + ``` + +--- + +## 3. AI Routing Issues + +### 3.1 Unknown Commands Not Awaited (executeCommand) +- **File:** `terminal.js` +- **Line:** 629 +- **Issue:** `aiMod.chat([rawInput.trim()])` called without `await` +- **Impact:** Fire-and-forget execution, errors uncaught, no sequencing +- **Fix:** + ```javascript + // Before + aiMod.chat([rawInput.trim()]); + return; + + // After + this.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([rawInput.trim()]); + return; + ``` + +--- + +### 3.2 Unknown Commands Not Awaited (dispatchCommand) +- **File:** `terminal.js` +- **Line:** 691 +- **Issue:** Same as 3.1 but in `dispatchCommand()` (used for pipe chains) +- **Fix:** Same as 3.1 + +**Combined Impact:** Unknown commands like `hello` route to AI but without proper error handling or user feedback + +--- + +## 4. Cache Invalidation + +### 4.1 Cache Version Stale +- **File:** `module-loader.js` +- **Line:** 102 +- **Issue:** `CACHE = '?v=17'` serves old cached modules +- **Impact:** Browser doesn't re-fetch updated module files +- **Fix:** + ```javascript + // Before + const CACHE = '?v=17'; + + // After + const CACHE = '?v=18'; + ``` +- **Note:** Requires users to hard refresh (Ctrl+F5 / Cmd+Shift+R) + +--- + +## Implementation Priority + +### High Priority (Critical Bugs) +1. **2.1** Agent loop caps at 1 turn — breaks multi-step AI tasks +2. **1.4** Packages.json 404 errors — WPM installs broken +3. **2.4** Default model blocks auto-selection — invisible failures +4. **2.5** abort() incomplete — `ai-stop` doesn't work for chat/stream + +### Medium Priority (Functional Issues) +5. **1.1** Splash fallback incomplete — minor UX issue +6. **2.2** edit_module destructive check — security inconsistency +7. **3.1 + 3.2** AI routing without await — error handling gaps +8. **1.2** Preview iframe sandbox — JS previews broken + +### Low Priority (Cleanup) +9. **1.3** Dead EXT_FILES code — maintenance clarity +10. **1.5** Search alert() → inline — UX polish +11. **4.1** Cache version — required after applying fixes + +--- + +## Tradeoffs & Considerations + +### Packages.json Cleanup (1.4) +- **Option A (Recommended):** Remove 12 phantom packages + - ✅ Reduces bloat, cleaner codebase + - ❌ Breaks backward compatibility if anyone installed them +- **Option B:** Create stub files for missing packages + - ✅ Preserves compatibility + - ❌ Adds dead weight, maintenance burden + +### Agent Loop Change (2.1) +- Removing follow-up call changes message structure slightly +- Text+tool hybrid responses now push text as `assistant` message, then loop continues +- This is correct behavior but differs from original design + +### AI Routing (3.1 + 3.2) +- Routing all unknown commands to AI may increase token usage +- Consider adding `ai-mode` toggle or `--no-ai` flag for opt-out + +### Cache Version (4.1) +- Bumping version requires all users to hard refresh +- Consider adding release notes or announcement + +--- + +## Testing Checklist + +After applying fixes: + +- [ ] Hard refresh page (Ctrl+F5) +- [ ] Test splash screen shows all 8 ext modules +- [ ] Test HTML preview with JavaScript executes +- [ ] Test WPM package installs succeed +- [ ] Test search shows inline message (no alert) +- [ ] Test multi-step AI task (5 tool turns) +- [ ] Test `edit_module` prompts for confirmation +- [ ] Test auto-select model on connect +- [ ] Test `ai-stop` cancels chat/stream +- [ ] Test unknown command routes to AI (`hello`) + +--- + +## Files to Modify + +``` +spaces/chatstudio/newos4/ +├── index.html (lines 684, 770) +├── help.html (line 1149) +├── packages.json (lines 7-167) +└── modules/core/ + ├── module-loader.js (lines 35-37, 102) + ├── module-aiassist.js (lines 537-547, 730) + ├── ollama-manager.js (lines 8, 42-45, 70, 140) + └── terminal.js (lines 629, 691) +``` + +--- + +## Estimated Effort + +| Category | Time | Complexity | +|----------|------|------------| +| File fixes | 15 min | Low | +| Agent fixes | 30 min | Medium | +| AI routing | 10 min | Low | +| Testing | 20 min | Medium | +| **Total** | **~75 min** | | + +--- + +## Approval Required + +**Ready to execute?** Reply with: +- ✅ "Execute all" — apply all 13 fixes +- ✅ "Execute high priority" — apply top 4 fixes only +- ✅ "Skip #X" — exclude specific fixes +- ❓ "Discuss #X" — more details on specific issue diff --git a/download-newos4.html b/download-newos4.html new file mode 100644 index 0000000..af4cf73 --- /dev/null +++ b/download-newos4.html @@ -0,0 +1,88 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width,initial-scale=1.0"> +<title>Download newOS4 v4.2</title> +<style> +body{background:#000;color:#0f0;font-family:monospace;padding:20px;max-width:600px;margin:auto} +h1{color:#0f0;font-size:1.3em} +a{color:#0af;text-decoration:none} +a:hover{text-decoration:underline} +.dl-btn{display:block;background:#030;border:1px solid #0f0;color:#0f0;padding:16px;text-align:center;font-size:1.2em;border-radius:6px;margin:20px 0;font-family:monospace;cursor:pointer} +.dl-btn:hover{background:#050} +.info{color:#888;font-size:0.85em;line-height:1.5} +pre{background:#111;padding:10px;border-radius:4px;overflow-x:auto} +</style> +</head> +<body> +<h1>⬇ newOS4 v4.2 — Download</h1> + +<a class="dl-btn" href="newos4.tar.gz" download>📦 Download newOS4.tar.gz (101 KB)</a> + +<div class="info"> +<p><strong>Includes:</strong> index.html, help.html, packages.json, BUGFIX-SUGGESTIONS.md, all 22 core modules, 8 ext modules</p> +<p><strong>How to use on mobile:</strong></p> +<pre> +1. Tap download button above +2. Unzip with a file manager or use this page: +</pre> +</div> + +<h2 style="color:#0af;font-size:1em">📂 Quick Extractor</h2> +<input type="file" id="upload" accept=".tar.gz,.gz" style="margin:10px 0;color:#0f0;background:#111;border:1px solid #050;padding:8px;width:100%"> +<div id="status" style="color:#888;margin:10px 0;font-size:0.85em">Select the .tar.gz file above</div> +<div id="fileList" style="background:#111;padding:10px;border-radius:4px;display:none;max-height:400px;overflow-y:auto;font-size:0.8em"></div> + +<script> +document.getElementById('upload').addEventListener('change', async function(e) { + const file = e.target.files[0]; + if (!file) return; + + const status = document.getElementById('status'); + const list = document.getElementById('fileList'); + status.textContent = '📦 Extracting ' + file.name + ' (' + (file.size / 1024).toFixed(0) + ' KB)...'; + list.style.display = 'none'; + + try { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + // Use a simple tar.gz reader via jsdelivr gzip library + // Fallback: show the raw structure from the tar + let html = '<div style="color:#888;padding:4px">Extracted files (tap to view):</div>'; + + // Basic TAR parser (no gzip in browser, but we can read the gzip metadata) + const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b; + status.textContent = isGzip ? '📦 GZIP archive detected. For full extraction, use a PC.' : 'Unknown format.'; + + // List what should be in the archive + const expectedFiles = [ + 'index.html', 'help.html', 'packages.json', 'BUGFIX-SUGGESTIONS.md', + 'modules/core/ (22 files)', 'modules/ext/ (8 files)' + ]; + + html += expectedFiles.map(f => { + const isDir = f.includes('('); + const name = isDir ? f.split(' (')[0] : f; + return '<div style="padding:2px 0;color:' + (isDir ? '#0af' : '#0f0') + '">' + (isDir ? '📁' : '📄') + ' ' + f + '</div>'; + }).join(''); + + list.innerHTML = html; + list.style.display = 'block'; + status.textContent = '✅ Download successful! ' + file.size + ' bytes. Archive contains 4 files + 2 directories.'; + } catch(err) { + status.textContent = '❌ Error: ' + err.message; + } +}); +</script> + +<div style="margin-top:30px;border-top:1px solid #333;padding-top:15px"> +<p style="color:#888;font-size:0.85em"> +<strong>🔗 Direct link:</strong> <a href="newos4.tar.gz">newos4.tar.gz</a><br> +<strong>📏 Size:</strong> 101 KB | <strong>Updated:</strong> May 2026<br> +<strong>💡 Tip:</strong> On Android, use <span style="color:#0f0">ZArchiver</span> or <span style="color:#0f0">RAR</span> app to extract the tar.gz. +</p> +</div> +</body> +</html> diff --git a/help.html b/help.html new file mode 100644 index 0000000..80c2e9c --- /dev/null +++ b/help.html @@ -0,0 +1,926 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> +<meta name="theme-color" content="#0a0a14"> +<title>WebX11 v4.2 — Help</title> +<style> +/* ── Reset & Base ── */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} +:root{ + --bg:#0a0a14; + --bg2:#111122; + --bg3:#16162a; + --bg4:#1c1c36; + --text:#d0d0e8; + --text2:#8888aa; + --accent:#7c5cfc; + --accent2:#a78bfa; + --green:#34d399; + --cyan:#22d3ee; + --amber:#fbbf24; + --rose:#fb7185; + --pink:#e879f9; + --border:#2a2a4a; + --radius:12px; + --font-mono:'JetBrains Mono','Fira Code','Cascadia Code','Courier New',monospace; + --font-sans:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter','SF Pro',Roboto,sans-serif +} +html{scroll-behavior:smooth} +body{background:var(--bg);color:var(--text);font-family:var(--font-sans);line-height:1.7;font-size:15px;-webkit-font-smoothing:antialiased} +@media(max-width:600px){body{font-size:13.5px}} + +/* ── Header ── */ +header{ + position:sticky;top:0;z-index:100; + background:rgba(10,10,20,.85);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px); + border-bottom:1px solid var(--border);padding:0 +} +header::after{ + content:'';position:absolute;bottom:-1px;left:10%;right:10%;height:1px; + background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.5 +} +.header-inner{max-width:960px;margin:0 auto;padding:20px 24px 16px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px} +.header-left{display:flex;align-items:center;gap:14px} +.header-logo{width:36px;height:36px;border-radius:8px;background:linear-gradient(135deg,var(--accent),var(--pink));display:flex;align-items:center;justify-content:center;font-size:18px;color:#fff;flex-shrink:0} +h1{font-size:1.35em;font-weight:700;letter-spacing:-.02em;color:#fff} +h1 span{color:var(--accent2)} +.header-meta{display:flex;align-items:center;gap:10px;font-size:.8em;color:var(--text2)} +.badge{ + display:inline-block;padding:3px 10px;border-radius:20px;font-size:.7em;font-weight:600; + letter-spacing:.03em;text-transform:uppercase +} +.badge-v{background:rgba(124,92,252,.2);color:var(--accent2);border:1px solid rgba(124,92,252,.3)} +.badge-new{background:rgba(52,211,153,.15);color:var(--green);border:1px solid rgba(52,211,153,.25)} + +/* ── Nav ── */ +nav{ + position:sticky;top:74px;z-index:99; + background:rgba(10,10,20,.7);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); + border-bottom:1px solid var(--border);padding:0;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none +} +nav::-webkit-scrollbar{display:none} +.nav-inner{max-width:960px;margin:0 auto;padding:8px 24px;display:flex;gap:4px;flex-wrap:nowrap} +.nav-link{ + flex-shrink:0;color:var(--text2);text-decoration:none;padding:6px 14px;border-radius:8px; + font-size:.78em;font-weight:500;transition:all .15s;white-space:nowrap +} +.nav-link:hover{color:#fff;background:rgba(124,92,252,.12)} + +/* ── Main ── */ +main{max-width:960px;margin:0 auto;padding:32px 24px 80px} + +/* ── Search ── */ +.search-box{ + display:flex;gap:8px;margin-bottom:40px; + background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius); + padding:4px;transition:border-color .2s +} +.search-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,.15)} +.search-box input{ + flex:1;background:transparent;border:none;color:var(--text);font-family:var(--font-mono); + font-size:.9em;outline:none;padding:10px 14px +} +.search-box input::placeholder{color:var(--text2);opacity:.6} +.search-box button{ + background:var(--accent);color:#fff;border:none;padding:8px 20px;border-radius:8px; + font-size:.8em;font-weight:600;cursor:pointer;transition:opacity .15s;font-family:inherit +} +.search-box button:hover{opacity:.85} +.search-result-msg{color:var(--amber);font-size:.85em;margin-top:8px;display:none} + +/* ── Sections ── */ +section{margin-bottom:56px;scroll-margin-top:130px} +h2{ + font-size:1.35em;font-weight:700;color:#fff;margin-bottom:20px;letter-spacing:-.01em; + display:flex;align-items:center;gap:10px +} +h2::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,var(--border),transparent)} +h3{ + font-size:1.05em;font-weight:600;color:var(--text);margin:28px 0 12px; + display:flex;align-items:center;gap:8px +} +h4{font-size:.92em;font-weight:600;color:var(--accent2);margin:20px 0 8px} +p{margin-bottom:14px;color:var(--text);line-height:1.8} +ul,ol{margin-bottom:14px;padding-left:22px;color:var(--text)} +li{margin-bottom:6px} +a{color:var(--accent2);text-decoration:none;transition:color .15s} +a:hover{color:#fff} + +/* ── Tables ── */ +.table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:16px 0;border-radius:var(--radius);border:1px solid var(--border)} +table{width:100%;border-collapse:collapse;font-size:.88em} +th,td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--border)} +th{background:var(--bg3);color:var(--accent2);font-weight:600;font-size:.82em;text-transform:uppercase;letter-spacing:.04em} +tr:last-child td{border-bottom:none} +tr:hover td{background:rgba(124,92,252,.04)} +code{ + font-family:var(--font-mono);font-size:.88em;padding:2px 7px;border-radius:5px; + background:rgba(124,92,252,.12);color:var(--accent2);border:1px solid rgba(124,92,252,.15) +} +pre code{background:transparent;border:none;padding:0;color:var(--text);font-size:.88em} + +/* ── Pre / Code Blocks ── */ +pre{ + background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius); + padding:16px 20px;overflow-x:auto;margin:16px 0;font-family:var(--font-mono);font-size:.85em;line-height:1.6 +} +pre .cmt{color:var(--text2);opacity:.6} /* comment style */ + +/* ── Callouts ── */ +.callout{ + padding:16px 20px;border-radius:var(--radius);margin:16px 0; + border-left:3px solid;position:relative +} +.callout::before{content:'';position:absolute;inset:0;border-radius:var(--radius);opacity:.06;pointer-events:none} +.callout-success{border-color:var(--green)}.callout-success::before{background:var(--green)} +.callout-info{border-color:var(--cyan)}.callout-info::before{background:var(--cyan)} +.callout-warning{border-color:var(--amber)}.callout-warning::before{background:var(--amber)} +.callout-error{border-color:var(--rose)}.callout-error::before{background:var(--rose)} +.callout-ollama{border-color:var(--pink)}.callout-ollama::before{background:var(--pink)} +.callout-title{font-weight:700;margin-bottom:6px;display:block;font-size:.92em} + +/* ── Feature Grid ── */ +.feature-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin:16px 0} +.feature-card{ + background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius); + padding:16px;transition:border-color .2s,transform .15s +} +.feature-card:hover{border-color:rgba(124,92,252,.4);transform:translateY(-1px)} +.feature-card h4{margin:0 0 6px;font-size:.88em} +.feature-card p{font-size:.82em;color:var(--text2);margin:0;line-height:1.5} + +/* ── Quick Ref ── */ +.quick-ref{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin:16px 0} +.quick-ref-title{color:var(--accent2);font-weight:700;text-align:center;margin-bottom:16px;font-size:.95em;letter-spacing:.02em;text-transform:uppercase} +.quick-ref-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px} +.quick-ref-item{background:var(--bg3);padding:10px 12px;border-radius:8px;border:1px solid var(--border)} +.quick-ref-item .cmd{color:var(--accent2);font-family:var(--font-mono);font-weight:600;font-size:.85em;display:block} +.quick-ref-item .desc{color:var(--text2);font-size:.78em;margin-top:3px} + +/* ── Version Items ── */ +.version-item{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:12px 0;transition:border-color .2s} +.version-item:hover{border-color:rgba(124,92,252,.3)} +.version-item .v-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:6px} +.version-item .v-num{color:var(--accent2);font-weight:700;font-size:.95em} +.version-item .v-date{color:var(--text2);font-size:.82em} +.version-item ul{margin:0;padding-left:18px} +.version-item li{margin-bottom:4px;font-size:.88em;color:var(--text)} + +/* ── Footer ── */ +footer{ + text-align:center;padding:32px 24px;border-top:1px solid var(--border); + color:var(--text2);font-size:.82em +} +footer .links{display:flex;justify-content:center;gap:20px;margin-bottom:14px;flex-wrap:wrap} +footer .links a{color:var(--text);font-weight:500} +footer .links a:hover{color:var(--accent2)} +footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500} + +/* ── Back to Top ── */ +.back-top{ + position:fixed;bottom:24px;right:24px; + background:var(--bg3);border:1px solid var(--border);color:var(--text2); + width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center; + font-size:18px;cursor:pointer;opacity:0;pointer-events:none;transition:all .2s;z-index:50; + backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);text-decoration:none +} +.back-top.visible{opacity:1;pointer-events:auto} +.back-top:hover{background:var(--accent);color:#fff;border-color:var(--accent);transform:translateY(-2px)} + +/* ── Responsive ── */ +@media(max-width:600px){ + .header-inner{padding:16px 16px 12px} + .header-meta{display:none} + .nav-inner{padding:6px 12px;gap:2px} + .nav-link{padding:5px 10px;font-size:.74em} + main{padding:20px 16px 60px} + h2{font-size:1.15em} + .feature-grid{grid-template-columns:1fr} + .quick-ref-grid{grid-template-columns:1fr 1fr} + pre{font-size:.8em;padding:12px 14px} + th,td{padding:8px 10px;font-size:.82em} +} +@media(max-width:400px){ + .quick-ref-grid{grid-template-columns:1fr} + .badge-new{display:none} +} +</style> +</head> +<body> + +<header> + <div class="header-inner"> + <div class="header-left"> + <div class="header-logo">⚡</div> + <h1>WebX11 <span>Docs</span></h1> + </div> + <div class="header-meta"> + <span class="badge badge-v">v4.2</span> + <span class="badge badge-new">24 core · 12 ext</span> + <span style="color:var(--text2)">110+ commands</span> + </div> + </div> +</header> + +<nav> + <div class="nav-inner"> + <a href="#overview" class="nav-link">Overview</a> + <a href="#start" class="nav-link">Quick Start</a> + <a href="#commands" class="nav-link">Commands</a> + <a href="#filesystem" class="nav-link">Filesystem</a> + <a href="#ai" class="nav-link">AI Agent</a> + <a href="#skills" class="nav-link">Skills</a> + <a href="#ollama" class="nav-link">Ollama</a> + <a href="#web" class="nav-link">Search & Web</a> + <a href="#voice" class="nav-link">Voice & Sound</a> + <a href="#config" class="nav-link">Config</a> + <a href="#modules" class="nav-link">Modules</a> + <a href="#extras" class="nav-link">Extras</a> + <a href="#sessions" class="nav-link">Sessions</a> + <a href="#storage" class="nav-link">Storage</a> + <a href="#security" class="nav-link">Security</a> + <a href="#trouble" class="nav-link">Troubleshoot</a> + <a href="#ref" class="nav-link">Quick Ref</a> + <a href="#history" class="nav-link">Changelog</a> + </div> +</nav> + +<main> + <div class="search-box"> + <input type="text" id="search-input" placeholder="Search docs…" spellcheck="false"> + <button onclick="searchDocs()">Search</button> + </div> + <div class="search-result-msg" id="search-result-msg"></div> + + <!-- ── Overview ── --> + <section id="overview"> + <h2>⛅ Overview</h2> + <p>WebX11 v4.2 is a browser-based Unix-like terminal — dynamic modules, AI assistant (Ollama), persistent virtual filesystem, web search, voice input, code editor, and a full skill system. Everything runs client-side in your browser.</p> + <div class="feature-grid"> + <div class="feature-card"><h4>🧩 24 Core + 12 Ext</h4><p>Modules loaded at startup. Load new ones from any URL at runtime.</p></div> + <div class="feature-card"><h4>🤖 Agent with Tools</h4><p>15+ tools — file ops, web search, skills, modules. Destructive action confirmations with inline bar + countdown.</p></div> + <div class="feature-card"><h4>📚 Skills System</h4><p>Persistent procedural memory. Create, patch, search, export skills for repeatable workflows.</p></div> + <div class="feature-card"><h4>💾 VFS + Sessions</h4><p>Virtual filesystem persists in localStorage. Save/load/export sessions.</p></div> + <div class="feature-card"><h4>🎤 Voice Input</h4><p>Speech recognition in the terminal. Works in Chrome, Edge, Safari.</p></div> + <div class="feature-card"><h4>🌐 Web Search</h4><p>SearXNG + DuckDuckGo fallback + URL fetch/extract. All built-in.</p></div> + <div class="feature-card"><h4>☁️ WebDAV Sync</h4><p>Push, pull, backup VFS to any WebDAV server.</p></div> + <div class="feature-card"><h4>📱 Mobile First</h4><p>Touch-optimized. <code>srcdoc</code> HTML preview works on mobile.</p></div> + <div class="feature-card"><h4>✏️ Text Editor</h4><p>Inline VFS editor — <code>edit <path></code> opens a modal with save/cancel.</p></div> + <div class="feature-card"><h4>🔗 Aliases</h4><p><code>ll</code>, <code>h</code>, <code>cls</code> built-in. Define custom aliases with <code>alias x=y</code>.</p></div> + </div> + </section> + + <!-- ── Quick Start ── --> + <section id="start"> + <h2>🚀 Quick Start</h2> + <ol style="margin-bottom:8px"> + <li><strong>Open</strong> <code>index.html</code> in Chrome/Edge</li> + <li>Watch the splash screen load modules</li> + <li>Type <code>help</code> + Enter</li> + <li>Use the quick-action buttons above the input area</li> + </ol> + + <h3>First Commands</h3> + <pre><span class="cmt"># List VFS files</span> +ls + +<span class="cmt"># List files faster</span> +ll + +<span class="cmt"># Connect to Ollama AI</span> +ollamaconnect + +<span class="cmt"># Chat with AI</span> +ai what's in this directory? + +<span class="cmt"># Create a file</span> +put hello.txt Hello World + +<span class="cmt"># Edit a file</span> +edit hello.txt + +<span class="cmt"># Search the web</span> +search quantum computing + +<span class="cmt"># Find files</span> +find hello + +<span class="cmt"># Compare two files</span> +diff a.txt b.txt</pre> + + <div class="callout callout-info"> + <span class="callout-title">⌨️ Keyboard Shortcuts</span> + <ul style="margin:0"> + <li><strong>Enter</strong> — Execute</li> + <li><strong>Tab</strong> — Cycle autocomplete</li> + <li><strong>↑/↓</strong> — Command history</li> + <li><strong>Esc</strong> — Hide autocomplete / stop AI</li> + <li><strong>|</strong> — Pipe: <code>ls | grep hello</code></li> + </ul> + </div> + </section> + + <!-- ── Commands ── --> + <section id="commands"> + <h2>📖 Full Command Reference</h2> + + <h3>System</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>help</code></td><td>Show all commands</td><td><code>help</code></td></tr> + <tr><td><code>clear</code> / <code>cls</code></td><td>Clear terminal</td><td><code>cls</code></td></tr> + <tr><td><code>echo</code></td><td>Print text</td><td><code>echo Hello</code></td></tr> + <tr><td><code>mem</code></td><td>Show memory usage</td><td><code>mem</code></td></tr> + <tr><td><code>status</code></td><td>System status</td><td><code>status</code></td></tr> + <tr><td><code>font-size</code></td><td>Change font size (8–32)</td><td><code>font-size 16</code></td></tr> + <tr><td><code>load-local</code></td><td>Load module from VFS or URL</td><td><code>load-local modules/ext/calc.js</code></td></tr> + <tr><td><code>startup-modules</code></td><td>Open startup module selector</td><td><code>startup-modules</code></td></tr> + <tr><td><code>preview</code></td><td>Preview HTML in iframe</td><td><code>preview test.html</code></td></tr> + <tr><td><code>factory-reset</code></td><td>Reset config</td><td><code>factory-reset</code></td></tr> + <tr><td><code>reset-vfs</code></td><td>Reset VFS</td><td><code>reset-vfs</code></td></tr> + </table></div> + + <h3>AI Assistant</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>ai</code> / <code>ask</code></td><td>Chat with AI (tool-calling)</td><td><code>ai create a notes file</code></td></tr> + <tr><td><code>ai-clear</code></td><td>Reset conversation context</td><td><code>ai-clear</code></td></tr> + <tr><td><code>ai-system</code></td><td>View/set system prompt</td><td><code>ai-system</code></td></tr> + <tr><td><code>ai-context</code></td><td>Show conversation memory</td><td><code>ai-context</code></td></tr> + <tr><td><code>ai-allow</code></td><td>Allow pending destructive action</td><td><code>ai-allow</code></td></tr> + <tr><td><code>ai-deny</code></td><td>Deny pending destructive action</td><td><code>ai-deny</code></td></tr> + <tr><td><code>ai-stop</code></td><td>Cancel running AI request</td><td><code>ai-stop</code></td></tr> + </table></div> + + <h3>Smart Context</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>context</code></td><td>Set active profile</td><td><code>context expert</code></td></tr> + <tr><td><code>contexts</code></td><td>List profiles</td><td><code>contexts</code></td></tr> + <tr><td><code>context-new</code></td><td>Create profile</td><td><code>context-new myprofile</code></td></tr> + <tr><td><code>context-delete</code></td><td>Delete profile</td><td><code>context-delete old</code></td></tr> + <tr><td><code>smart-ask</code></td><td>Ask with current profile</td><td><code>smart-ask explain this</code></td></tr> + </table></div> + + <h3>Dynamic Prompts</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>prompt</code></td><td>Use saved template</td><td><code>prompt summarize</code></td></tr> + <tr><td><code>prompt-save</code></td><td>Save template</td><td><code>prompt-save myprompt</code></td></tr> + <tr><td><code>prompt-list</code></td><td>List saved prompts</td><td><code>prompt-list</code></td></tr> + <tr><td><code>prompt-delete</code></td><td>Delete prompt</td><td><code>prompt-delete old</code></td></tr> + <tr><td><code>prompt-var</code></td><td>Set template variable</td><td><code>prompt-var name Alice</code></td></tr> + <tr><td><code>prompt-vars</code></td><td>List variables</td><td><code>prompt-vars</code></td></tr> + </table></div> + + <h3>Skills</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>skill-list</code></td><td>List all skills</td><td><code>skill-list</code></td></tr> + <tr><td><code>skill-view</code></td><td>View skill content</td><td><code>skill-view web-scraper</code></td></tr> + <tr><td><code>skill-create</code></td><td>Create a skill</td><td><code>skill-create name desc "steps" tags</code></td></tr> + <tr><td><code>skill-patch</code></td><td>Update a skill</td><td><code>skill-patch name "new content"</code></td></tr> + <tr><td><code>skill-delete</code></td><td>Delete a skill</td><td><code>skill-delete oldskill</code></td></tr> + <tr><td><code>skill-export</code></td><td>Export skill to JSON</td><td><code>skill-export myskill</code></td></tr> + <tr><td><code>skill-import</code></td><td>Import skill from JSON</td><td><code>skill-import</code></td></tr> + <tr><td><code>skill-sync</code></td><td>Rebuild skill index</td><td><code>skill-sync</code></td></tr> + </table></div> + + <h3>Config</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>config</code></td><td>Get a config value</td><td><code>config</code></td></tr> + <tr><td><code>config-set</code></td><td>Set a config value</td><td><code>config-set key value</code></td></tr> + <tr><td><code>config-list</code></td><td>List all config</td><td><code>config-list</code></td></tr> + <tr><td><code>config-delete</code></td><td>Delete a config entry</td><td><code>config-delete key</code></td></tr> + <tr><td><code>config-export</code></td><td>Export config as JSON</td><td><code>config-export</code></td></tr> + <tr><td><code>config-reset</code></td><td>Reset all config</td><td><code>config-reset</code></td></tr> + </table></div> + </section> + + <!-- ── Filesystem ── --> + <section id="filesystem"> + <h2>📁 Filesystem</h2> + <p>All VFS files persist in <code>localStorage</code>. <code>ls</code>, <code>cd</code>, <code>pwd</code> work like a real terminal. Clickable output — tap a file to <code>cat</code>, tap a dir to <code>cd</code>.</p> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>ls</code> / <code>ll</code></td><td>List files</td><td><code>ll</code></td></tr> + <tr><td><code>cd</code></td><td>Change directory</td><td><code>cd notes</code></td></tr> + <tr><td><code>pwd</code></td><td>Print working directory</td><td><code>pwd</code></td></tr> + <tr><td><code>cat</code></td><td>View file</td><td><code>cat readme.txt</code></td></tr> + <tr><td><code>put</code></td><td>Create file with content</td><td><code>put hello.txt Hello World</code></td></tr> + <tr><td><code>edit</code></td><td>Open in modal editor</td><td><code>edit notes.md</code></td></tr> + <tr><td><code>rm</code></td><td>Delete file</td><td><code>rm temp.txt</code></td></tr> + <tr><td><code>mkdir</code></td><td>Create directory</td><td><code>mkdir projects</code></td></tr> + <tr><td><code>rmdir</code></td><td>Remove directory</td><td><code>rmdir empty</code></td></tr> + <tr><td><code>mv</code></td><td>Move/rename</td><td><code>mv old.txt new.txt</code></td></tr> + <tr><td><code>cp</code></td><td>Copy file</td><td><code>cp a.txt b.txt</code></td></tr> + <tr><td><code>touch</code></td><td>Create empty file</td><td><code>touch newfile.txt</code></td></tr> + <tr><td><code>tree</code></td><td>Directory tree</td><td><code>tree</code></td></tr> + <tr><td><code>find</code></td><td>Recursive file search</td><td><code>find .txt</code></td></tr> + <tr><td><code>diff</code></td><td>Compare two files</td><td><code>diff a.txt b.txt</code></td></tr> + <tr><td><code>grep</code></td><td>Search file contents</td><td><code>grep hello *.txt</code></td></tr> + <tr><td><code>head</code></td><td>First N lines</td><td><code>head file.txt 10</code></td></tr> + <tr><td><code>tail</code></td><td>Last N lines</td><td><code>tail file.txt 10</code></td></tr> + <tr><td><code>sort</code></td><td>Sort file lines</td><td><code>sort file.txt</code></td></tr> + <tr><td><code>wc</code></td><td>Count lines/words/chars</td><td><code>wc file.txt</code></td></tr> + <tr><td><code>du</code></td><td>Disk usage</td><td><code>du</code></td></tr> + <tr><td><code>upload</code></td><td>Upload file from device</td><td><code>upload</code></td></tr> + <tr><td><code>download</code></td><td>Download file</td><td><code>download file.txt</code></td></tr> + <tr><td><code>vfs-export</code></td><td>Download VFS as JSON</td><td><code>vfs-export</code></td></tr> + <tr><td><code>vfs-import</code></td><td>Import VFS from JSON</td><td><code>vfs-import</code></td></tr> + </table></div> + </section> + + <!-- ── AI Agent ── --> + <section id="ai"> + <h2>🤖 AI Assistant</h2> + <div class="callout callout-success"> + <span class="callout-title">Tool-Calling Agent</span> + The AI runs a full agent loop with 15+ tools: filesystem, web search, URL fetch, skills, WPM, session memory, user profile, module introspection. + </div> + + <h3>How It Works</h3> + <ol> + <li>Type <code>ai <request></code></li> + <li>AI loads relevant skills + recent session memory</li> + <li>Decides which tools to call (up to 5 turns)</li> + <li><strong>Destructive tools</strong> trigger an inline confirm bar — no dark overlay</li> + <li>120s countdown: yellow → bold red at 30s → pulsing at 10s</li> + <li>Type <code>ai-allow</code> / <code>ai-deny</code> or tap the buttons</li> + <li>After complex tasks, AI suggests creating a skill</li> + </ol> + + <div class="callout callout-warning"> + <span class="callout-title">⚠️ Destructive Tools</span> + <code>write_file</code>, <code>delete_file</code>, <code>run_command</code>, <code>make_directory</code>, <code>edit_module</code> all require confirmation before executing. + </div> + + <h3>Available Tools</h3> + <div class="table-wrap"><table> + <tr><th>Tool</th><th>Description</th><th>Destructive?</th></tr> + <tr><td><code>list_files</code></td><td>List VFS directory</td><td>❌</td></tr> + <tr><td><code>read_file</code></td><td>Read file (8k trunc)</td><td>❌</td></tr> + <tr><td><code>write_file</code></td><td>Write to file</td><td>✅</td></tr> + <tr><td><code>make_directory</code></td><td>Create directory</td><td>✅</td></tr> + <tr><td><code>delete_file</code></td><td>Delete file/dir</td><td>✅</td></tr> + <tr><td><code>get_status</code></td><td>System status</td><td>❌</td></tr> + <tr><td><code>get_help</code></td><td>List commands</td><td>❌</td></tr> + <tr><td><code>web_search</code></td><td>SearXNG + DDG</td><td>❌</td></tr> + <tr><td><code>web_fetch</code></td><td>Extract URL content</td><td>❌</td></tr> + <tr><td><code>run_command</code></td><td>Execute terminal cmd</td><td>✅</td></tr> + <tr><td><code>load_skills</code></td><td>Search relevant skills</td><td>❌</td></tr> + <tr><td><code>create/patch/list_skills</code></td><td>Skill management</td><td>❌</td></tr> + <tr><td><code>view/edit/test/reload</code></td><td>Module operations</td><td>edit ✅</td></tr> + <tr><td><code>user_get/set/list/forget</code></td><td>User profile</td><td>❌</td></tr> + <tr><td><code>session_save/load/recent/stats</code></td><td>Session memory</td><td>❌</td></tr> + </table></div> + + <h3>Sound Notification</h3> + <p>A short <strong>"ding"</strong> plays via WebAudio when AI finishes. Toggle with the <strong>🔊 Sound</strong> button in quick-actions. Persists in localStorage.</p> + </section> + + <!-- ── Skills ── --> + <section id="skills"> + <h2>📚 Skills System</h2> + <div class="callout callout-success"> + <span class="callout-title">Procedural Memory</span> + Skills are reusable markdown procedures stored in VFS <code>/skills/</code>. The AI auto-loads relevant skills at task start. Create skills after complex workflows (3+ tool calls) to save the approach. + </div> + + <h3>Format</h3> + <pre>--- +name: web-scrape-and-summarize +description: Scrape a URL and summarize +tags: [web, research] +category: general +--- +## Steps +1. Use web_fetch(url) to get content +2. Present a concise summary +3. Save with create_skill</pre> + + <h3>Commands</h3> + <pre><span class="cmt"># List all skills</span> +skill-list + +<span class="cmt"># Create a skill</span> +skill-create my-skill "Does X" "Steps to do X" "tag1,tag2" + +<span class="cmt"># View / patch / delete</span> +skill-view my-skill +skill-patch my-skill "Updated steps" +skill-delete my-skill + +<span class="cmt"># Export / Import</span> +skill-export my-skill +skill-import</pre> + + <h3>Auto-Creation</h3> + <p>When the AI encounters the same tool error 3+ times, a workaround skill is auto-created. Every 20 messages, skills are audited for staleness.</p> + </section> + + <!-- ── Ollama ── --> + <section id="ollama"> + <h2>🤖 Ollama</h2> + <div class="callout callout-ollama"> + <span class="callout-title">Local or Remote AI</span> + WebX11 connects to any Ollama instance. All tool-calling and chat features require a connection. + </div> + + <h3>Setup</h3> + <pre><span class="cmt"># Install & pull a model</span> +curl -fsSL https://ollama.com/install.sh | sh +ollama pull llama2 + +<span class="cmt"># Start Ollama</span> +ollama serve + +<span class="cmt"># In WebX11</span> +ollamaconnect</pre> + + <h3>Commands</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th></tr> + <tr><td><code>ollamaconnect</code></td><td>Connect to Ollama</td></tr> + <tr><td><code>ollamasetproxy</code></td><td>Set remote endpoint</td></tr> + <tr><td><code>ollamamodels</code></td><td>List models</td></tr> + <tr><td><code>ollamasetmodel</code></td><td>Set active model</td></tr> + <tr><td><code>ollamadisconnect</code></td><td>Disconnect</td></tr> + <tr><td><code>ollama</code></td><td>Open Ollama Manager UI</td></tr> + <tr><td><code>models</code></td><td>List all models</td></tr> + <tr><td><code>model-info</code></td><td>Model details</td></tr> + <tr><td><code>model-pull</code></td><td>Pull a model</td></tr> + <tr><td><code>model-delete</code></td><td>Delete a model</td></tr> + <tr><td><code>model-run</code></td><td>Run model with prompt</td></tr> + </table></div> + + <div class="callout callout-warning"> + <span class="callout-title">🔌 CORS for Remote</span> + <code>OLLAMA_ORIGINS="*" ollama serve</code> + </div> + </section> + + <!-- ── Search & Web ── --> + <section id="web"> + <h2>🌐 Search & Web</h2> + <div class="callout callout-info"> + <span class="callout-title">SearXNG + DDG + URL Fetcher</span> + Three modules provide comprehensive web capabilities via the proxy API. + </div> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Module</th><th>Description</th><th>Example</th></tr> + <tr><td><code>search</code> / <code>s</code></td><td>SearXNG</td><td>Web search</td><td><code>search raspberry pi</code></td></tr> + <tr><td><code>ddg</code> / <code>duck</code></td><td>DDG</td><td>DuckDuckGo fallback</td><td><code>ddg news today</code></td></tr> + <tr><td><code>fetch</code> / <code>web</code></td><td>Fetcher</td><td>Fetch URL content</td><td><code>fetch https://example.com</code></td></tr> + <tr><td><code>extract</code></td><td>Fetcher</td><td>Clean readable text</td><td><code>extract https://news.article</code></td></tr> + </table></div> + <p>The AI agent also has <code>web_search</code> and <code>web_fetch</code> tools using the same APIs.</p> + </section> + + <!-- ── Voice & Sound ── --> + <section id="voice"> + <h2>🎤 Voice & Sound</h2> + <div class="callout callout-success"> + <span class="callout-title">Speech Recognition</span> + A <strong>🎤 button</strong> in the input area activates SpeechRecognition. Chrome, Edge, Safari 14.1+. Not Firefox. + </div> + <ul> + <li>Tap 🎤 to start listening — button pulses green</li> + <li>Say your command — recognized text fills the input</li> + <li>Tap again or wait to stop</li> + <li>Red flash indicates failure</li> + </ul> + <h3>Sound</h3> + <p>Toggle AI response "ding" with the <strong>🔊 Sound</strong> button. Persists in localStorage.</p> + </section> + + <!-- ── Config ── --> + <section id="config"> + <h2>⚙️ Configuration</h2> + <p>All config values stored as <code>webx11_config_*</code> in localStorage. Use the ⚙ Config button or CLI commands.</p> + <h3>Notable Keys</h3> + <div class="table-wrap"><table> + <tr><th>Key</th><th>Description</th><th>Values</th></tr> + <tr><td><code>webx11_config_ai_sound</code></td><td>AI sound toggle</td><td><code>on</code>, <code>off</code></td></tr> + <tr><td><code>webx11_config_voice_visible</code></td><td>Mic button visibility</td><td><code>on</code>, <code>off</code></td></tr> + </table></div> + <h3>Persisted State</h3> + <div class="table-wrap"><table> + <tr><th>Key</th><th>Description</th></tr> + <tr><td><code>webx11_filesystem</code></td><td>VFS JSON</td></tr> + <tr><td><code>webx11_modules</code></td><td>Loaded module URLs</td></tr> + <tr><td><code>webx11_ollama_endpoint</code></td><td>Ollama API URL</td></tr> + <tr><td><code>webx11_ollama_model</code></td><td>Selected model</td></tr> + <tr><td><code>webx11_startup_modules</code></td><td>Startup selection</td></tr> + <tr><td><code>webx11_font_size</code></td><td>Font size (8–32)</td></tr> + <tr><td><code>webx11_command_history</code></td><td>Command history</td></tr> + <tr><td><code>webx11_terminal_output</code></td><td>Persisted output</td></tr> + <tr><td><code>webx11_ai_context</code></td><td>AI conversation history</td></tr> + <tr><td><code>webx11_user_profile</code></td><td>AI user profile</td></tr> + <tr><td><code>webx11_aliases</code></td><td>User-defined aliases</td></tr> + </table></div> + </section> + + <!-- ── Modules ── --> + <section id="modules"> + <h2>🧩 Module System</h2> + <div class="callout callout-info"> + <span class="callout-title">24 Core + 12 Ext</span> + Modules loaded in dependency order at startup. Toggle ext modules with ⚙ gear on splash. + </div> + <h3>Commands</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th></tr> + <tr><td><code>modules</code></td><td>Open Module Manager UI</td></tr> + <tr><td><code>loadmod <url></code></td><td>Load module from URL</td></tr> + <tr><td><code>removemod <name></code></td><td>Remove a module</td></tr> + <tr><td><code>listmods</code></td><td>List all loaded modules</td></tr> + </table></div> + + <h3>Boot Order</h3> + <ol> + <li>Splash screen with ⚙ gear</li> + <li>Foundation: <code>virtual-fs.js</code> → <code>module-manager.js</code> → <code>ollama-manager.js</code> → <code>terminal.js</code></li> + <li>Built-in modules loaded async</li> + <li>Ext modules loaded if selected</li> + <li>Splash hides, terminal ready</li> + </ol> + + <h3>Code Editor</h3> + <p>The AI can <code>edit_module(name)</code> to copy source into the Code Editor modal. Edit inline, then <strong>💾 Save & Test</strong> to load live from <code>/_mods/</code>. Use <code>test_module</code> without reloading the page.</p> + </section> + + <!-- ── Extras ── --> + <section id="extras"> + <h2>🧰 Extended Modules</h2> + + <h3>WebDAV Sync</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th></tr> + <tr><td><code>webdav-config</code></td><td>Configure server</td></tr> + <tr><td><code>webdav-status</code></td><td>Connection status</td></tr> + <tr><td><code>webdav-ls</code></td><td>List remote dir</td></tr> + <tr><td><code>webdav-push</code></td><td>Push file</td></tr> + <tr><td><code>webdav-pull</code></td><td>Pull file</td></tr> + <tr><td><code>webdav-backup</code></td><td>Backup VFS</td></tr> + <tr><td><code>webdav-restore</code></td><td>Restore VFS</td></tr> + <tr><td><code>webdav-backup-list</code></td><td>List backups</td></tr> + </table></div> + + <h3>Calculator & Cron</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Module</th><th>Description</th></tr> + <tr><td><code>calc</code></td><td>calc.js</td><td>Evaluate math / unit conversion</td></tr> + <tr><td><code>cron-every</code></td><td>cron.js</td><td>Schedule a command</td></tr> + <tr><td><code>cron-list</code></td><td>cron.js</td><td>List scheduled jobs</td></tr> + <tr><td><code>cron-stop</code></td><td>cron.js</td><td>Stop a job</td></tr> + <tr><td><code>cron-clear</code></td><td>cron.js</td><td>Clear all jobs</td></tr> + </table></div> + + <h3>Theme, WPM & Mindmap</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Module</th><th>Description</th></tr> + <tr><td><code>theme</code></td><td>theme.js</td><td>Set color theme (dark, light, amber, cyber, …)</td></tr> + <tr><td><code>wpm</code></td><td>wpm.js</td><td>Package manager</td></tr> + <tr><td><code>mindmap</code></td><td>mindmap.js</td><td>Show file structure as mindmap</td></tr> + </table></div> + + <h3>Server Monitor</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th></tr> + <tr><td><code>server</code></td><td>Add server to monitor</td></tr> + <tr><td><code>servers</code></td><td>List monitored servers</td></tr> + <tr><td><code>server-check</code></td><td>Ping a server</td></tr> + <tr><td><code>server-remove</code></td><td>Remove server</td></tr> + <tr><td><code>monitor</code></td><td>Toggle auto-monitoring</td></tr> + </table></div> + + <h3>IO / Backup</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th></tr> + <tr><td><code>export-mods</code></td><td>Export module configs</td></tr> + <tr><td><code>import-mods</code></td><td>Import module configs</td></tr> + <tr><td><code>backup</code></td><td>Full system backup</td></tr> + <tr><td><code>restore</code></td><td>Restore from backup</td></tr> + <tr><td><code>share-mod</code></td><td>Share module as text</td></tr> + </table></div> + + <h3>Aliases</h3> + <p>Built-in: <code>ll</code> = <code>ls</code>, <code>h</code> = <code>help</code>, <code>cls</code> = <code>clear</code>. Define custom: <code>alias x=ls -la</code>. Remove: <code>unalias x</code>. Persists in localStorage.</p> + </section> + + <!-- ── Sessions ── --> + <section id="sessions"> + <h2>💬 Sessions</h2> + <p>Sessions persist AI conversation logs in VFS <code>/memory/</code>. The AI auto-loads recent sessions at start for continuity.</p> + <h3>AI Tools</h3> + <div class="table-wrap"><table> + <tr><th>Tool</th><th>Description</th></tr> + <tr><td><code>session_save</code></td><td>Save summary</td></tr> + <tr><td><code>session_load</code></td><td>Load by date</td></tr> + <tr><td><code>session_recent</code></td><td>Show last N</td></tr> + <tr><td><code>session_stats</code></td><td>Aggregate statistics</td></tr> + </table></div> + <p>The terminal auto-saves session state every 60s and auto-restores on page reload.</p> + </section> + + <!-- ── Storage ── --> + <section id="storage"> + <h2>💾 Storage</h2> + <div class="callout callout-success"> + <span class="callout-title">localStorage Persistence</span> + Everything survives hard refreshes via 15+ localStorage keys. + </div> + <pre><span class="cmt"># Clear all WebX11 data</span> +localStorage.clear() +location.reload() + +<span class="cmt"># Back up / restore VFS</span> +vfs-export +vfs-import</pre> + <p>~5–10MB per domain. Only URLs (not code) are stored for modules.</p> + </section> + + <!-- ── Security ── --> + <section id="security"> + <h2>🔒 Security</h2> + <div class="table-wrap"><table> + <tr><th>Feature</th><th>Status</th></tr> + <tr><td>Input Validation</td><td>✅</td></tr> + <tr><td>Output Escaping</td><td>✅</td></tr> + <tr><td>No eval() on User Input</td><td>✅</td></tr> + <tr><td>Path Traversal Prevention</td><td>✅</td></tr> + <tr><td>Sandboxed File System</td><td>✅</td></tr> + <tr><td>AI Destructive Confirmations</td><td>✅ Required + timeout</td></tr> + <tr><td>Iframe Preview Sandbox</td><td>✅ allow-same-origin allow-scripts</td></tr> + </table></div> + <div class="callout callout-warning"> + <span class="callout-title">⚠️ Module Loading</span> + Only load modules from trusted sources — they have full terminal access. + </div> + </section> + + <!-- ── Troubleshooting ── --> + <section id="trouble"> + <h2>🔧 Troubleshooting</h2> + <div class="table-wrap"><table> + <tr><th>Issue</th><th>Solution</th></tr> + <tr><td>AI not responding</td><td>Run <code>ollamaconnect</code> first, ensure Ollama is running</td></tr> + <tr><td>Voice not working</td><td>Chrome/Edge/Safari only. Grant mic permission</td></tr> + <tr><td>Module fails to load</td><td>Check CORS on the hosting server</td></tr> + <tr><td>Data lost after refresh</td><td>Check localStorage is enabled. Private mode clears data</td></tr> + <tr><td>AI actions not confirming</td><td>Confirm bar auto-denies after 120s. Type <code>ai-allow</code></td></tr> + <tr><td>Sound not playing</td><td>Browser may need first user gesture. Toggle off/on</td></tr> + <tr><td>Ollama CORS error</td><td><code>OLLAMA_ORIGINS="*" ollama serve</code></td></tr> + </table></div> + <h3>Debug</h3> + <pre><span class="cmt"># Console</span> +F12 → Console + +<span class="cmt"># View AI state</span> +ai-system +ai-context + +<span class="cmt"># Module status</span> +listmods</pre> + </section> + + <!-- ── Quick Ref ── --> + <section id="ref"> + <h2>⚡ Quick Reference</h2> + <div class="quick-ref"> + <div class="quick-ref-title">WebX11 v4.2</div> + <div class="quick-ref-grid"> + <div class="quick-ref-item"><span class="cmd">help</span><span class="desc">Show all commands</span></div> + <div class="quick-ref-item"><span class="cmd">cls</span><span class="desc">Clear terminal</span></div> + <div class="quick-ref-item"><span class="cmd">ll</span><span class="desc">List files</span></div> + <div class="quick-ref-item"><span class="cmd">cd <dir></span><span class="desc">Change directory</span></div> + <div class="quick-ref-item"><span class="cmd">cat <f></span><span class="desc">View file</span></div> + <div class="quick-ref-item"><span class="cmd">edit <f></span><span class="desc">Edit file</span></div> + <div class="quick-ref-item"><span class="cmd">ai <msg></span><span class="desc">Chat with AI</span></div> + <div class="quick-ref-item"><span class="cmd">search <q></span><span class="desc">Web search</span></div> + <div class="quick-ref-item"><span class="cmd">find <q></span><span class="desc">File search</span></div> + <div class="quick-ref-item"><span class="cmd">diff a b</span><span class="desc">Compare files</span></div> + <div class="quick-ref-item"><span class="cmd">ollamaconnect</span><span class="desc">Connect AI</span></div> + <div class="quick-ref-item"><span class="cmd">ai-allow</span><span class="desc">Allow destructive</span></div> + <div class="quick-ref-item"><span class="cmd">ai-deny</span><span class="desc">Deny destructive</span></div> + <div class="quick-ref-item"><span class="cmd">modules</span><span class="desc">Module Manager</span></div> + <div class="quick-ref-item"><span class="cmd">skill-list</span><span class="desc">List skills</span></div> + <div class="quick-ref-item"><span class="cmd">alias</span><span class="desc">List aliases</span></div> + <div class="quick-ref-item"><span class="cmd">preview</span><span class="desc">HTML preview</span></div> + <div class="quick-ref-item"><span class="cmd">upload</span><span class="desc">Upload file</span></div> + </div> + </div> + </section> + + <!-- ── Changelog ── --> + <section id="history"> + <h2>📋 Changelog</h2> + + <div class="version-item"> + <div class="v-header"><span class="v-num">v4.2 — May 2026</span><span class="v-date">2026-05-25</span></div> + <ul> + <li><strong>Editor</strong> — <code>edit <path></code> opens inline VFS editor modal</li> + <li><strong>Diff</strong> — <code>diff a b</code> LCS-based comparison + <code>diff --module</code></li> + <li><strong>Aliases</strong> — <code>ll</code>, <code>h</code>, <code>cls</code> defaults + custom alias definition</li> + <li><strong>File Finder</strong> — <code>find <query></code> recursive VFS search</li> + <li>Agent loop: capped at 5 turns (was 1), <code>edit_module</code> destructive confirmation, model auto-validation</li> + <li><code>ai-stop</code> now cancels chat + stream, not just tool calls</li> + <li>Unknown commands awaited, "Command not found" suppressed on AI route</li> + <li><code>loopResult</code> scope fix — no more undefined error on AI fail</li> + <li>Preview iframe allows JS (<code>allow-scripts</code>), cache bump <code>v=18</code></li> + <li>Splash fallback + startup modal list all 12 ext modules</li> + </ul> + </div> + + <div class="version-item"> + <div class="v-header"><span class="v-num">v4.1</span><span class="v-date">2026-05-23</span></div> + <ul> + <li>AI Agent with full tool-calling loop (15+ tools)</li> + <li>Inline destructive confirmation bar with 120s countdown timer</li> + <li>Skills system (8 CLI commands + UI modal)</li> + <li>Voice input (🎤 button, SpeechRecognition, show/hide toggle)</li> + <li>Sound notification (🔊 toggle, WebAudio ding)</li> + <li>AI spinner in status bar</li> + <li>Session system (save/load/recent/stats)</li> + <li>WebDAV sync (8 commands)</li> + <li>Web search (SearXNG + DDG + URL fetch/extract)</li> + <li>Config manager (6 commands + UI)</li> + <li>Startup modules selector (⚙ gear on splash)</li> + <li>Code editor modal (module editing, save & test live)</li> + <li>HTML preview via srcdoc (mobile)</li> + <li>File/dir autocomplete, pipe support</li> + <li>Smart context profiles, dynamic prompts with variables</li> + <li>Server monitor, ChatStudio overlay</li> + <li>Modular architecture: 23 JS modules, async loader</li> + </ul> + </div> + + <div class="version-item"> + <div class="v-header"><span class="v-num">v4.0</span><span class="v-date">2026-05-20</span></div> + <ul> + <li>Monolith refactored into core/ext modules</li> + <li>Async module loader with splash screen</li> + <li>Config manager modal</li> + <li>Bootloader fix for disabled modules</li> + </ul> + </div> + </section> +</main> + +<footer> + <div class="links"> + <a href="./index.html">Launch WebX11</a> + <a href="https://ollama.com" target="_blank">Ollama</a> + </div> + <p>WebX11 v4.2 · 2026</p> + <p class="tagline">Happy Hacking 🖥️🤖🎤</p> +</footer> + +<a href="#" class="back-top" id="backTop">↑</a> + +<script> +/* ── Back to Top ── */ +const bt = document.getElementById('backTop'); +window.addEventListener('scroll',()=>{bt.classList.toggle('visible',window.scrollY>300)}); + +/* ── Search ── */ +function searchDocs(){ + const q=document.getElementById('search-input').value.toLowerCase().trim(); + const msg=document.getElementById('search-result-msg'); + if(!q){msg.style.display='none';return} + const sections=document.querySelectorAll('section'); + let found=false; + sections.forEach(s=>{ + if(s.textContent.toLowerCase().includes(q)){ + s.scrollIntoView({behavior:'smooth'}); + s.style.border='2px solid var(--accent)';s.style.borderRadius='12px'; + s.style.padding='0 20px';s.style.transition='border .3s'; + setTimeout(()=>{s.style.border='none';s.style.padding='0'},2000); + found=true; + } + }); + if(!found){ + msg.textContent='No results for "'+q+'"'; + msg.style.display='block'; + setTimeout(()=>{msg.style.display='none'},3000); + }else{msg.style.display='none'} +} +document.getElementById('search-input').addEventListener('keypress',e=>{if(e.key==='Enter')searchDocs()}); + +/* ── Smooth scroll nav ── */ +document.querySelectorAll('.nav-link').forEach(l=>{ + l.addEventListener('click',e=>{ + e.preventDefault(); + const t=document.querySelector(l.getAttribute('href')); + if(t)t.scrollIntoView({behavior:'smooth'}); + }); +}); + +console.log('WebX11 v4.2 Docs — Modern'); +</script> +</body> +</html> diff --git a/index.html b/index.html new file mode 100644 index 0000000..f1f315e --- /dev/null +++ b/index.html @@ -0,0 +1,810 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <meta name="theme-color" content="#000000"> + <title>WebX11 Module System v4.2</title> + <style> + :root { + --bg-color: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #111111; + --text-color: #00ff00; + --text-muted: #888888; + --accent-color: #00aa00; + --border-color: #005500; + --error-color: #ff5555; + --warning-color: #ffaa00; + --info-color: #00aaff; + --success-color: #00ff00; + --ai-text-color: #aaddff; + --ai-bg-color: #001a26; + --font-primary: 'Courier New', Courier, monospace; + } + * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } + body { + background-color: var(--bg-color); + color: var(--text-color); + font-family: var(--font-primary); + font-size: 16px; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + @media (max-width: 600px) { body { font-size: 12px; } } + header { + border-bottom: 1px solid var(--border-color); + padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); + flex-shrink: 0; + } + .header-title { font-weight: bold; font-size: 1.1em; } + .header-version { font-size: 0.7em; color: var(--text-muted); } + .status-pill { + font-size: 0.8em; + padding: 2px 6px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-tertiary); + } + #terminal-container { + flex-grow: 1; + overflow-y: auto; + padding: 10px; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + } + #ai-confirm-area { + display: none; + flex-shrink: 0; + padding: 10px; + margin: 0 8px 4px; + border: 1px solid var(--warning-color); + border-radius: 6px; + background: var(--bg-secondary); + z-index: 200; + font-size: 0.95em; + min-height: 48px; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; + pointer-events: auto; + } + #ai-confirm-area.visible { display: flex; } + #ai-confirm-area.hidden { display: none !important; } + #ai-confirm-area .confirm-desc { + flex: 1; + min-width: 120px; + color: var(--warning-color); + word-break: break-word; + } + #ai-confirm-area .confirm-btn { + padding: 12px 24px; + border: 1px solid var(--success-color); + color: var(--success-color); + background: transparent; + border-radius: 6px; + cursor: pointer; + font-family: inherit; + font-size: 1.1em; + font-weight: bold; + touch-action: manipulation; + min-height: 48px; + min-width: 90px; + user-select: none; + -webkit-user-select: none; + transition: background 0.15s; + } + #ai-confirm-area .confirm-btn:active { + background: rgba(0, 255, 0, 0.15); + } + #ai-confirm-area .confirm-btn.deny { + border-color: var(--error-color); + color: var(--error-color); + } + #ai-confirm-area .confirm-timer { + font-size: 0.8em; + color: var(--warning-color); + flex-shrink: 0; + } + #ai-confirm-area .confirm-timer.urgent { + color: var(--error-color); + font-weight: bold; + } + #ai-confirm-area .confirm-timer.critical { + color: #ff0000; + font-weight: bold; + animation: pulse 1s infinite; + } + /* ── Startup Modules Modal ── */ + .output-line { white-space: pre-wrap; word-break: break-word; line-height: 1.4; } + .output-line.command-prompt { color: var(--text-color); font-weight: bold; } + .output-line.error { color: var(--error-color); } + .output-line.warning { color: var(--warning-color); } + .output-line.info { color: var(--info-color); } + .output-line.success { color: var(--success-color); } + .output-line.text-muted { color: var(--text-muted); } + .output-line.ollama { + color: var(--ai-text-color); + background: var(--ai-bg-color); + padding: 8px; + border-radius: 4px; + border: 1px solid #003344; + margin-bottom: 10px; + position: relative; + } + #input-area { + border-top: 1px solid var(--border-color); + padding: 8px 10px; + background: var(--bg-secondary); + display: flex; + align-items: flex-start; + flex-shrink: 0; + position: relative; + } + .prompt-label { margin-right: 8px; color: var(--accent-color); white-space: nowrap; margin-top: 6px; } + #command-input { + flex-grow: 1; + background: transparent; + border: none; + color: var(--text-color); + font-family: var(--font-primary); + font-size: inherit; + outline: none; + resize: none; + overflow-y: auto; + max-height: 120px; + line-height: 1.4; + padding: 4px 0; + } + #send-btn { + flex-shrink: 0; + min-width: 44px; + touch-action: manipulation; + margin-top: 2px; + } + #quick-actions { scrollbar-width: none; -ms-overflow-style: none; } + #quick-actions::-webkit-scrollbar { display: none; } + @media (max-width: 480px) { + #input-area { padding: 6px; } + #command-input { font-size: 0.9em; } + .prompt-label { font-size: 0.8em; max-width: 80px; overflow: hidden; text-overflow: ellipsis; } + } + #autocomplete-dropdown { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-bottom: none; + max-height: 200px; + overflow-y: auto; + display: none; + z-index: 100; + } + .autocomplete-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #111; + } + .autocomplete-item:hover, .autocomplete-item.selected { + background: var(--bg-tertiary); + color: var(--success-color); + } + .autocomplete-item .cmd-name { color: var(--text-color); font-weight: bold; } + .autocomplete-item .cmd-desc { color: var(--text-muted); font-size: 0.85em; } + #quick-actions { + display: flex; + gap: 8px; + padding: 8px 10px; + overflow-x: auto; + border-top: 1px solid #111; + background: var(--bg-tertiary); + flex-shrink: 0; + } + .qa-btn { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 8px 12px; + border-radius: 4px; + font-family: inherit; + font-size: 0.9em; + cursor: pointer; + white-space: nowrap; + touch-action: manipulation; + } + .qa-btn:active { background: var(--border-color); } + #status-bar { + border-top: 1px solid var(--border-color); + padding: 4px 10px; + font-size: 0.8em; + display: flex; + justify-content: space-between; + color: var(--text-muted); + flex-shrink: 0; + } + .modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.85); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + } + .modal-content { + background: var(--bg-color); + border: 1px solid var(--border-color); + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + border-radius: 4px; + } + .modal-header { + padding: 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + font-weight: bold; + } + .modal-body { padding: 10px; overflow-y: auto; flex-grow: 1; } + .modal-footer { + padding: 10px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 8px; + } + .btn { + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-color); + cursor: pointer; + border-radius: 3px; + } + .btn:hover { background: var(--bg-tertiary); } + .btn-danger { border-color: var(--error-color); color: var(--error-color); } + .file-item, .module-item, .model-item { + padding: 8px; + border-bottom: 1px solid #111; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + } + .file-item:hover, .module-item:hover, .model-item:hover { background: var(--bg-tertiary); } + .file-item.dir { color: var(--info-color); } + .module-item.active { border-left: 3px solid var(--success-color); } + .module-item.disabled { opacity: 0.5; } + .model-item.connected { color: var(--success-color); } + .hidden { display: none !important; } + .text-center { text-align: center; } + .clickable-file, .clickable-dir { + cursor: pointer; + display: inline-block; + padding: 1px 3px; + border: 1px solid transparent; + border-radius: 2px; + margin: 1px 0; + } + .clickable-file:hover { background: rgba(0,255,0,0.1); border-color: var(--border-color); } + .module-url-input { + width: 100%; + padding: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-color); + font-family: var(--font-primary); + margin-bottom: 10px; + } + .ollama-status { + padding: 10px; + margin-bottom: 10px; + border-radius: 4px; + } + .ollama-status.connected { background: rgba(0, 255, 0, 0.1); border: 1px solid var(--success-color); } + .ollama-status.disconnected { background: rgba(255, 85, 85, 0.1); border: 1px solid var(--error-color); } + .copy-btn { + position: absolute; + top: 5px; + right: 5px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--border-color); + color: var(--text-muted); + font-size: 0.7em; + padding: 2px 6px; + cursor: pointer; + border-radius: 3px; + font-family: sans-serif; + } + .copy-btn:hover { + color: var(--text-color); + background: rgba(0, 255, 0, 0.1); + } + .module-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.75em; + margin-left: 5px; + } + .module-badge.builtin { background: var(--bg-tertiary); border: 1px solid var(--border-color); } + .fetch-content { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + padding: 10px; + margin: 5px 0; + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + font-size: 0.85em; + } + .search-result { + margin-bottom: 10px; + padding: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + border-left: 3px solid var(--info-color); + } + .search-result a { + color: var(--info-color); + text-decoration: none; + } + .search-result a:hover { + text-decoration: underline; + } + + /* ── Splash Screen ── */ + #splash-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--bg-color); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 2000; + transition: opacity 0.3s ease; + } + #splash-overlay.hidden { + opacity: 0; + pointer-events: none; + } + #splash-title { + font-size: 1.8em; + font-weight: bold; + margin-bottom: 4px; + } + #splash-version { + font-size: 0.8em; + color: var(--text-muted); + margin-bottom: 24px; + } + #splash-progress-track { + width: 260px; + max-width: 70vw; + height: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; + margin-bottom: 12px; + } + #splash-progress-fill { + height: 100%; + width: 0%; + background: var(--accent-color); + transition: width 0.15s ease; + border-radius: 3px; + } + #splash-phase { + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 4px; + } + #splash-filename { + font-size: 0.85em; + color: var(--info-color); + margin-bottom: 16px; + } + #splash-cursor { + display: inline-block; + width: 8px; + height: 14px; + background: var(--text-color); + animation: blink 1s step-end infinite; + } + @keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } + } + #splash-gear { + position: absolute; + top: 12px; + right: 12px; + font-size: 1.4em; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.2s; + touch-action: manipulation; + padding: 8px; + } + #splash-gear:hover { opacity: 1; } + #splash-error { + color: var(--error-color); + font-size: 0.85em; + margin-top: 12px; + text-align: center; + display: none; + } + #splash-error.show { display: block; } + + /* ── Startup Modules Modal ── */ + .mod-card { + display: flex; + align-items: center; + padding: 10px 8px; + border-bottom: 1px solid #111; + min-height: 44px; + touch-action: manipulation; + } + .mod-card:last-child { border-bottom: none; } + .mod-card.disabled { opacity: 0.4; pointer-events: none; } + .mod-card label { + display: flex; + align-items: center; + gap: 10px; + flex-grow: 1; + cursor: pointer; + } + .mod-card input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-color); + flex-shrink: 0; + } + .mod-card .mod-name { + font-size: 0.9em; + flex-shrink: 0; + } + .mod-card .mod-badge { + font-size: 0.7em; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-muted); + margin-left: auto; + } + .mod-card .mod-badge.foundation { + background: #001a00; + border-color: var(--accent-color); + color: var(--accent-color); + } + .mod-card .mod-badge.ext { + background: #1a001a; + border-color: #550055; + color: #aa55ff; + } + .mod-section-header { + padding: 8px 8px 4px; + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + } + @media (max-width: 400px) { + .mod-card { padding: 10px 6px; } + .mod-card .mod-name { font-size: 0.82em; } + } + </style> +</head> +<body> + <header> + <div> + <span class="header-title">WebX11</span> + <span class="header-version">v4.2</span> + </div> + <div id="header-status" class="status-pill">Mem: --</div> + </header> + + <div id="terminal-container"> +<div class="output-line info">WebX11 Module System v4.2 - Unix-like Terminal</div> + <div class="output-line info">Commands: ls, cd, pwd, cat, echo, rm, mkdir, mv, cp, touch, tree, find, grep</div> + <div class="output-line info">Type 'help' for all commands</div> + <div class="output-line"></div> + <div class="output-line info loading" id="loading-indicator">Loading modules...</div> + </div> + + <!-- AI Confirm Area — fixed outside scrollable output, always visible --> + <div id="ai-confirm-area" class="hidden"></div> + + <div id="quick-actions"> + <button class="qa-btn" onclick="webx11.executeCommand('ls')">📁 Files</button> + <button class="qa-btn" onclick="webx11.executeCommand('help')">📖 Help</button> + <button class="qa-btn" onclick="webx11.executeCommand('modules')">🧩 Modules</button> + <button class="qa-btn" onclick="webx11.openOllamaManager()">🤖 Ollama</button> + <button class="qa-btn" onclick="webx11.openSkillsManager()">📚 Skills</button> + <button class="qa-btn" onclick="webx11.openConfigManager()">⚙ Config</button> + <button class="qa-btn" onclick="webx11.executeCommand('clear')">🧹 Clear</button> + <button class="qa-btn" id="voice-toggle-btn" onclick="webx11.toggleMicVisibility()">🎤 Mic</button> + <button class="qa-btn" id="sound-toggle-btn">🔊 Sound</button> + </div> + + <div id="input-area"> + <span class="prompt-label">$</span> + <textarea id="command-input" rows="1" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" placeholder="Type a command..." style="resize:none; overflow-y:auto; max-height:120px;"></textarea> + <button id="send-btn" onclick="var i=document.getElementById('command-input');webx11.executeCommand(i.value);i.value='';i.style.height='auto'" style="background:var(--bg-tertiary); border:1px solid var(--border-color); color:var(--success-color); padding:6px 12px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:0.9em; margin-left:6px; white-space:nowrap; flex-shrink:0;">⏎ Send</button> + <div id="autocomplete-dropdown"></div> + </div> + + <div id="status-bar"> + <span id="status-text">Ready</span> + <span id="network-status">📶 Online</span> + <button id="ai-cancel-btn" style="display:none; background:var(--error-color); color:#fff; border:none; border-radius:4px; padding:2px 10px; cursor:pointer; font-family:inherit; font-size:0.8em; touch-action:manipulation; min-height:28px;" onclick="webx11.executeCommand('ai-stop')">⏹ Stop</button> + </div> + + <!-- File Browser Modal --> + <div id="file-browser" class="modal-overlay"> + <div class="modal-content"> + <div class="modal-header"> + <span>File Browser</span> + <span style="cursor:pointer" onclick="webx11.closeFileBrowser()">✕</span> + </div> + <div class="modal-body" id="fb-content"></div> + <div class="modal-footer"> + <button class="btn" onclick="document.getElementById('file-upload-input').click()">⬆️ Upload</button> + <button class="btn" onclick="webx11.closeFileBrowser()">Close</button> + </div> + </div> + </div> + + <!-- Module Manager Modal --> + <div id="module-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 600px;"> + <div class="modal-header"> + <span>Module Manager</span> + <span style="cursor:pointer" onclick="webx11.closeModuleManager()">✕</span> + </div> + <div class="modal-body"> + <div style="margin-bottom: 15px;"> + <input type="text" id="module-url-input" class="module-url-input" placeholder="Enter module URL (https://example.com/module.js)"> + <button class="btn" onclick="webx11.loadModuleFromURL()" style="width: 100%;">Load Module from URL</button> + </div> + <h4 style="color: var(--info-color); margin-bottom: 10px;">Loaded Modules</h4> + <div id="module-list"></div> + </div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="webx11.clearAllModules()">Clear All</button> + <button class="btn" onclick="webx11.closeModuleManager()">Close</button> + </div> + </div> + </div> + + <!-- Ollama Manager Modal --> + <div id="ollama-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 600px;"> + <div class="modal-header"> + <span>Ollama AI Manager</span> + <span style="cursor:pointer" onclick="webx11.closeOllamaManager()">✕</span> + </div> + <div class="modal-body"> + <div id="ollama-status" class="ollama-status disconnected"> + <strong>Status:</strong> <span id="ollama-status-text">Disconnected</span> + </div> + <div style="margin-bottom: 15px;"> + <label style="display: block; margin-bottom: 5px; color: var(--text-muted);">Ollama Endpoint:</label> + <input type="text" id="ollama-endpoint" class="module-url-input" value="http://localhost:11434" placeholder="http://localhost:11434"> + <button class="btn" onclick="webx11.connectOllama()" style="width: 100%;">Connect</button> + </div> + <h4 style="color: var(--info-color); margin-bottom: 10px;">Available Models</h4> + <div id="ollama-models"></div> + <div style="margin-top: 15px;"> + <label style="display: block; margin-bottom: 5px; color: var(--text-muted);">Current Model:</label> + <select id="ollama-current-model" class="module-url-input"> + <option value="">Select a model</option> + </select> + <button class="btn" onclick="webx11.setOllamaModel()" style="width: 100%;">Set Model</button> + </div> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.closeOllamaManager()">Close</button> + </div> + </div> + </div> + + <!-- Splash Screen --> + <div id="splash-overlay"> + <span id="splash-gear" onclick="openStartupModulesSplash(event)" title="Select startup modules">⚙</span> + <div id="splash-title">⚡ WebX11</div> + <div id="splash-version">v4.2</div> + <div id="splash-progress-track"><div id="splash-progress-fill"></div></div> + <div id="splash-phase">Initializing...</div> + <div id="splash-filename"></div> + <div><span id="splash-cursor"></span></div> + <div id="splash-error"></div> + </div> + + <!-- Skills Manager Modal --> + <div id="skills-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 700px; max-height: 85vh;"> + <div class="modal-header"> + <span>📚 Skills Manager</span> + <span style="cursor:pointer" onclick="webx11.closeSkillsManager()">✕</span> + </div> + <div class="modal-body" style="display:flex; flex-direction:column; gap:10px;"> + <div style="flex-shrink:0;"> + <h4 style="color: var(--info-color); margin-bottom: 8px;">Installed Skills</h4> + <div id="skills-list"></div> + </div> + <div id="skill-preview" style="display:none; flex-shrink:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:4px; padding:10px; max-height:250px; overflow-y:auto; white-space:pre-wrap; font-family:var(--font-primary); font-size:0.85em; color:var(--text-color);"> + <div style="display:flex; justify-content:space-between; margin-bottom:6px;"> + <span id="skill-preview-title" style="color:var(--success-color); font-weight:bold;"></span> + <span style="cursor:pointer; color:var(--text-muted); font-size:1.2em;" onclick="webx11.closeSkillPreview()">✕</span> + </div> + <div id="skill-preview-content"></div> + </div> + <div style="flex-shrink:0;"> + <h4 style="color: var(--info-color); margin-bottom: 8px;">Create New Skill</h4> + <input type="text" id="skill-name-input" class="module-url-input" placeholder="Skill name (e.g. web-scrape-and-summarize)"> + <input type="text" id="skill-desc-input" class="module-url-input" style="margin-top:6px" placeholder="One-line description"> + <input type="text" id="skill-tags-input" class="module-url-input" style="margin-top:6px" placeholder="Tags (comma-separated, e.g. web,research)"> + <textarea id="skill-content-input" class="module-url-input" style="margin-top:6px; height:100px; resize:vertical; font-family:var(--font-primary)" placeholder="Markdown skill content (optional — will use template)"></textarea> + <button class="btn" onclick="webx11.createSkillFromUI()" style="width:100%; margin-top:6px; border-color:var(--success-color);color:var(--success-color)">Create Skill</button> + </div> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.refreshSkillsList()">🔄 Refresh</button> + <button class="btn" onclick="webx11.closeSkillsManager()">Close</button> + </div> + </div> + </div> + + <!-- Preview Modal --> + <div id="preview-modal" class="modal-overlay"> + <div class="modal-content" style="max-width: 95%; max-height: 90vh; width: 95%;"> + <div class="modal-header"> + <span id="preview-title">📄 Preview</span> + <span style="cursor:pointer" onclick="webx11.closePreview()">✕</span> + </div> + <div class="modal-body" style="padding:0; overflow:hidden; height:75vh;"> + <iframe id="preview-iframe" style="width:100%; height:100%; border:none; background:var(--bg-color);" sandbox="allow-same-origin allow-scripts"></iframe> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.closePreview()">Close</button> + </div> + </div> + </div> + + <!-- Config Manager Modal --> + <div id="config-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 550px; max-height: 80vh;"> + <div class="modal-header"> + <span>⚙ Configuration</span> + <span style="cursor:pointer" onclick="webx11.closeConfigManager()">✕</span> + </div> + <div class="modal-body" id="config-manager-body"> + <div id="config-list" style="margin-bottom:16px;"></div> + <div style="border-top:1px solid var(--border-color); padding-top:12px;"> + <h4 style="color:var(--info-color); margin-bottom:8px;">Add / Update Config</h4> + <div style="display:flex; gap:6px; flex-wrap:wrap;"> + <input type="text" id="config-key-input" class="module-url-input" placeholder="Key" style="flex:1; min-width:100px;"> + <input type="text" id="config-value-input" class="module-url-input" placeholder="Value" style="flex:3; min-width:150px;"> + <button class="btn" onclick="webx11.addConfigFromUI()" style="border-color:var(--success-color);color:var(--success-color); padding:6px 14px;">Add</button> + </div> + </div> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.exportConfigFromUI()">📤 Export</button> + <button class="btn btn-danger" onclick="webx11.resetConfigFromUI()">🗑 Reset All</button> + <button class="btn" onclick="webx11.closeConfigManager()">Close</button> + </div> + </div> + </div> + + <!-- Startup Modules Modal --> + <div id="startup-modules" class="modal-overlay"> + <div class="modal-content" style="max-width: 500px;"> + <div class="modal-header"> + <span>⚙ Startup Modules</span> + <span style="cursor:pointer" onclick="var w=window.webx11;if(w&&typeof w.closeStartupModules==='function')w.closeStartupModules();else document.getElementById('startup-modules').style.display='none'">✕</span> + </div> + <div class="modal-body" id="startup-modules-body"></div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="webx11.closeStartupModules()">Cancel</button> + <button class="btn" onclick="webx11.saveStartupModules()" style="border-color:var(--info-color);color:var(--info-color)">Save & Reload</button> + </div> + </div> + </div> + + <!-- Code Editor Modal --> + <div id="code-editor" class="modal-overlay"> + <div class="modal-content" style="max-width: 90vw; max-height: 90vh; width: 900px; height: 80vh;"> + <div class="modal-header"> + <span id="code-editor-title">📝 Edit Module</span> + <span style="cursor:pointer" onclick="if(webx11) webx11.closeCodeEditor()">✕</span> + </div> + <div class="modal-body" style="padding:0; display:flex; flex-direction:column; height:calc(100% - 100px);"> + <div id="code-editor-info" style="padding:6px 10px; font-size:0.8em; color:var(--text-muted); border-bottom:1px solid var(--border-color); flex-shrink:0;"></div> + <textarea id="code-editor-textarea" style="flex:1; width:100%; border:none; background:var(--bg-tertiary); color:var(--text-color); font-family:monospace; font-size:13px; padding:10px; resize:none; outline:none; tab-size:2; white-space:pre; overflow:auto;"></textarea> + </div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="if(webx11) webx11.closeCodeEditor()">Cancel</button> + <button class="btn" onclick="(async()=>{if(webx11)await webx11.saveCodeEditor()})()" style="border-color:var(--info-color);color:var(--info-color)">💾 Save & Test</button> + </div> + </div> + </div> + + <input type="file" id="file-upload-input" style="display:none"> + + + <!-- Gear splash function — works before webx11 initializes --> + <script> + function openStartupModulesSplash(event) { + event.stopPropagation(); + var w = window.webx11; + if (w && typeof w.openStartupModules === 'function') { + w.openStartupModules(); + } else { + var overlay = document.getElementById('startup-modules'); + if (!overlay) return; + // Bare-bones render for when webx11 isn't ready yet + var body = document.getElementById('startup-modules-body'); + if (body && !body.children.length) { + var mods = [ + ['Foundation (always loaded)', ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js'], true], + ['Built-in modules', ['module-system.js','module-fs.js','module-modmgr.js','module-ollama.js','module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js','module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js','module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js','module-fetcher.js','module-skills.js','module-chatstudio.js'], false], + ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js'], false] + ]; + var isSel = function(name) { return true; }; // default: all selected + mods.forEach(function(section) { + var h = document.createElement('div'); + h.className = 'mod-section-header'; + h.textContent = section[0]; + body.appendChild(h); + section[1].forEach(function(name) { + var card = document.createElement('div'); + card.className = 'mod-card' + (section[2] ? ' disabled' : ''); + var label = document.createElement('label'); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = section[2] || isSel(name); + cb.disabled = section[2]; + cb.dataset.name = name; + var nameSpan = document.createElement('span'); + nameSpan.className = 'mod-name'; + nameSpan.textContent = name; + var badgeSpan = document.createElement('span'); + badgeSpan.className = 'mod-badge' + (section[2] ? ' foundation' : ''); + badgeSpan.textContent = section[2] ? 'foundation' : 'built-in'; + label.appendChild(cb); + label.appendChild(nameSpan); + card.appendChild(label); + card.appendChild(badgeSpan); + body.appendChild(card); + }); + }); + } + overlay.style.display = 'flex'; + } + } + </script> + + <!-- Async dependency-ordered module loader (replaces 26 synchronous script tags) --> + <script src="modules/core/module-loader.js?v=19"></script> + +</body> +</html> diff --git a/index.html.pre-refactor b/index.html.pre-refactor new file mode 100644 index 0000000..c9779c9 --- /dev/null +++ b/index.html.pre-refactor @@ -0,0 +1,35 @@ + + // Initialize the modular system + const webx11 = new WebX11Terminal(); + window.webx11 = webx11; + </script> + + <!-- Core infrastructure modules --> + <script src="modules/core/virtual-fs.js?v=3"></script> + <script src="modules/core/module-manager.js?v=3"></script> + <script src="modules/core/ollama-manager.js?v=3"></script> + <script src="modules/core/terminal.js?v=3"></script> + <script src="modules/core/module-system.js?v=3"></script> + <script src="modules/core/module-fs.js?v=3"></script> + <script src="modules/core/module-modmgr.js?v=3"></script> + <script src="modules/core/module-ollama.js?v=3"></script> + <script src="modules/core/module-my.js?v=3"></script> + <script src="modules/core/module-searxng.js?v=3"></script> + <script src="modules/core/module-servermon.js?v=3"></script> + <script src="modules/core/module-ollamamodels.js?v=3"></script> + <script src="modules/core/module-config.js?v=3"></script> + <script src="modules/core/module-simplegui.js?v=3"></script> + <script src="modules/core/module-io.js?v=3"></script> + <script src="modules/core/module-dynamicprompts.js?v=3"></script> + <script src="modules/core/module-aiassist.js?v=3"></script> + <script src="modules/core/module-aiasm.js?v=3"></script> + <script src="modules/core/module-smartctx.js?v=3"></script> + <script src="modules/core/module-duckduckgo.js?v=3"></script> + <script src="modules/core/module-fetcher.js?v=3"></script> + <script src="modules/ext/calc.js?v=3"></script> + <script src="modules/ext/voice.js?v=3"></script> + <script src="modules/ext/cron.js?v=3"></script> + <script src="modules/ext/theme.js?v=3"></script> + <script src="modules/ext/wpm.js?v=3"></script> +</body> +</html>
\ No newline at end of file diff --git a/modules/core/module-aiasm.js b/modules/core/module-aiasm.js new file mode 100644 index 0000000..60e1757 --- /dev/null +++ b/modules/core/module-aiasm.js @@ -0,0 +1,26 @@ +// Extracted from newOS4 index.html — WebXAIAliasModule + class WebXAIAliasModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'webxai': 'webxai <question> — Send a question to the AI assistant', + 'wx': 'wx <question> — Alias for webxai', + 'webx': 'webx <question> — Alias for webxai' + }; + } + registerCommands() { + this.commands['webxai'] = (args) => this.forwardToAI(args); + this.commands['wx'] = (args) => this.forwardToAI(args); + this.commands['webx'] = (args) => this.forwardToAI(args); + } + forwardToAI(args) { + if (!args[0]) { + this.terminal.print("Usage: webxai <your-question>", "warning"); return; + } + if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { + this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); + } else { + this.terminal.ollamaManager.chat(args.join(' ')); + } + } + } diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js new file mode 100644 index 0000000..0ef6a2f --- /dev/null +++ b/modules/core/module-aiassist.js @@ -0,0 +1,1435 @@ +// Extracted from newOS4 index.html — WebX11AIAssistantModule (Full Agent Loop) + class WebX11AIAssistantModule { + constructor() { + this.commands = {}; this.terminal = null; + this.systemPrompt = this.buildSystemPrompt(); + this.context = []; this.maxContext = 10; + this.maxContextTokens = 2000; + this.MAX_TOOL_TURNS = 8; + this._pendingConfirmation = null; + this._confirmResolve = null; + this._errorLog = []; + this._commandFreq = {}; + this._messageCount = 0; + this._userProfile = {}; + this._sessionMemoryLoaded = false; + this._sessionSaved = false; + this._thinkingTimer = null; + this._thinkingStartTime = 0; + this._toolDefs = null; + try { const s = localStorage.getItem('webx11_ai_patterns'); if (s) this._commandFreq = JSON.parse(s); } catch (e) {} + try { const p = localStorage.getItem('webx11_user_profile'); if (p) this._userProfile = JSON.parse(p); } catch (e) {} + // Restore AI conversation context from localStorage + try { + const saved = localStorage.getItem('webx11_ai_context'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && Array.isArray(parsed.context)) { + this.context = parsed.context; + this._messageCount = parsed.messageCount || 0; + } + } + } catch (e) {} + } + buildSystemPrompt() { + return `You are WebX11AI, the AI assistant embedded in the WebX11 browser terminal. You have direct access to read/write files, search the web, install packages, edit modules, and run terminal commands — all via tool calls. Use tools proactively. Keep responses concise. + +== TOOL REFERENCE == +You have these tools available. Use them WITHOUT asking permission unless the tool description says (DESTRUCTIVE): + +FILESYSTEM: +- list_files(path?) — List files in VFS directory (omit for current dir) +- read_file(path) — Read file content (truncated past 8000 chars) +- write_file(path, content) — Write to file (DESTRUCTIVE — confirm with user first) +- make_directory(path) — Create a directory (DESTRUCTIVE — confirm first) +- delete_file(path) — Delete file (DESTRUCTIVE — confirm first) +- get_current_dir — Show current working directory + +SYSTEM: +- get_status — Show filesystem, modules, Ollama, memory status +- get_help — List all available terminal commands +- run_command(command) — Execute any terminal command and capture output +- font-size <8-32> — Change terminal font size (persists in localStorage) + +SEARCH: +- web_search(query) — Search the web via proxy API (SearXNG + DDG fallback, results with title/url/snippet) +- web_fetch(url) — Fetch and extract readable content from a URL via proxy API (returns title + clean text + word count) + +PACKAGES: +- wpm_install(package) — Install WPM package by name (e.g. "widget", "calc") +- wpm_search(keyword) — Search available WPM packages + +SKILLS (PERSISTENT PROCEDURAL MEMORY): +- list_skills(category?) — Browse available skills, optionally filtered by category +- load_skills(query) — Search skills relevant to current task. Call at START of every task. +- create_skill(name, description, content, tags, category) — Save a new stored procedure. Call after completing complex multi-step tasks (3+ tool calls). +- patch_skill(name, content) — Update an existing skill when the approach changes + +MODULE SELF-IMPROVEMENT: +- view_module(name) — View source code of any loaded module +- edit_module(name) — Copy module into editor. Opens editor for modifications. +- test_module(name) — Load edited module from /_mods/ for live testing +- reload_module(name) — Discard edits and reload from original URL +- list_modified_modules — List modules with local edits pending +- get_module_status — Full status of all modules (loaded, modified, URLs) + +== SKILLS WORKFLOW == +1. At the START of every task, call load_skills(query) to check if a stored procedure exists +2. Follow the skill's steps. If they're wrong, call patch_skill to fix them. +3. After a complex multi-step task (3+ tool calls), call create_skill to save the approach +4. Skills persist in /skills/ as markdown files with YAML frontmatter + +== MEMORY SYSTEM == +- I maintain a conversation context (this.context) that persists across messages in this session +- The last ${this.maxContext} exchanges are remembered +- Type "ai-clear" to reset conversation memory +- Type "ai-context" to see what I remember +- Type "ai-model" to see/switch models +- Type "ai-stop" to cancel a running AI request +- Smart context profiles (set via "context <name>") adjust my temperature and system prompt style +- Saved prompts (via "prompt", "prompt-save") let you reuse template with {variable} substitution + +== USER PROFILE == +- Use user_get, user_set to read/write persistent user preferences across sessions +- user_set dot-notation supported: user_set knownFacts.name "Alex" +- Profile auto-stores known facts (name, project, preferences) +- Use session_save after complex tasks to log what was done +- Use session_load to review past sessions +- Use session_recent at start of session to remember what was worked on before + +== SELF-IMPROVEMENT WORKFLOW == +1. User asks for module changes → call get_module_status to list modules +2. Call view_module(name) to read the module source +3. Call edit_module(name) to copy source into editor for modification +4. After editing, call test_module(name) to load and test the edit +5. If it works, the user keeps it. If not, call reload_module(name). + +== AUTO-IMPROVEMENT (SYSTEM-DRIVEN) == +- If I encounter the same tool error 3+ times, a workaround skill is created automatically +- After complex tasks (3+ tool calls), I run health checks and suggest improvements +- I track conversation patterns and surface frequent topics +- Every 20 messages, skills are audited for staleness +- Use check_updates to verify module source URLs are reachable + +== RESPONSE STYLE == +- Be concise. Use bullet points for lists. +- When using tools, explain what you're doing briefly. +- If a tool fails, try an alternative approach. +- For web research tasks: search first, then analyze the results, then present findings. +|- When the user says "update" or "improve" without specifics, use get_status + get_help to understand the current state first. + +== REACT PROTOCOL == +For every task, follow this cycle: +1. **REASON** — Think about what you need next. What information is missing? +2. **ACT** — Call ONE tool at a time (unless multiple independent lookups are needed). +3. **OBSERVE** — Study the tool result carefully. Did it succeed? Is the data useful? +4. **DECIDE** — Do you have enough to answer? If not, go back to step 1. + +RULES: +- Call tools in sequence when the second depends on the first result +- Independent lookups (e.g. reading two unrelated files) can run in parallel +- If a tool returns an error, try a different approach before giving up +- After 6 tool turns, start synthesizing a final answer +- Always provide a complete, helpful final answer — don't leave the user hanging`; + } + getToolDefinitions() { + if (!this._toolDefs) this._toolDefs = this._buildToolDefs(); + return this._toolDefs; + } + _buildToolDefs() { + return [{ + type: 'function', + function: { + name: 'list_files', + description: 'List files and directories in the current or specified virtual filesystem directory', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path to list, e.g. "" for root, "notes" for notes dir' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'read_file', + description: 'Read contents of a file from the virtual filesystem', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to read, e.g. "hello.txt" or "notes/todo.md"' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'write_file', + description: 'Write content to a file in the virtual filesystem (DESTRUCTIVE)', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to write' }, content: { type: 'string', description: 'Content to write' } }, required: ['path', 'content'] } + } + }, { + type: 'function', + function: { + name: 'make_directory', + description: 'Create a new directory in the virtual filesystem', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory name to create' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'delete_file', + description: 'Delete a file or empty directory from the virtual filesystem (DESTRUCTIVE)', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'get_status', + description: 'Get system status: filesystem, modules, Ollama connection, memory', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'get_help', + description: 'Get full command reference for WebX11', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'web_search', + description: 'Search the web using the WebX11 proxy API (SearXNG + DDG fallback). Returns structured results with title, URL, and content snippet.', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query' } }, required: ['query'] } + } + }, { + type: 'function', + function: { + name: 'web_fetch', + description: 'Fetch and extract clean readable content from a URL using the WebX11 proxy API. Returns title, clean text content, and word count. Use for reading articles, documentation, or web pages.', + parameters: { type: 'object', properties: { url: { type: 'string', description: 'Full URL to fetch' } }, required: ['url'] } + } + }, { + type: 'function', + function: { + name: 'wpm_install', + description: 'Install a WebX11 package from the package index', + parameters: { type: 'object', properties: { package: { type: 'string', description: 'Package name to install, e.g. "widget" or "calc"' } }, required: ['package'] } + } + }, { + type: 'function', + function: { + name: 'wpm_search', + description: 'Search available WebX11 packages by keyword', + parameters: { type: 'object', properties: { keyword: { type: 'string', description: 'Search keyword' } }, required: ['keyword'] } + } + }, { + type: 'function', + function: { + name: 'run_command', + description: 'Execute any WebX11 terminal command directly and return its output', + parameters: { type: 'object', properties: { command: { type: 'string', description: 'Full command to execute, e.g. "grep error" or "wc -l"' } }, required: ['command'] } + } + }, { + type: 'function', + function: { + name: 'get_current_dir', + description: 'Get current working directory path in virtual filesystem', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'view_module', + description: 'View the source code of any loaded WebX11 module', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name, e.g. ThemeSwitcherModule' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'edit_module', + description: 'Copy a module source into the editor for modification (DESTRUCTIVE)', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name to edit' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'test_module', + description: 'Load an edited module from /_mods/ for live testing', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'reload_module', + description: 'Reload a module from its original URL, discarding local edits', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'list_modified_modules', + description: 'List which modules have local edits pending test', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'get_module_status', + description: 'Full status of all modules: loaded, modified, URLs', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'load_skills', + description: 'Search available skills by relevance to a query. Skills are stored procedures with step-by-step approaches for recurring tasks. Returns matching skill content to help you work efficiently.', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query describing the task — e.g. "search the web" or "organize files"' } }, required: ['query'] } + } + }, { + type: 'function', + function: { + name: 'create_skill', + description: 'Save a new skill (stored procedure) so you can reuse this approach next time. Call after completing a complex multi-step task. Skills persist across sessions.', + parameters: { type: 'object', properties: { + name: { type: 'string', description: 'Short kebab-case name, e.g. "web-scrape-and-summarize"' }, + description: { type: 'string', description: 'One-line description of what this skill does' }, + content: { type: 'string', description: 'Markdown content with YAML frontmatter. Must start with "---\\nname: <name>\\n..."' }, + tags: { type: 'string', description: 'Comma-separated tags, e.g. "search,web,scraping"' }, + category: { type: 'string', description: 'Category, e.g. "research", "system", "dev"' } + }, required: ['name', 'description', 'content'] } + } + }, { + type: 'function', + function: { + name: 'patch_skill', + description: 'Update an existing skill file. Use when a stored procedure is outdated or has better steps. Replaces the entire skill content.', + parameters: { type: 'object', properties: { + name: { type: 'string', description: 'Skill name to patch (without .md)' }, + content: { type: 'string', description: 'Complete new markdown content including YAML frontmatter' } + }, required: ['name', 'content'] } + } + }, { + type: 'function', + function: { + name: 'list_skills', + description: 'List all available skills. Optionally filter by category.', + parameters: { type: 'object', properties: { category: { type: 'string', description: 'Optional category filter, e.g. "research" or "system"' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'check_updates', + description: 'Check if loaded modules are reachable at their source URLs. Reports any that appear outdated or unreachable.', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'user_get', + description: 'Read a value from the persistent user profile. Returns null if key not found.', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key to read, e.g. "preferredModel", "fontSize", "knownFacts.name"' } }, required: ['key'] } + } + }, { + type: 'function', + function: { + name: 'user_set', + description: 'Write a value to the persistent user profile. Saves across sessions.', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key, e.g. "preferredModel"' }, value: { type: 'string', description: 'Value to store' } }, required: ['key', 'value'] } + } + }, { + type: 'function', + function: { + name: 'user_list', + description: 'List all stored keys in the user profile.', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'user_forget', + description: 'Delete a key from the user profile.', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Key to delete' } }, required: ['key'] } + } + }, { + type: 'function', + function: { + name: 'session_save', + description: 'Save a summary of the current session to /memory/. Use after complex tasks. Creates a daily log file.', + parameters: { type: 'object', properties: { summary: { type: 'string', description: 'Brief summary of what was accomplished this session' } }, required: ['summary'] } + } + }, { + type: 'function', + function: { + name: 'session_load', + description: 'Load a past session log by date. Format: YYYY-MM-DD.', + parameters: { type: 'object', properties: { date: { type: 'string', description: 'Date string like "2026-05-23"' } }, required: ['date'] } + } + }, { + type: 'function', + function: { + name: 'session_recent', + description: 'Show summaries from the most recent sessions.', + parameters: { type: 'object', properties: { count: { type: 'number', description: 'Number of recent sessions to show (default 3)' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'session_stats', + description: 'Aggregate statistics across all sessions: total tasks, most-used tools, common topics.', + parameters: { type: 'object', properties: {}, required: [] } + } + }]; + } + getHelpInfo() { + return { + 'ai': 'ai <question> — Ask the AI assistant anything', + 'ask': 'ask <question> — Alias for ai command', + 'ai-clear': 'ai-clear — Clear conversation context', + 'ai-system': 'ai-system <prompt> — Set system prompt', + 'ai-context': 'ai-context — Show current conversation context', + 'ai-allow': 'ai-allow — Confirm pending AI action', + 'ai-deny': 'ai-deny — Deny pending AI action', + 'ai-stop': 'ai-stop — Cancel current AI operation', + 'ai-model': 'ai-model [model] — Show or set AI model' + }; + } + registerCommands() { + this.commands['ai'] = (args) => this.chat(args); + this.commands['ask'] = (args) => this.chat(args); + this.commands['ai-clear'] = () => this.clearContext(); + this.commands['ai-system'] = (args) => this.setSystemPrompt(args); + this.commands['ai-context'] = () => this.showContext(); + this.commands['ai-allow'] = () => this.confirmPending(); + this.commands['ai-deny'] = () => this.denyPending(); + this.commands['ai-stop'] = () => this.cancelAi(); + this.commands['ai-model'] = (args) => this.showModel(args); + this.commands['ai-compress'] = () => this._manualCompress(); + } + async chat(args) { + const userMessage = args.join(' '); + if (!userMessage) { + this.terminal.print('Usage: ai <your-question>', 'warning'); return; + } + this._cancelled = false; + this.terminal.setAiBusy(true); + while (this._chatLock) await new Promise(r => setTimeout(r, 50)); + this._chatLock = true; + this.terminal.markDirty(); + // Re-read context from localStorage (auto-restore may have written it after init) + this._loadContext(); + // Compress context if it exceeds the token threshold + this._compressContext(); + let loopResult; + try { + const messages = [ + { role: 'system', content: this.systemPrompt } + ]; + this.context.slice().forEach(msg => messages.push(msg)); + messages.push({ role: 'user', content: userMessage }); + this.terminal.print('🤖 WebX11AI thinking...', 'info'); + // Ensure skills directory exists and seed defaults on first run + this.terminal.fs.mkdirRecursive('/skills'); + if (!this.terminal.fs.readFile('/skills/_seeded')) { + try { + await this._seedDefaultSkills(); + } catch (e) { /* non-critical */ } + } + // Auto-load skills relevant to the user's query (awaited, not fire-and-forget) + // Skip skill loading for trivial queries (< 10 chars, no actionable keywords) + const isTrivial = userMessage.length < 10 && !/\b(search|write|install|edit|create|make|find|delete|remove|change|update|improve|add|fix|build|set|load|save|help)\b/i.test(userMessage); + if (!isTrivial) { + try { + const skillRes = await this.runTool('load_skills', { query: userMessage }); + const skillData = JSON.parse(skillRes); + if (skillData && skillData.matches && skillData.matches.length > 0) { + const skillNotes = skillData.matches.map(m => `[Skill: ${m.name}] ${m.content}`).join('\n\n---\n\n'); + messages.push({ role: 'system', content: `Relevant skills for this task:\n\n${skillNotes}\n\nUse the steps above as guidance. If they're wrong or outdated, use patch_skill to fix them after the task.` }); + this.terminal.print(`📚 Loaded ${skillData.count} relevant skill(s)`, 'info'); + } + } catch (e) { /* skills not available yet — first run */ } + } + + // Track conversation patternsfor learning + this._trackPattern(userMessage); + + // Extract facts from user message + this._extractFacts(userMessage, ''); + + // On first message of session, auto-load recent memory + if (!this._sessionMemoryLoaded) { + this._sessionMemoryLoaded = true; + this.terminal.fs.mkdirRecursive('/memory'); + try { + const recentRes = await this.runTool('session_recent', { count: 3 }); + const rd = JSON.parse(recentRes); + if (rd.sessions && rd.sessions.length > 0) { + const memNotes = rd.sessions.map(s => '[' + s.date + '] ' + (s.tasks || 1) + ' tasks: ' + (s.summary || 'No summary')).join('\n'); + messages.push({ role: 'system', content: 'Recent sessions:\n' + memNotes + '\nThis provides continuity. Refer to past work when relevant.' }); + this.terminal.print('📖 Loaded ' + rd.sessions.length + ' recent session(s)', 'info'); + } + } catch (e) {} + } + + loopResult = await this.agenticLoop(messages); + this.context.push({ role: 'user', content: userMessage }); + if (loopResult.finalAnswer) { + this.context.push({ role: 'assistant', content: loopResult.finalAnswer }); + } + if (this.context.length > this.maxContext * 2) { + this.context = this.context.slice(-this.maxContext * 2); + } + // Reflection: after complex tasks, run health scan and auto-skill creation + if (loopResult.toolTurns >= 2 && !this._cancelled) { + await this.afterActionReflection(userMessage, loopResult.toolTurns); + } + // Auto-create skills from repeated errors + if (this._errorLog.length >= 3) { + await this._autoCreateErrorSkill(); + } + // Skill quality audit every 20 messages + this._messageCount++; + if (this._messageCount % 20 === 0) { + await this._auditSkills(); + } + // Sound notification on AI response + if (this.terminal.playNotificationSound) { + setTimeout(() => this.terminal.playNotificationSound(), 100); + } + } finally { + this._chatLock = false; + this.terminal.setAiBusy(false); + // Persist conversation context after each exchange + this._saveContext(); + // Log the AI interaction + this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); + if (loopResult && loopResult.toolTurns) { + this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s)'); + } + } + // Register session save on page unload (runs once per session) + if (!this._sessionSaved) { + this._sessionSaved = true; + window.addEventListener('beforeunload', () => { + this.terminal.fs.mkdirRecursive('/memory'); + const today = new Date().toISOString().split('T')[0]; + const index = this.terminal.fs.fsReadJSON('/memory/index.json') || {}; + index[today] = index[today] || { summary: 'Session in progress', tasks: 0 }; + index[today].tasks = (index[today].tasks || 0) + 1; + index[today].updated = new Date().toISOString(); + this.terminal.fs.fsWriteJSON('/memory/index.json', index); + // Also persist AI context on page close + this._saveContext(); + }); + } + } + _summarizeObservation(result, maxLen) { + if (!result || typeof result !== 'string' || result.length <= maxLen) return result || ''; + const lines = result.split('\n'); + if (lines.length > 5) { + return lines.slice(0, 2).join('\n') + '\n...(' + (lines.length - 4) + ' more lines)...\n' + lines.slice(-2).join('\n'); + } + return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)'; + } + _isToolError(result) { + if (!result || typeof result !== 'string') return null; + const trimmed = result.trim(); + if (trimmed.startsWith('{"error":') || trimmed.startsWith("{'error':")) { + try { const parsed = JSON.parse(trimmed); return parsed.error || null; } + catch (e) { return null; } + } + return null; + } + async agenticLoop(messages) { + let toolTurns = 0; + while (toolTurns < this.MAX_TOOL_TURNS) { + if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; } + toolTurns++; + // On last turn, inject wrap-up prompt + if (toolTurns === this.MAX_TOOL_TURNS) { + this.terminal.print(`⏱ Final turn (${toolTurns}/${this.MAX_TOOL_TURNS}) — AI must provide final answer`, 'warning'); + messages.push({ role: 'system', content: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.' }); + } + const thinkStart = Date.now(); + const res = await this.terminal.ollamaManager.chatWithTools(messages, this.getToolDefinitions()); + const thinkTime = Math.round((Date.now() - thinkStart) / 1000); + 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) { + 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'); + return { finalAnswer: text, toolTurns }; + } + if (res.tool_calls && res.tool_calls.length > 0) { + this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${this.MAX_TOOL_TURNS} (${thinkTime}s)`, 'info'); + messages.push(res.message); + let pendingText = res.message?.content || null; + const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name)); + const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name)); + // Execute non-destructive tools in parallel (independent read-only operations) + 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.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); + } else { + this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + // Only block on destructive tools + 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.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); + } else { + 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 by user. Non-destructive actions already executed.` + }); + } + } + if (pendingText) { + messages.push({ role: 'assistant', content: pendingText }); + } + continue; + } + return { finalAnswer: null, toolTurns }; + } + // Loop exited without final answer — auto-summary + const toolNames = Object.keys(this._commandFreq).slice(-3).join(', '); + this.terminal.print(`🤖 AI reached the ${toolTurns}/${this.MAX_TOOL_TURNS} tool limit.`, 'warning'); + this.terminal.print('Tools were executed but no final answer was produced — try a more specific query.', 'info'); + return { finalAnswer: null, toolTurns }; + } + async afterActionReflection(query, toolTurns) { + // Debounce: only suggest skill creation once per 30 minutes + const now = Date.now(); + if (this._lastSkillSuggestion && (now - this._lastSkillSuggestion) < 1800000) { + return; + } + const suggestions = []; + const fs = this.terminal.fs; + try { + // Batch: read all status info in one VFS pass + const reg = this.terminal.moduleManager.moduleRegistry || []; + const total = reg.length; + const loaded = Object.keys(this.terminal.moduleManager.modules).length; + if (loaded < total) { + suggestions.push(loaded + '/' + total + ' modules loaded — use loadmod to restore'); + } + } catch (e) {} + try { + const skillIdx = fs.fsReadJSON('/skills/index.json'); + const skillCount = skillIdx ? Object.keys(skillIdx).length : 0; + if (skillCount === 0 && toolTurns >= 4) { + suggestions.push('Save this approach as a skill with create_skill'); + this._lastSkillSuggestion = now; + } + } catch (e) {} + try { + const modsDir = fs.getCurrentDir(); + const mc = modsDir.content['_mods']; + const modCount = mc && mc.content ? Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')).length : 0; + if (modCount > 0) { + suggestions.push(modCount + ' pending module edit(s) in /_mods/ — use test_module to verify'); + } + } catch (e) {} + try { + if (performance.memory && performance.memory.usedJSHeapSize > 100 * 1048576) { + suggestions.push('High memory (' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB) — consider clearing terminal'); + } + } catch (e) {} + if (toolTurns >= 4 && suggestions.length === 0) { + suggestions.push('Used ' + toolTurns + ' tools — create a skill to save this workflow'); + this._lastSkillSuggestion = now; + } + if (suggestions.length > 0) { + this.terminal.print('💡 ' + suggestions.join(' | '), 'info'); + } + } + async confirmActionMulti(toolCalls) { + const names = toolCalls.map(tc => tc.function.name).join(', '); + const args = toolCalls.map(tc => typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments)).join(' | '); + const truncatedArgs = args.length > 60 ? args.substring(0, 60) + '...' : args; + this.terminal.printHTML(`⚠️ AI wants to: ${names}(${truncatedArgs}). <span class="cmd-link" onclick="webx11.executeCommand('ai-allow')" style="color:var(--success-color);cursor:pointer;text-decoration:underline;">ai-allow</span> to confirm, <span class="cmd-link" onclick="webx11.executeCommand('ai-deny')" style="color:var(--error-color);cursor:pointer;text-decoration:underline;">ai-deny</span> to refuse. (Auto-denies in 120s)`); + // Show confirm bar OUTSIDE scrollable output (always visible) + const area = document.getElementById('ai-confirm-area'); + if (!area) { + // Fallback to promise resolve + return true; + } + area.innerHTML = ` + <span class="confirm-desc">⚠️ ${names}(${truncatedArgs})</span> + <button class="confirm-btn" id="ai-confirm-allow" onclick="(function(){var a=document.getElementById('ai-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="ai-confirm-deny" onclick="(function(){var d=document.getElementById('ai-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span class="confirm-timer" id="ai-confirm-timer"></span> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: ai-allow or ai-deny</span> + `; + area.classList.add('visible'); + this._confirmArea = area; + this._confirmBar = area; + + // Attach button handlers via both addEventListener + DOM property (inline onclick fallback) + const allowBtn = document.getElementById('ai-confirm-allow'); + const denyBtn = document.getElementById('ai-confirm-deny'); + const onAllow = () => { + this._resolveConfirm(true); + }; + const onDeny = () => { + this._resolveConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + + const startTime = Date.now(); + return new Promise((resolve) => { + this._pendingConfirmation = { names, args }; + this._confirmResolve = resolve; + this._confirmAllowBtn = allowBtn; + this._confirmDenyBtn = denyBtn; + this._confirmOnAllow = onAllow; + this._confirmOnDeny = onDeny; + // Countdown timer updates with escalating visibility + const timerInterval = setInterval(() => { + const elapsed = Math.round((Date.now() - startTime) / 1000); + const remaining = Math.max(0, 120 - elapsed); + const timerEl = document.getElementById('ai-confirm-timer'); + if (timerEl) { + timerEl.textContent = `⏱ ${remaining}s`; + timerEl.className = 'confirm-timer'; + if (remaining > 30) { + timerEl.style.color = 'var(--warning-color)'; + } else if (remaining > 10) { + timerEl.classList.add('urgent'); + } else { + timerEl.classList.add('critical'); + } + } + if (remaining === 60) { + this.terminal.print(`⏱ 60s remaining to respond to AI confirmation for: ${names}`, 'warning'); + } else if (remaining === 30) { + this.terminal.print(`⏱ 30s — ${names} will be auto-denied soon!`, 'warning'); + } else if (remaining === 10) { + this.terminal.print(`⏱ 10s remaining! Type ai-allow or click Allow now!`, 'warning'); + } + }, 500); + this._confirmTimerInterval = timerInterval; + setTimeout(() => { + clearInterval(timerInterval); + if (this._confirmResolve) { + this._confirmResolve(false); + this._cleanupConfirmBar(); + this.terminal.print('⏱ Confirmation timed out — tool not executed', 'warning'); + } + }, 120000); + }); + } + _resolveConfirm(approved) { + if (this._confirmResolve) { + this._confirmResolve(approved); + this._cleanupConfirmBar(); + if (approved) { + this.terminal.print('Action allowed', 'success'); + } else { + this.terminal.print('Action denied', 'warning'); + } + } + } + _cleanupConfirmBar() { + clearInterval(this._confirmTimerInterval); + this._confirmTimerInterval = null; + this._confirmResolve = null; + this._pendingConfirmation = null; + this._confirmAllowBtn = null; + this._confirmDenyBtn = null; + this._confirmOnAllow = null; + this._confirmOnDeny = null; + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + this._confirmArea = null; + this._confirmBar = null; + const timerEl = document.getElementById('ai-confirm-timer'); + if (timerEl) timerEl.style.animation = ''; + } + confirmPending() { + this._resolveConfirm(true); + } + denyPending() { + this._resolveConfirm(false); + } + cancelAi() { + this._cancelled = true; + this.terminal.print('⏹ Cancelling AI...', 'warning'); + // Abort any in-flight HTTP request + if (this.terminal.ollamaManager) { + this.terminal.ollamaManager.abort(); + } + // Clear any pending confirmation + if (this._confirmResolve) { + this._resolveConfirm(false); + } + // Immediately reset UI so user knows it stopped + this.terminal.setAiBusy(false); + } + isDestructive(name) { + return name === 'write_file' || name === 'delete_file' || name === 'run_command' || name === 'make_directory' || name === 'edit_module'; + } + async runTool(name, argsStr) { + try { + const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { + case 'list_files': { + const originalPath = a.path || ''; + const p = originalPath.replace(/^\//, ''); + const saved = [...this.terminal.fs.currentPath]; + if (originalPath.startsWith('/')) { + this.terminal.fs.currentPath = []; + } + if (p) { + const parts = p.split('/'); + const dir = this.terminal.fs.root; + let cur = dir; + for (const part of parts) { + if (part === '..') { saved.pop(); continue; } + if (part && cur.content[part] && cur.content[part].type === 'dir') cur = cur.content[part]; + else return JSON.stringify({ error: `Directory not found: ${p}` }); + } + this.terminal.fs.currentPath = saved.concat(parts.filter(x => x !== '..')); + } + const files = this.terminal.fs.ls(); + this.terminal.fs.currentPath = saved; + return JSON.stringify(files.map(f => ({ name: f.name, type: f.type, size: f.size }))); + } + case 'read_file': { + const rp = a.path || ''; + const savedR = [...this.terminal.fs.currentPath]; + if (rp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = rp.replace(/^\//, ''); + const content = this.terminal.fs.readFile(path); + this.terminal.fs.currentPath = savedR; + if (content === null) return JSON.stringify({ error: `File not found: ${path}` }); + return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content; + } + case 'write_file': { + const wp = a.path || ''; + const savedW = [...this.terminal.fs.currentPath]; + if (wp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = wp.replace(/^\//, ''); + const content = a.content || ''; + this.terminal.fs.writeFile(path, content); + this.terminal.fs.currentPath = savedW; + return `File written: ${path} (${content.length} chars)`; + } + case 'make_directory': { + const mp = a.path || ''; + const savedM = [...this.terminal.fs.currentPath]; + if (mp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = mp.replace(/^\//, ''); + const ok = this.terminal.fs.mkdir(path); + this.terminal.fs.currentPath = savedM; + if (ok) return `Directory created: ${path}`; + return JSON.stringify({ error: `Cannot create: ${path}` }); + } + case 'delete_file': { + const dp = a.path || ''; + const savedD = [...this.terminal.fs.currentPath]; + if (dp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = dp.replace(/^\//, ''); + const delOk = this.terminal.fs.deleteFile(path); + this.terminal.fs.currentPath = savedD; + if (delOk) return `Deleted: ${path}`; + return JSON.stringify({ error: `Cannot delete: ${path}` }); + } + case 'get_status': { + const fs = this.terminal.fs; + const mm = this.terminal.moduleManager; + const om = this.terminal.ollamaManager; + return JSON.stringify({ + files: fs.ls().length, + dirs: fs.currentPath.length ? '/' + fs.currentPath.join('/') : '/', + modules: Object.keys(mm.modules).length, + ollama: om.connected ? `Connected (${om.currentModel})` : 'Disconnected', + memory: performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A' + }); + } + case 'get_help': { + const cmds = this.terminal.moduleManager.getAllCommands(); + return JSON.stringify(Object.keys(cmds).sort()); + } + case 'get_current_dir': { + return '/' + this.terminal.fs.currentPath.join('/'); + } + case 'web_search': { + const query = a.query || ''; + if (!query) return JSON.stringify({ error: 'No query provided' }); + try { + const resp = await fetch('/api/search?q=' + encodeURIComponent(query)); + if (!resp.ok) { return JSON.stringify({ error: 'Search API returned HTTP ' + resp.status }); } + const data = await resp.json(); + const results = data.results || []; + if (results.length === 0) return JSON.stringify({ message: 'No results found' }); + return JSON.stringify({ count: results.length, source: data.source, results: results.map(r => ({ title: r.title, url: r.url, content: (r.content || '').substring(0, 300) })) }); + } catch (e) { return JSON.stringify({ error: 'Search failed: ' + e.message }); } + } + case 'web_fetch': { + const url = a.url || ''; + if (!url) return JSON.stringify({ error: 'No URL provided' }); + try { + const resp = await fetch('/api/extract?url=' + encodeURIComponent(url)); + if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' }); + if (!resp.ok) { return JSON.stringify({ error: 'Fetch API returned HTTP ' + resp.status }); } + const data = await resp.json(); + return JSON.stringify({ title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method: data.method || 'extract' }); + } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } + } + case 'wpm_install': { + const pkg = a.package || ''; + if (!pkg) return JSON.stringify({ error: 'No package name' }); + const mod = this.terminal.moduleManager.modules['WpmModule']; + if (mod) { await mod.install(pkg); return `Package "${pkg}" installation attempted`; } + return JSON.stringify({ error: 'WPM module not available' }); + } + case 'wpm_search': { + const kw = a.keyword || ''; + if (!kw) return JSON.stringify({ error: 'No keyword' }); + const mod = this.terminal.moduleManager.modules['WpmModule']; + if (mod) { + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + try { await mod.search(kw); } finally { this.terminal.print = originalPrint; } + return output.trim() || 'Search completed'; + } + return JSON.stringify({ error: 'WPM module not available' }); + } + case 'run_command': { + const cmd = a.command || ''; + if (!cmd) return JSON.stringify({ error: 'No command provided' }); + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + try { await this.terminal.dispatchCommand(cmd.split(' ')[0].toLowerCase(), cmd.split(' ').slice(1)); } finally { this.terminal.print = originalPrint; } + return output.trim() || '(no output)'; + } + case 'check_updates': { + const reg = this.terminal.moduleManager.moduleRegistry; + const results = []; + for (const mod of reg) { + if (mod.url && mod.url.startsWith('http')) { + try { + const resp = await fetch(mod.url, { method: 'HEAD' }); + results.push({ name: mod.name, url: mod.url, status: resp.ok ? 'reachable' : 'error (' + resp.status + ')' }); + } catch (e) { + results.push({ name: mod.name, url: mod.url, status: 'unreachable' }); + } + } + } + return JSON.stringify({ checked: results.length, modules: results }); + } + case 'user_get': { + const key = a.key || ''; + if (!key) return JSON.stringify({ error: 'Key is required' }); + const val = key.split('.').reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, this._userProfile); + return JSON.stringify({ key, value: val !== undefined ? val : null }); + } + case 'user_set': { + const ukey = a.key || ''; + if (!ukey) return JSON.stringify({ error: 'Key and value are required' }); + const uval = a.value || ''; + const keys = ukey.split('.'); + let obj = this._userProfile; + for (let i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') obj[keys[i]] = {}; + obj = obj[keys[i]]; + } + obj[keys[keys.length - 1]] = uval; + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + return JSON.stringify({ success: true, key: ukey, value: uval }); + } + case 'user_list': { + return JSON.stringify({ profile: this._userProfile, keys: Object.keys(this._userProfile) }); + } + case 'user_forget': { + const fkey = a.key || ''; + if (!fkey) return JSON.stringify({ error: 'Key is required' }); + if (fkey.includes('.')) { + const parts = fkey.split('.'); + let obj = this._userProfile; + for (let i = 0; i < parts.length - 1; i++) { + if (!obj[parts[i]]) return JSON.stringify({ error: 'Key not found' }); + obj = obj[parts[i]]; + } + delete obj[parts[parts.length - 1]]; + } else { + delete this._userProfile[fkey]; + } + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + return JSON.stringify({ success: true, key: fkey }); + } + case 'session_save': { + const summary = a.summary || 'No summary provided'; + const fs = this.terminal.fs; + fs.mkdirRecursive('/memory'); + const today = new Date().toISOString().split('T')[0]; + const log = { date: today, summary, toolCallCount: 0, skillsCreated: [], modulesEdited: [], timestamp: new Date().toISOString() }; + fs.fsWriteJSON('/memory/' + today + '.json', log); + const index = fs.fsReadJSON('/memory/index.json') || {}; + index[today] = { summary, tasks: (index[today]?.tasks || 0) + 1, updated: new Date().toISOString() }; + fs.fsWriteJSON('/memory/index.json', index); + return JSON.stringify({ success: true, date: today }); + } + case 'session_load': { + const sdate = a.date || ''; + if (!sdate) return JSON.stringify({ error: 'Date required (YYYY-MM-DD)' }); + const sfs = this.terminal.fs; + const slog = sfs.fsReadJSON('/memory/' + sdate + '.json'); + if (!slog) return JSON.stringify({ error: 'No session log for ' + sdate }); + return JSON.stringify(slog); + } + case 'session_recent': { + const count = a.count || 3; + const rfs = this.terminal.fs; + const rindex = rfs.fsReadJSON('/memory/index.json') || {}; + const dates = Object.keys(rindex).sort().reverse().slice(0, count); + const sessions = dates.map(d => ({ date: d, ...rindex[d] })); + return JSON.stringify({ sessions, count: sessions.length }); + } + case 'session_stats': { + const sfs = this.terminal.fs; + const sindex = sfs.fsReadJSON('/memory/index.json') || {}; + const entries = Object.entries(sindex); + let totalTasks = 0; + for (const [, meta] of entries) totalTasks += meta.tasks || 0; + return JSON.stringify({ totalSessions: entries.length, totalTasks, dates: entries.map(([d]) => d) }); + } + case 'load_skills': { + const query = (a && a.query || '').toLowerCase(); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + if (!query) { + const names = Object.keys(index); + return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' }); + } + const matches = []; + for (const [name, meta] of Object.entries(index)) { + const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0); + if (score > 0) { + const content = fs.fsRead(`/skills/${name}.md`) || ''; + matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) }); + } + } + matches.sort((a, b) => b.score - a.score); + const top = matches.slice(0, 3); + return JSON.stringify(top.length > 0 ? { matches: top.map(m => ({ name: m.name, description: m.description, content: m.content })), count: top.length } : { matches: [], count: 0, message: 'No matching skills found. You can create one after this task.' }); + } + case 'create_skill': { + const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase(); + if (!name) return JSON.stringify({ error: 'Skill name is required' }); + const description = a.description || ''; + let content = a.content || ''; + const tags = (a.tags || '').split(',').map(t => t.trim()).filter(Boolean); + const category = a.category || 'general'; + if (!content.startsWith('---')) { + content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: ${category}\n---\n## Steps\n${content}`; + } + const fs = this.terminal.fs; + fs.mkdirRecursive('/skills'); + fs.fsWrite(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, tags, category, created: index[name]?.created || new Date().toISOString(), updated: new Date().toISOString() }; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`💡 Skill saved: ${name}`, 'success'); + return JSON.stringify({ success: true, name, message: `Skill "${name}" created (${content.length} chars)` }); + } + case 'patch_skill': { + const pname = (a.name || '').trim(); + if (!pname) return JSON.stringify({ error: 'Skill name is required' }); + const pcontent = a.content || ''; + if (!pcontent) return JSON.stringify({ error: 'Skill content is required' }); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` }); + fs.fsWrite(`/skills/${pname}.md`, pcontent); + index[pname].updated = new Date().toISOString(); + // Re-parse frontmatter for metadata updates + const metaMatch = pcontent.match(/^---\n([\s\S]*?)\n---/); + if (metaMatch) { + const metaLines = metaMatch[1].split('\n'); + metaLines.forEach(line => { + const [k, ...v] = line.split(':'); + if (k === 'description') index[pname].description = v.join(':').trim(); + if (k === 'tags') index[pname].tags = v.join(':').trim().replace(/[\[\]]/g, '').split(',').map(t => t.trim()); + if (k === 'category') index[pname].category = v.join(':').trim(); + }); + } + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`💡 Skill patched: ${pname}`, 'success'); + return JSON.stringify({ success: true, name: pname, message: `Skill "${pname}" updated` }); + } + case 'list_skills': { + const filterCat = (a.category || '').toLowerCase(); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + const list = Object.entries(index).map(([name, meta]) => ({ name, description: meta.description, category: meta.category || 'general', tags: meta.tags || [], updated: meta.updated })) + .filter(s => !filterCat || (s.category || '').toLowerCase() === filterCat); + return JSON.stringify({ skills: list, count: list.length }); + } + case 'view_module': + case 'edit_module': + case 'test_module': + case 'reload_module': + case 'list_modified_modules': + case 'get_module_status': + return await this.runModuleTool(name, argsStr); + default: + return JSON.stringify({ error: `Unknown tool: ${name}` }); + } + } catch (e) { + this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); + return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); + } + } + async runModuleTool(name, argsStr) { + const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { + case 'view_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + const code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (code) return code; + const url = entry ? entry.url : null; + if (!url) return JSON.stringify({ error: `No source URL for ${modName}` }); + try { + const resp = await fetch(url); + if (resp.ok) { + const src = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src); + return src.substring(0, 8000); + } + return JSON.stringify({ error: `HTTP ${resp.status}` }); + } catch (e) { return JSON.stringify({ error: e.message }); } + } + case 'edit_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + const url = entry ? entry.url : null; + let code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (!code && url) { + try { + const resp = await fetch(url); + if (resp.ok) { + code = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ originalUrl: url, modifiedAt: new Date().toISOString(), editCount: 0 })); + } + } catch (e) {} + } + if (code) this.terminal.openCodeEditor(modName, url || '', code); + return code ? `Editor opened for ${modName} (${code.length} chars)` : JSON.stringify({ error: `Could not load source for ${modName}` }); + } + case 'test_module': { + const modName = a.name || ''; + const code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); + this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); + return `Module "${modName}" loaded from local edit for testing`; + } + case 'reload_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + this.terminal.fs.deleteFile(`_mods/${modName}.js`); + this.terminal.fs.deleteFile(`_mods/${modName}.orig.js`); + this.terminal.fs.deleteFile(`_mods/${modName}.meta.json`); + if (entry && entry.url) { + this.terminal.moduleManager.unloadModule(modName); + await this.terminal.moduleManager.loadModuleFromURL(entry.url); + return `Module "${modName}" reloaded from ${entry.url}`; + } + return JSON.stringify({ error: `No original URL for ${modName}` }); + } + case 'list_modified_modules': { + const modsDir = this.terminal.fs.getCurrentDir(); + const mc = modsDir.content['_mods']; + if (!mc || Object.keys(mc.content).length === 0) return JSON.stringify({ modified: [], message: 'No local edits' }); + const files = Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')); + return JSON.stringify({ modified: files.map(f => f.replace('.js', '')), count: files.length }); + } + case 'get_module_status': { + const mm = this.terminal.moduleManager; + const modsDir = this.terminal.fs.getCurrentDir(); + const hasMods = modsDir.content['_mods'] ? !!Object.keys(modsDir.content['_mods'].content).length : false; + const modules = mm.moduleRegistry.map(m => ({ + name: m.name, url: m.url, enabled: m.enabled, + loaded: !!mm.modules[m.name], + modified: !!this.terminal.fs.readFile(`_mods/${m.name}.js`) + })); + return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods }); + } + default: return JSON.stringify({ error: `Unknown module tool: ${name}` }); + } + } + clearContext() { + this.context = []; + localStorage.removeItem('webx11_ai_context'); + this.terminal.print('AI conversation context cleared', 'success'); + } + _saveContext() { + try { + const data = JSON.stringify({ + context: this.context.slice(-20), + messageCount: this._messageCount, + savedAt: Date.now() + }); + localStorage.setItem('webx11_ai_context', data); + // Also persist to VFS for crash survival + try { + const fs = this.terminal.fs; + if (fs) { + fs.mkdirRecursive('_sessions'); + fs.writeFile('_sessions/_auto-context.json', data); + } + } catch (e) {} + } catch (e) { + // localStorage quota exceeded — silently ignore + } + } + _loadContext() { + try { + const saved = localStorage.getItem('webx11_ai_context'); + if (saved) { + const data = JSON.parse(saved); + if (data && data.context) { + this.context = data.context.slice(-20); + this._messageCount = data.messageCount || 0; + } + } + } catch (e) {} + } + setSystemPrompt(args) { + if (!args[0]) { + this.terminal.print(`Current system prompt: ${this.systemPrompt.substring(0, 50)}...`, 'info'); return; + } + this.systemPrompt = args.join(' '); + this.terminal.print('System prompt updated', 'success'); + } + showModel(args) { + const om = this.terminal.ollamaManager; + if (args.length > 0) { + const name = args.join(' '); + if (om.models && om.models.some(m => m.name === name)) { + om.currentModel = name; + localStorage.setItem('webx11_ollama_model', name); + this.terminal.print(`AI model set to: ${name}`, 'success'); + } else { + this.terminal.print(`Model "${name}" not found in available models. Use ai-model to list.`, 'warning'); + } + return; + } + this.terminal.print(`Current AI model: ${om.currentModel || 'none'}`, 'info'); + if (om.models && om.models.length > 0) { + const maxShow = 20; + const names = om.models.slice(0, maxShow).map(m => m.name).join(', '); + this.terminal.print(`Available (${om.models.length}): ${names}${om.models.length > maxShow ? `... +${om.models.length - maxShow} more` : ''}`, 'info'); + } else { + this.terminal.print('No models available. Use ollamaconnect first.', 'warning'); + } + } + showContext() { + if (this.context.length === 0) { + this.terminal.print('No conversation context', 'info'); return; + } + this.terminal.print(`=== Context (${this.context.length} messages) ===`, 'info'); + this.context.forEach(msg => { + const icon = msg.role === 'user' ? '👤' : msg.role === 'assistant' ? '🤖' : '🔧'; + const content = typeof msg.content === 'string' ? msg.content.substring(0, 60) : JSON.stringify(msg).substring(0, 60); + this.terminal.print(`${icon} ${content}...`); + }); + } + _extractFacts(userMsg, aiResp) { + const patterns = [ + { re: /my name is (\w+)/i, key: 'name' }, + { re: /call me (\w+)/i, key: 'name' }, + { re: /i(?:'m| am) working on (.+)/i, key: 'project' }, + { re: /(?:my|the) project is (.+)/i, key: 'project' }, + { re: /i have a server at (.+)/i, key: 'server' }, + { re: /i use (\w+).*?(?:editor|ide|text)/i, key: 'preferredEditor' }, + { re: /i (?:like|prefer|love) (.+)/i, key: 'preference' }, + ]; + let changed = false; + for (const { re, key } of patterns) { + const m = (userMsg + ' ' + (aiResp || '')).match(re); + if (m && m[1]) { + if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {}; + if (this._userProfile.knownFacts[key] !== m[1].trim()) { + this._userProfile.knownFacts[key] = m[1].trim(); + changed = true; + } + } + } + if (changed) { + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + } + } + async _seedDefaultSkills() { + const fs = this.terminal.fs; + const mindmapSkill = `--- +name: mindmap-user-guide +description: How to create, view, and manage brainstorming mindmaps using the mindmap terminal module +tags: [mindmap, brainstorming, organization, planning] +category: productivity +--- +## Steps + +1. **Create a new mindmap:** + - \`mindmap new "<topic>" -t tag1,tag2\` — creates a root node + - Tags are optional (e.g. planning, research, dev) + - Root gets auto-assigned a color + +2. **Add child nodes:** + - \`mindmap add <parent-id> "<idea>" -t tag\` — add a child + - Get the parent-id from \`mindmap show\` + - IDs are 4-char random strings (e.g. a3f8, x7k2) + +3. **View the mindmap:** + - \`mindmap show\` — full tree with Unicode box-drawing + - \`mindmap show <id>\` — subtree starting at that node + +4. **Edit nodes:** + - \`mindmap rename <id> "<new text>"\` — change text + - \`mindmap color <id> <color>\` — set color (red/green/yellow/blue/cyan/magenta/white) + - \`mindmap tag <id> <tag>\` — add a tag + +5. **Persistence:** + - \`mindmap save <name>\` — save to localStorage + - \`mindmap load <name>\` — restore + - \`mindmap list\` — show saved mindmaps + - \`mindmap delete <name>\` — remove saved + +6. **Export:** + - \`mindmap export [id]\` — Markdown bullet list output + +## When to use +- User wants to brainstorm, organize ideas, plan features +- User says "map out", "diagram", "brainstorm", "organize thoughts" +- Visualizing project structure or task decomposition`; + + fs.fsWrite('/skills/mindmap-user-guide.md', mindmapSkill); + const existingIndex = fs.fsReadJSON('/skills/index.json') || {}; + existingIndex['mindmap-user-guide'] = { name: 'mindmap-user-guide', description: 'How to create, view, and manage brainstorming mindmaps using the mindmap terminal module', tags: ['mindmap', 'brainstorming', 'organization', 'planning'], category: 'productivity', created: existingIndex['mindmap-user-guide']?.created || new Date().toISOString(), updated: new Date().toISOString() }; + fs.fsWriteJSON('/skills/index.json', existingIndex); + fs.fsWrite('/skills/_seeded', '1'); + } + _trackPattern(userMessage) { + const lower = userMessage.toLowerCase(); + const patterns = ['font size','bigger text','smaller text','export','backup','save','install','load module','search','find','color','theme','skill','learn','remember','mindmap','brainstorm']; + for (const p of patterns) { + if (lower.includes(p)) this._commandFreq[p] = (this._commandFreq[p] || 0) + 1; + } + try { localStorage.setItem('webx11_ai_patterns', JSON.stringify(this._commandFreq)); } catch (e) {} + } + async _autoCreateErrorSkill() { + const grouped = {}; + for (const e of this._errorLog) { + if (!grouped[e.tool]) grouped[e.tool] = []; + grouped[e.tool].push(e); + } + for (const [tool, errors] of Object.entries(grouped)) { + if (errors.length >= 3) { + const skillName = 'tool-' + tool + '-workaround'; + const existing = this.terminal.fs.fsRead('/skills/' + skillName + '.md'); + if (existing) continue; + const steps = errors.map((e, i) => (i + 1) + '. When calling ' + tool + ', avoid: "' + JSON.stringify(e.args) + '" which caused: ' + e.error).join('\n'); + const content = '---\nname: ' + skillName + '\ndescription: Workarounds for ' + tool + ' failures\ntags: [' + tool + ', error, workaround]\ncategory: system\n---\n## Steps\n' + steps; + await this.runTool('create_skill', { name: skillName, description: 'Workarounds for ' + tool + ' failures', content: content, tags: tool + ',error,workaround', category: 'system' }); + this.terminal.print('🤖 Auto-created skill from repeated errors: ' + skillName, 'info'); + } + } + this._errorLog = []; + } + async _auditSkills() { + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + const now = Date.now(); + const issues = []; + for (const [name, meta] of Object.entries(index)) { + const content = fs.fsRead('/skills/' + name + '.md'); + if (!content || content.length < 50) { + issues.push({ name, issue: 'empty or too short' }); + continue; + } + const updated = new Date(meta.updated || 0).getTime(); + const ageDays = (now - updated) / 86400000; + if (ageDays > 30) issues.push({ name, issue: 'not updated in ' + Math.round(ageDays) + ' days' }); + } + if (issues.length > 0) { + this.terminal.print('📋 Skill audit: ' + issues.length + ' issue(s) found', 'info'); + issues.slice(0, 3).forEach(i => this.terminal.print(' - ' + i.name + ': ' + i.issue, 'warning')); + } + } + _estimateTokens(text) { + if (!text || typeof text !== 'string') return 0; + return Math.ceil(text.length / 4); + } + _estimateContextTokens() { + let total = 0; + for (const msg of this.context) { + total += this._estimateTokens(msg.content || ''); + } + return total; + } + async _compressContext() { + if (this.context.length <= 6) return; + const totalTokens = this._estimateContextTokens(); + if (totalTokens < this.maxContextTokens) return; + const keepCount = 3; + const oldMsgs = this.context.slice(0, -keepCount * 2); + if (oldMsgs.length < 2) return; + const recentMsgs = this.context.slice(-keepCount * 2); + const summaryText = oldMsgs.map(m => + (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + + (m.content || '').substring(0, 150) + ).join(' | '); + let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`; + // Try AI summarization, fallback to truncation + try { + if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) { + const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) }))); + const sumRes = await this.terminal.ollamaManager.chat(sumPrompt); + if (sumRes && sumRes.length < 300) { + compressed = '[Earlier context: ' + sumRes.trim() + ']'; + } + } + } catch (e) { /* fallback to truncation */ } + this.context = [{ role: 'system', content: compressed }, ...recentMsgs]; + this.terminal.print('📦 Context compressed (' + oldMsgs.length + ' messages → 1 summary, ~' + totalTokens + ' tokens)', 'info'); + } + _manualCompress() { + this._compressContext().then(() => { + this.terminal.print('Context: ' + this.context.length + ' message(s), ~' + this._estimateContextTokens() + ' estimated tokens', 'info'); + }).catch(e => { + this.terminal.print('Compression error: ' + e.message, 'error'); + }); + } + } diff --git a/modules/core/module-chatstudio.js b/modules/core/module-chatstudio.js new file mode 100644 index 0000000..5777acf --- /dev/null +++ b/modules/core/module-chatstudio.js @@ -0,0 +1,1060 @@ +/* + * ChatStudio Overlay Module for newOS4 v4.2 + * Integrates ChatStudio Lite's full chat UI as an overlay launched via `chat` command. + * All Lite engine functions are inlined from lite/cslite-core.js, adapted to use + * the terminal's OllamaManager for endpoint configuration. + */ +class ChatStudioModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._overlay = null; + this._active = false; + this._cssLoaded = false; + this._escapeHandler = null; + this._initialized = false; + } + + getHelpInfo() { + return { + 'chat': 'Toggle the ChatStudio overlay', + 'chat-setapi': 'Set the API base URL for ChatStudio', + 'chat-setmodel': 'Set the default model for ChatStudio', + 'chat-ask': 'Ask a question directly via ChatStudio' + }; + } + + registerCommands() { + this.commands['chat'] = () => this.toggleChatOverlay(); + this.commands['chat-setapi'] = (args) => { + if (!args[0]) { + const base = window.API_BASE || '/api'; + this.terminal.print('Current API base: ' + base, 'info'); + this.terminal.print('Usage: chat-setapi <base-url>', 'info'); + this.terminal.print('Example: chat-setapi /api', 'info'); + return; + } + const url = args[0].replace(/\/+$/, ''); + window.API_BASE = url; + try { + var settings = JSON.parse(localStorage.getItem('webx11_chat_settings') || '{}'); + settings.apiBase = url; + localStorage.setItem('webx11_chat_settings', JSON.stringify(settings)); + } catch (e) {} + this.terminal.print('API base set to: ' + url + ' (reopen chat to apply)', 'success'); + }; + } + + toggleChatOverlay() { + if (this._active) { + this._closeOverlay(); + } else { + this._openOverlay(); + } + } + + _loadOverlayAssets() { + return new Promise((resolve, reject) => { + // Load CSS + if (!this._cssLoaded) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'chat/chat-ui.css?v=4'; + link.onload = () => { this._cssLoaded = true; }; + link.onerror = () => reject(new Error('Failed to load chat/chat-ui.css')); + document.head.appendChild(link); + } + + // Expose ALL Lite globals BEFORE scripts load so chat-modules.js & chat-commands.js can use them + if (!window._csGlobalsInited) { + window.OLLAMA_URL = (this.terminal && this.terminal.ollamaManager && this.terminal.ollamaManager.endpoint) || '/ollama'; + window.OLLAMA_API_KEY = ''; + window.DEFAULT_MODEL = 'llama3.2'; + window.USE_STREAMING = true; + window.API_BASE = '/api'; + window.latestAiContent = ''; + window.logs = []; + window.messages = []; + window.isStreaming = false; + window.CHATSTUDIO_VERSION = '1.0.0-lite-inline'; + window.modules = []; + window.currentChatModule = 'default'; + + // Utility functions + window.utf8_to_b64 = function(str) { return btoa(unescape(encodeURIComponent(str))); }; + window.b64_to_utf8 = function(str) { return decodeURIComponent(escape(atob(str))); }; + window.escapeHtml = function(text) { + if (typeof text !== 'string') return ''; + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"'); + }; + window.generateId = function() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); }; + window.showToast = function(msg) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.style.display = 'block'; + setTimeout(function() { t.style.display = 'none'; }, 2500); + }; + window.addLog = function(action, detail) { + // Placeholder — replaced by full impl in _initLiteEngine + }; + // Placeholder functions — replaced by full implementations in _initLiteEngine + window.addMessage = function(role, content) {}; + window.showTyping = function() {}; + window.hideTyping = function() {}; + window.loadSettings = function() {}; + window.saveSettings = function() {}; + window.loadModels = function() {}; + window.switchModule = function(id) {}; + window.getCurrentModule = function() {}; + window.sendMessage = function() {}; + window.clearChat = function() {}; + window.closeSettingsModal = function() {}; + window.openSettingsModal = function() {}; + window._csGlobalsInited = true; + } + + // Fetch HTML template + fetch('chat/chat-ui.html?v=4') + .then(r => r.text()) + .then(html => { + // Insert overlay into body if not already there + if (!document.getElementById('chat-overlay')) { + const temp = document.createElement('div'); + temp.innerHTML = html; + document.body.appendChild(temp.firstElementChild); + + // Load commands.js and modules.js dynamically + const loadScript = (src) => { + return new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = src; + s.onload = () => res(); + s.onerror = () => rej(new Error('Failed to load ' + src)); + document.body.appendChild(s); + }); + }; + + Promise.all([ + loadScript('chat/chat-commands.js?v=4'), + loadScript('chat/chat-modules.js?v=4') + ]).then(() => { + resolve(); + }); + } else { + resolve(); + } + }) + .catch(() => reject(new Error('Failed to load chat/chat-ui.html'))); + }); + } + + async _openOverlay() { + if (this._active) return; + this._active = true; + + try { + // Load overlay assets first (CSS, HTML, scripts) + await this._loadOverlayAssets(); + + // Initialize the Lite engine + this._initLiteEngine(); + + // Show overlay + const overlay = document.getElementById('chat-overlay'); + if (overlay) { + overlay.style.display = 'flex'; + overlay.style.flexDirection = 'column'; + } + + // Add Escape listener + this._escapeHandler = (e) => { + if (e.key === 'Escape') { + this._closeOverlay(); + } + }; + document.addEventListener('keydown', this._escapeHandler); + + // Set window-level endpoint for Lite to use + if (this.terminal && this.terminal.ollamaManager) { + window.ollamaEndpoint = this.terminal.ollamaManager.endpoint; + } + } catch (err) { + // Print error to terminal + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay failed: ' + err.message, 'error'); + } + + // Show a toast on the main page if possible + if (typeof window.showToast === 'function') { + window.showToast('Chat overlay failed: ' + err.message); + } + + // Clean up any partial overlay DOM + const partialOverlay = document.getElementById('chat-overlay'); + if (partialOverlay) { + partialOverlay.remove(); + } + + // Reset state so user can retry + this._active = false; + } + } + + _closeOverlay() { + if (!this._active) return; + this._active = false; + + const overlay = document.getElementById('chat-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + + // Remove Escape listener + if (this._escapeHandler) { + document.removeEventListener('keydown', this._escapeHandler); + this._escapeHandler = null; + } + + // Focus terminal input + if (this.terminal && this.terminal.inputField) { + setTimeout(() => this.terminal.inputField.focus(), 50); + } + } + + _initLiteEngine() { + // Already initialized — no-op + if (this._initialized) return; + + // Set the overlay reference + this._overlay = document.getElementById('chat-overlay'); + if (!this._overlay) { + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay: #chat-overlay element not found', 'error'); + } + return; + } + + this._initialized = true; + + try { + // Close button already exists in HTML as "✕ Close" button calling closeChatOverlay() + window.closeChatOverlay = () => this._closeOverlay(); + + // ── Constants ── + const CHATSTUDIO_VERSION = '1.0.0-lite-inline'; + const API_KEY = 'webx11_chat_api'; + const MESSAGES_KEY = 'cslite_messages'; + const SETTINGS_KEY = 'webx11_chat_settings'; + const LOGS_KEY = 'webx11_chat_logs'; + + // ── State ── + let OLLAMA_URL = '/ollama'; + let OLLAMA_API_KEY = ''; + let DEFAULT_MODEL = 'llama3.2'; + let USE_STREAMING = true; + let API_BASE = '/api'; + let latestAiContent = ''; + let logs = []; + let messages = []; + let isStreaming = false; + + // Sync OLLAMA_URL from terminal + if (this.terminal && this.terminal.ollamaManager) { + OLLAMA_URL = this.terminal.ollamaManager.endpoint || '/ollama'; + } + + // ── Utility Functions ── + function escapeHtml(text) { + if (typeof text !== 'string') return ''; + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + function utf8_to_b64(str) { + return btoa(unescape(encodeURIComponent(str))); + } + + function b64_to_utf8(str) { + return decodeURIComponent(escape(atob(str))); + } + + function generateId() { + return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); + } + + function showToast(msg) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.style.display = 'block'; + setTimeout(function () { + t.style.display = 'none'; + }, 2500); + } + + function autoExpand(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + } + + function toggleSidebar() { + var s = document.getElementById('sidebar'); + if (s) s.classList.toggle('open'); + var b = document.getElementById('sidebarBackdrop'); + if (b) b.classList.toggle('open'); + } + function closeSidebar() { + var s = document.getElementById('sidebar'); + if (s) s.classList.remove('open'); + var b = document.getElementById('sidebarBackdrop'); + if (b) b.classList.remove('open'); + } + + // ── Log System ── + function addLog(action, detail) { + var time = new Date().toLocaleTimeString(); + logs.unshift({ time: time, action: action, detail: detail }); + if (logs.length > 50) logs.pop(); + renderLogs(); + try { + localStorage.setItem(LOGS_KEY, utf8_to_b64(JSON.stringify(logs))); + } catch (e) { /* ignore */ } + } + + function renderLogs() { + var container = document.getElementById('logsContent'); + if (!container) return; + container.innerHTML = logs.map(function (l) { + return '<div class="log-entry">' + + '<span class="log-time">' + escapeHtml(l.time) + '</span>' + + '<span class="log-action">' + escapeHtml(l.action) + '</span>' + + '<span class="log-detail">' + escapeHtml(l.detail) + '</span>' + + '</div>'; + }).join(''); + } + + function copyLogs() { + var text = logs.map(function (l) { + return l.time + ' | ' + l.action + ' | ' + l.detail; + }).join('\n'); + navigator.clipboard.writeText(text).then(function () { + showToast('Logs copied!'); + }); + } + + function clearLogs() { + if (!confirm('Clear all activity logs?')) return; + logs = []; + try { + localStorage.removeItem(LOGS_KEY); + } catch (e) { /* ignore */ } + renderLogs(); + showToast('Logs cleared'); + } + + function loadLogs() { + try { + var saved = localStorage.getItem(LOGS_KEY); + if (saved) logs = JSON.parse(b64_to_utf8(saved)); + } catch (e) { + logs = []; + } + renderLogs(); + } + + // ── Settings ── + function loadSettings() { + try { + var saved = localStorage.getItem(SETTINGS_KEY); + if (saved) { + var settings = JSON.parse(b64_to_utf8(saved)); + OLLAMA_URL = settings.ollamaUrl || '/ollama'; + OLLAMA_API_KEY = settings.ollamaApiKey || ''; + DEFAULT_MODEL = settings.defaultModel || 'llama3.2'; + USE_STREAMING = settings.useStreaming !== false; + API_BASE = settings.apiBase || '/api'; + } + } catch (e) { + console.warn('Failed to load settings:', e); + } + // Sync from terminal if available + var term = window.webx11; + if (term && term.ollamaManager) { + OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; + } + var urlEl = document.getElementById('ollamaUrl'); + var keyEl = document.getElementById('ollamaApiKey'); + var modelEl = document.getElementById('defaultModel'); + var streamEl = document.getElementById('streamingToggle'); + var apiEl = document.getElementById('apiBase'); + if (urlEl) urlEl.value = OLLAMA_URL; + if (keyEl) keyEl.value = OLLAMA_API_KEY; + if (modelEl) modelEl.value = DEFAULT_MODEL; + if (streamEl) streamEl.checked = USE_STREAMING; + if (apiEl) apiEl.value = API_BASE; + } + + function saveSettings() { + OLLAMA_URL = document.getElementById('ollamaUrl').value.trim() || '/ollama'; + OLLAMA_API_KEY = document.getElementById('ollamaApiKey').value.trim(); + DEFAULT_MODEL = document.getElementById('defaultModel').value.trim() || 'llama3.2'; + USE_STREAMING = document.getElementById('streamingToggle').checked; + API_BASE = document.getElementById('apiBase').value.trim() || '/api'; + try { + var settings = { + ollamaUrl: OLLAMA_URL, + ollamaApiKey: OLLAMA_API_KEY, + defaultModel: DEFAULT_MODEL, + useStreaming: USE_STREAMING, + apiBase: API_BASE + }; + localStorage.setItem(SETTINGS_KEY, utf8_to_b64(JSON.stringify(settings))); + } catch (e) { + console.warn('Failed to save settings:', e); + } + closeSettingsModal(); + showToast('Settings saved!'); + } + + function openSettingsModal() { + loadSettings(); + var m = document.getElementById('settingsModal'); + if (m) m.classList.add('open'); + } + + function closeSettingsModal() { + var m = document.getElementById('settingsModal'); + if (m) m.classList.remove('open'); + } + + async function testConnection() { + var status = document.getElementById('connectionStatus'); + if (!status) return; + status.textContent = 'Testing...'; + status.style.color = 'var(--text2)'; + try { + var headers = { 'Content-Type': 'application/json' }; + if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; + var response = await fetch(OLLAMA_URL + '/api/tags', { headers: headers }); + if (response.ok) { + var data = await response.json(); + var modelCount = data.models ? data.models.length : 0; + status.textContent = 'Connected! ' + modelCount + ' models available.'; + status.style.color = 'var(--accent)'; + } else { + status.textContent = 'Connection failed: HTTP ' + response.status; + status.style.color = 'var(--danger)'; + } + } catch (e) { + status.textContent = 'Error: ' + e.message; + status.style.color = 'var(--danger)'; + } + } + + // ── Models ── + async function loadModels() { + try { + var response = await fetch(OLLAMA_URL + '/api/tags'); + if (!response.ok) throw new Error('Failed to fetch models'); + var data = await response.json(); + var select = document.getElementById('modelSelect'); + if (!select) return; + var shortNames = { + 'llama3.2': 'Llama3.2', + 'llama3': 'Llama3', + 'gemma3:4b': 'Gemma3-4B', + 'gemma3:1b': 'Gemma3-1B', + 'gemma2:2b': 'Gemma2', + 'qwen2.5:7b-instruct': 'Qwen2.5', + 'qwen3:8b': 'Qwen3', + 'mistral': 'Mistral', + 'tinyllama': 'Tiny' + }; + select.innerHTML = data.models.map(function (m) { + var name = m.name.split(':')[0]; + var display = shortNames[m.name] || name.substring(0, 14); + return '<option value="' + escapeHtml(m.name) + '">' + escapeHtml(display) + '</option>'; + }).join(''); + addLog('Init', 'Loaded ' + data.models.length + ' models'); + } catch (e) { + console.error('Failed to load models:', e); + var select = document.getElementById('modelSelect'); + if (select) select.innerHTML = '<option value="llama3.2">llama3.2 (default)</option>'; + } + } + + var modelSelect = document.getElementById('modelSelect'); + if (modelSelect) { + modelSelect.addEventListener('change', function () { + addLog('Model', 'Switched to ' + this.value); + }); + } + + // ── Message History ── + function saveMessages(msgs) { + msgs = msgs || messages; + try { + localStorage.setItem(MESSAGES_KEY, JSON.stringify(msgs)); + } catch (e) { + console.warn('Failed to save messages:', e); + } + } + + function loadMessages() { + try { + var raw = localStorage.getItem(MESSAGES_KEY); + if (raw) { + messages = JSON.parse(raw); + } + } catch (e) { + console.warn('Failed to load messages:', e); + messages = []; + } + } + + function renderMessagesFromData() { + var container = document.getElementById('chatContainer'); + if (!container) return; + if (!messages || messages.length === 0) { + return; + } + container.innerHTML = ''; + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + appendMessageToDOM(msg.role, msg.content, msg.id, false); + } + container.scrollTop = container.scrollHeight; + } + + function appendMessageToDOM(role, content, msgId, save) { + if (save === undefined) save = true; + var container = document.getElementById('chatContainer'); + if (!container) return; + var div = document.createElement('div'); + div.className = 'message ' + role; + if (msgId) div.dataset.msgId = msgId; + var previewBtn = role === 'ai' + ? '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + : ''; + if (role === 'ai') latestAiContent = content; + var renderedContent = role === 'user' ? escapeHtml(content) : formatContent(content); + div.innerHTML = '<div class="role">' + + '<span>' + (role === 'user' ? 'You' : 'AI') + '</span>' + + '<div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'' + role + '\')" title="Copy">📋</button>' + + previewBtn + + '</div></div>' + + '<div class="msg-content">' + renderedContent + '</div>'; + div.dataset.content = content; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + if (save) { + saveMessages(); + } + } + + function addMessage(role, content) { + var msgId = generateId(); + var msg = { + id: msgId, + role: role, + content: content, + timestamp: Date.now() + }; + messages.push(msg); + appendMessageToDOM(role, content, msgId, true); + return msgId; + } + + // ── Content Formatting ── + function formatContent(text) { + // Step 1: Extract code blocks into placeholders + var blocks = []; + var processed = text + .replace(/```html\n?([\s\S]*?)```/g, function(m, code) { + var idx = blocks.length; + blocks.push({lang: 'html', code: code, isBlock: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }) + .replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) { + var idx = blocks.length; + blocks.push({lang: lang, code: code, isBlock: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }) + .replace(/`([^`]+)`/g, function(m, content) { + var idx = blocks.length; + blocks.push({lang: '', code: content, isBlock: false, isInline: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }); + + // Step 2: Escape everything outside code blocks + processed = escapeHtml(processed); + + // Step 3: Apply markdown formatting on the escaped text + processed = processed + .replace(/\*\*([^*]+)\*\*/g, function(m, content) { return '<strong>' + content + '</strong>'; }) + .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') + .replace(/\n/g, '<br>'); + + // Step 4: Restore code blocks with proper HTML + processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, function(m, idx) { + idx = parseInt(idx); + var block = blocks[idx]; + if (!block) return m; + if (block.isInline) { + return '<code>' + escapeHtml(block.code) + '</code>'; + } + var id = 'code_' + Math.random().toString(36).substr(2, 9); + if (block.lang === 'html') { + return '<div class="code-block" data-code="' + encodeURIComponent(block.code) + '">' + + '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="previewCodeBlock(this)">👁️</button>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;margin-right:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code id="' + id + '">' + escapeHtml(block.code) + '</code></pre></div>'; + } + return '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code>' + escapeHtml(block.code) + '</code></pre>'; + }); + + return processed; + } + + // ── Copy & Preview ── + function copyMessage(btn, role) { + var content; + if (role === 'code') { + content = btn.nextElementSibling.textContent; + } else { + var msg = btn.closest('.message'); + content = msg.dataset.content; + } + navigator.clipboard.writeText(content).then(function () { + showToast('Copied!'); + }); + } + + function showHtmlPreview() { + var frame = document.getElementById('previewFrame'); + if (!frame) return; + var doc = frame.contentWindow.document; + doc.open(); + doc.write(latestAiContent); + doc.close(); + var m = document.getElementById('previewModal'); + if (m) m.classList.add('open'); + } + + function previewCodeBlock(btn) { + var block = btn.closest('.code-block'); + if (!block) return; + var code = decodeURIComponent(block.dataset.code); + var frame = document.getElementById('previewFrame'); + if (!frame) return; + var doc = frame.contentWindow.document; + doc.open(); + doc.write(code); + doc.close(); + var m = document.getElementById('previewModal'); + if (m) m.classList.add('open'); + } + + function closePreviewModal() { + var m = document.getElementById('previewModal'); + if (m) m.classList.remove('open'); + } + + // ── Typing Indicator ── + function showTyping() { + var container = document.getElementById('chatContainer'); + if (!container) return; + var div = document.createElement('div'); + div.className = 'typing'; + div.id = 'typingIndicator'; + div.innerHTML = 'AI is thinking' + + '<span class="typing-dots">' + + '<span></span><span></span><span></span>' + + '</span>'; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + + function hideTyping() { + var t = document.getElementById('typingIndicator'); + if (t) t.remove(); + } + + // ── Send Message ── + async function sendMessage() { + if (isStreaming) return; + + var input = document.getElementById('chatInput'); + if (!input) return; + var msg = input.value.trim(); + if (!msg) return; + + input.value = ''; + input.style.height = 'auto'; + + addMessage('user', msg); + addLog('User', msg.substring(0, 50) + (msg.length > 50 ? '...' : '')); + + if (msg.startsWith('/')) { + await handleCommand(msg); + return; + } + + showTyping(); + + // Sync OLLAMA_URL from terminal before sending + var term = window.webx11; + if (term && term.ollamaManager) { + OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; + } + // Re-set window-level endpoint + window.ollamaEndpoint = OLLAMA_URL; + + var modelSelect = document.getElementById('modelSelect'); + var model = modelSelect ? modelSelect.value : DEFAULT_MODEL; + var mod = typeof getCurrentModule === 'function' ? getCurrentModule() : null; + var systemPrompt = mod && mod.prompt ? mod.prompt : ''; + var chatHistory = buildChatContext(); + + try { + var headers = { 'Content-Type': 'application/json' }; + if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; + + var fullPrompt = systemPrompt + ? systemPrompt + '\n\n' + chatHistory + '\n\nUser: ' + msg + '\n\nAI:' + : chatHistory + '\n\nUser: ' + msg + '\n\nAI:'; + + if (USE_STREAMING) { + await sendStreamingRequest(headers, model, fullPrompt); + } else { + await sendNonStreamingRequest(headers, model, fullPrompt); + } + } catch (e) { + hideTyping(); + console.error('Chat error:', e); + addMessage('ai', 'Sorry, I could not connect to the AI service. Make sure Ollama is running on your server.\n\nError: ' + e.message); + } + } + + async function sendStreamingRequest(headers, model, prompt) { + isStreaming = true; + hideTyping(); + + var msgId = generateId(); + var container = document.getElementById('chatContainer'); + if (!container) { isStreaming = false; return; } + var div = document.createElement('div'); + div.className = 'message ai'; + div.dataset.msgId = msgId; + div.innerHTML = '<div class="role"><span>AI</span><div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'ai\')" title="Copy">📋</button>' + + '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + + '</div></div><div class="msg-content"><span class="streaming-cursor"></span></div>'; + div.dataset.content = ''; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + + var contentEl = div.querySelector('.msg-content'); + var fullContent = ''; + + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: true + }) + }); + + if (!response.ok) { + contentEl.textContent = 'AI request failed: HTTP ' + response.status; + throw new Error('AI request failed: HTTP ' + response.status); + } + + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + var lastFormat = 0; + var formatInterval = 400; + + while (true) { + var result = await reader.read(); + if (result.done) break; + + buffer += decoder.decode(result.value, { stream: true }); + var lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) continue; + try { + var parsed = JSON.parse(line); + if (parsed.response) { + fullContent += parsed.response; + var now = Date.now(); + if (now - lastFormat >= formatInterval) { + contentEl.innerHTML = formatContent(fullContent) + '<span class="streaming-cursor"></span>'; + container.scrollTop = container.scrollHeight; + lastFormat = now; + } + } + if (parsed.done) { + break; + } + } catch (e) { + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + var parsed = JSON.parse(buffer); + if (parsed.response) { + fullContent += parsed.response; + } + } catch (e) { /* ignore */ } + } + + // Final render without cursor + contentEl.innerHTML = formatContent(fullContent); + div.dataset.content = fullContent; + latestAiContent = fullContent; + container.scrollTop = container.scrollHeight; + + messages.push({ + id: msgId, + role: 'ai', + content: fullContent, + timestamp: Date.now() + }); + saveMessages(); + addLog('AI', fullContent.substring(0, 50) + '...'); + + } catch (e) { + if (contentEl) contentEl.textContent = 'Error: ' + e.message; + console.error('Stream error:', e); + } + + isStreaming = false; + } + + async function sendNonStreamingRequest(headers, model, prompt) { + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false + }) + }); + + hideTyping(); + + if (!response.ok) throw new Error('AI request failed'); + + var data = await response.json(); + addMessage('ai', data.response || 'No response'); + addLog('AI', (data.response || '').substring(0, 50) + '...'); + } catch (e) { + hideTyping(); + throw e; + } + } + + function buildChatContext() { + var context = ''; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var role = m.role === 'user' ? 'User' : 'AI'; + var content = m.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + context += role + ': ' + content + '\n'; + } + return context; + } + + function getChatHistory() { + return buildChatContext(); + } + + // ── Clear & Share ── + function clearChat() { + if (!confirm('Clear chat history?')) return; + messages = []; + localStorage.removeItem(MESSAGES_KEY); + var container = document.getElementById('chatContainer'); + if (container) { + container.innerHTML = + '<div class="message ai">' + + '<div class="role">AI</div>' + + 'Chat cleared! How can I help you?' + + '</div>'; + } + addLog('Clear', 'Chat history cleared'); + } + + function shareChat() { + var msgs = []; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var role = m.role === 'user' ? 'User' : 'AI'; + msgs.push('**' + role + ':** ' + m.content); + } + var text = '# WebX11 AI Chat\n\n' + msgs.join('\n\n'); + + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function () { + showToast('Chat copied to clipboard!'); + addLog('Share', 'Chat copied to clipboard'); + }); + } else { + var blob = new Blob([text], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'chat-export.txt'; + a.click(); + URL.revokeObjectURL(url); + addLog('Share', 'Chat downloaded as file'); + } + } + + // ── Expose Lite globals for commands.js and modules.js ── + // All functions exposed under CSLite namespace AND as individual window.* + // for backward compatibility with chat-commands.js and chat-modules.js + window.CSLite = { + CHATSTUDIO_VERSION: CHATSTUDIO_VERSION, + OLLAMA_URL: OLLAMA_URL, + OLLAMA_API_KEY: OLLAMA_API_KEY, + DEFAULT_MODEL: DEFAULT_MODEL, + USE_STREAMING: USE_STREAMING, + API_BASE: API_BASE, + getApiUrl: function(path) { return (window.CSLite.API_BASE || '/api') + path; }, + latestAiContent: latestAiContent, + logs: logs, + messages: messages, + isStreaming: isStreaming, + modules: [], + currentChatModule: 'default', + escapeHtml: escapeHtml, + utf8_to_b64: utf8_to_b64, + b64_to_utf8: b64_to_utf8, + generateId: generateId, + showToast: showToast, + autoExpand: autoExpand, + toggleSidebar: toggleSidebar, + closeSidebar: closeSidebar, + addLog: addLog, + renderLogs: renderLogs, + loadSettings: loadSettings, + saveSettings: saveSettings, + loadModels: loadModels, + loadMessages: loadMessages, + saveMessages: saveMessages, + appendMessageToDOM: appendMessageToDOM, + addMessage: addMessage, + formatContent: formatContent, + copyMessage: copyMessage, + showHtmlPreview: showHtmlPreview, + previewCodeBlock: previewCodeBlock, + closePreviewModal: closePreviewModal, + showTyping: showTyping, + hideTyping: hideTyping, + sendMessage: sendMessage, + sendStreamingRequest: sendStreamingRequest, + sendNonStreamingRequest: sendNonStreamingRequest, + buildChatContext: buildChatContext, + getChatHistory: getChatHistory, + clearChat: clearChat, + shareChat: shareChat, + copyLogs: copyLogs, + clearLogs: clearLogs, + loadLogs: loadLogs, + testConnection: testConnection, + openSettingsModal: openSettingsModal, + closeSettingsModal: closeSettingsModal + }; + // Individual window.* assignments for backward compat + window.CHATSTUDIO_VERSION = CHATSTUDIO_VERSION; + window.OLLAMA_URL = OLLAMA_URL; + window.OLLAMA_API_KEY = OLLAMA_API_KEY; + window.DEFAULT_MODEL = DEFAULT_MODEL; + window.USE_STREAMING = USE_STREAMING; + window.API_BASE = API_BASE; + window.getApiUrl = function(path) { return (window.API_BASE || '/api') + path; }; + window.latestAiContent = latestAiContent; + window.logs = logs; + window.messages = messages; + window.isStreaming = isStreaming; + window.escapeHtml = escapeHtml; + window.utf8_to_b64 = utf8_to_b64; + window.b64_to_utf8 = b64_to_utf8; + window.generateId = generateId; + window.showToast = showToast; + window.autoExpand = autoExpand; + window.toggleSidebar = toggleSidebar; + window.closeSidebar = closeSidebar; + window.addLog = addLog; + window.renderLogs = renderLogs; + window.loadSettings = loadSettings; + window.saveSettings = saveSettings; + window.loadModels = loadModels; + window.loadMessages = loadMessages; + window.saveMessages = saveMessages; + window.appendMessageToDOM = appendMessageToDOM; + window.addMessage = addMessage; + window.formatContent = formatContent; + window.copyMessage = copyMessage; + window.showHtmlPreview = showHtmlPreview; + window.previewCodeBlock = previewCodeBlock; + window.closePreviewModal = closePreviewModal; + window.showTyping = showTyping; + window.hideTyping = hideTyping; + window.sendMessage = sendMessage; + window.sendStreamingRequest = sendStreamingRequest; + window.sendNonStreamingRequest = sendNonStreamingRequest; + window.buildChatContext = buildChatContext; + window.getChatHistory = getChatHistory; + window.clearChat = clearChat; + window.shareChat = shareChat; + window.copyLogs = copyLogs; + window.clearLogs = clearLogs; + window.loadLogs = loadLogs; + window.testConnection = testConnection; + window.openSettingsModal = openSettingsModal; + window.closeSettingsModal = closeSettingsModal; + + // ── Init ── + loadSettings(); + loadMessages(); + loadLogs(); + loadModels(); + renderMessagesFromData(); + addLog('Init', 'Chat overlay initialized'); + } catch (err) { + // Reset initialization flag so user can retry + this._initialized = false; + + // Print error to terminal + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay init error: ' + err.message, 'error'); + } + + console.error('ChatStudioModule._initLiteEngine error:', err); + } + } +} diff --git a/modules/core/module-config.js b/modules/core/module-config.js new file mode 100644 index 0000000..fb5199e --- /dev/null +++ b/modules/core/module-config.js @@ -0,0 +1,92 @@ +// Extracted from newOS4 index.html — ConfigManagerModule + class ConfigManagerModule { + constructor() { + this.commands = {}; this.terminal = null; + this.configPrefix = 'webx11_config_'; + } + getHelpInfo() { + return { + 'config': 'config [key] — Show all config or a specific key', + 'config-set': 'config-set <key> <value> — Set a configuration value', + 'config-list': 'config-list — List all configuration keys', + 'config-delete': 'config-delete <key> — Delete a configuration key', + 'config-export': 'config-export — Export all config as JSON', + 'config-reset': 'config-reset — Reset all configuration to defaults', + }; + } + registerCommands() { + this.commands['config'] = (args) => this.getConfig(args); + this.commands['config-set'] = (args) => this.setConfig(args); + this.commands['config-list'] = () => this.listConfig(); + this.commands['config-delete'] = (args) => this.deleteConfig(args); + this.commands['config-export'] = () => this.exportConfig(); + this.commands['config-reset'] = () => this.resetConfig(); + } + getConfig(args) { + if (!args[0]) { + this.terminal.print("Usage: config <key>", "warning"); return; + } + const value = localStorage.getItem(this.configPrefix + args[0]); + if (value) { + this.terminal.print(`${args[0]}: ${value}`, "info"); + } else { + this.terminal.print(`Key '${args[0]}' not found`, "error"); + } + } + setConfig(args) { + if (args.length < 2) { + this.terminal.print("Usage: config-set <key> <value>", "warning"); return; + } + const key = args[0], value = args.slice(1).join(' '); + localStorage.setItem(this.configPrefix + key, value); + this.terminal.print(`Config saved: ${key} = ${value}`, "success"); + } + listConfig() { + const configs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { + configs[key.replace(this.configPrefix, '')] = localStorage.getItem(key); + } + } + if (Object.keys(configs).length === 0) { + this.terminal.print("No configuration saved", "info"); return; + } + this.terminal.print("=== Configuration ===", "info"); + Object.entries(configs).forEach(([k, v]) => { this.terminal.print(`${k}: ${v}`); }); + } + deleteConfig(args) { + if (!args[0]) { + this.terminal.print("Usage: config-delete <key>", "warning"); return; + } + localStorage.removeItem(this.configPrefix + args[0]); + this.terminal.print(`Config '${args[0]}' deleted`, "success"); + } + exportConfig() { + const configs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { + configs[key] = localStorage.getItem(key); + } + } + const json = JSON.stringify(configs, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'webx11-config.json'; a.click(); + URL.revokeObjectURL(url); + this.terminal.print("Configuration exported", "success"); + } + resetConfig() { + if (confirm("Reset all configuration to defaults?")) { + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { keysToRemove.push(key); } + } + keysToRemove.forEach(k => localStorage.removeItem(k)); + this.terminal.print("Configuration reset to defaults", "success"); + } + } + } diff --git a/modules/core/module-duckduckgo.js b/modules/core/module-duckduckgo.js new file mode 100644 index 0000000..c443b72 --- /dev/null +++ b/modules/core/module-duckduckgo.js @@ -0,0 +1,38 @@ +// Extracted from newOS4 index.html — DuckDuckGoModule + class DuckDuckGoModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'ddg': 'ddg <query> — Search DuckDuckGo (opens query in new tab)', + 'duck': 'duck <query> — Alias for ddg', + }; + } + registerCommands() { + this.commands['ddg'] = (args) => this.search(args); + this.commands['duck'] = (args) => this.search(args); + } + async search(args) { + if (!args[0]) { + this.terminal.print("Usage: ddg <search-query>", "warning"); + this.terminal.print("Example: ddg javascript tutorials", "info"); + this.terminal.print("Note: Due to CORS, results may be limited. Try 'search' command for better results.", "warning"); + return; + } + const query = args.join(' '); + this.terminal.print(`🦆 DuckDuckGo Search: ${query}`, "info"); + + // DuckDuckGo doesn't have a public API, so we provide alternatives + this.terminal.print("⚠️ DuckDuckGo doesn't provide a public API for direct access.", "warning"); + this.terminal.print("Opening search in new tab...", "info"); + + // Open in new tab as fallback + const ddgUrl = `https://duckduckgo.com/?q=${encodeURIComponent(query)}`; + window.open(ddgUrl, '_blank'); + + this.terminal.print(`\n💡 Alternative: Use 'search <query>' for SearXNG (privacy-focused)`, "info"); + this.terminal.print(`🔗 Direct link: ${ddgUrl}`, "info"); + } + } diff --git a/modules/core/module-dynamicprompts.js b/modules/core/module-dynamicprompts.js new file mode 100644 index 0000000..ff8c5ea --- /dev/null +++ b/modules/core/module-dynamicprompts.js @@ -0,0 +1,102 @@ +// Extracted from newOS4 index.html — DynamicPromptsModule + class DynamicPromptsModule { + constructor() { + this.commands = {}; this.terminal = null; + this.prompts = this.loadPrompts(); this.variables = {}; + } + getHelpInfo() { + return { + 'prompt': 'prompt <name> [additional-text] — Use a saved prompt template', + 'prompt-save': 'prompt-save <name> <template> — Save a new prompt template', + 'prompt-list': 'prompt-list — List all saved prompt templates', + 'prompt-delete': 'prompt-delete <name> — Delete a saved prompt', + 'prompt-var': 'prompt-var <name> <value> — Set a template variable', + 'prompt-vars': 'prompt-vars — List all template variables' + }; + } + loadPrompts() { + try { + const saved = localStorage.getItem('webx11_prompts'); + return saved ? JSON.parse(saved) : {}; + } catch (e) { return {}; } + } + savePrompts() { + localStorage.setItem('webx11_prompts', JSON.stringify(this.prompts)); + } + registerCommands() { + this.commands['prompt'] = (args) => this.usePrompt(args); + this.commands['prompt-save'] = (args) => this.savePrompt(args); + this.commands['prompt-list'] = () => this.listPrompts(); + this.commands['prompt-delete'] = (args) => this.deletePrompt(args); + this.commands['prompt-var'] = (args) => this.setVariable(args); + this.commands['prompt-vars'] = () => this.listVariables(); + } + usePrompt(args) { + if (!args[0]) { + this.terminal.print("Usage: prompt <name> [additional-text]", "warning"); return; + } + const name = args[0], additional = args.slice(1).join(' '); + if (!this.prompts[name]) { + this.terminal.print(`Prompt '${name}' not found`, "error"); return; + } + let template = this.prompts[name].template; + Object.entries(this.variables).forEach(([k, v]) => { + template = template.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + const finalPrompt = additional ? `${template} ${additional}` : template; + this.terminal.print("=== Generated Prompt ===", "info"); + this.terminal.printHTML(`<div class="output-line ollama" style="margin-top: 5px;"> + <button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.innerText.replace('Copy','').trim())">Copy</button> + ${finalPrompt} + </div>`); + this.terminal.print("\nUse with: ai <generated-prompt>", "info"); + } + savePrompt(args) { + if (args.length < 2) { + this.terminal.print("Usage: prompt-save <name> <template>", "warning"); return; + } + const name = args[0], template = args.slice(1).join(' '); + this.prompts[name] = { template, created: new Date().toISOString() }; + this.savePrompts(); + this.terminal.print(`Prompt '${name}' saved`, "success"); + } + listPrompts() { + const names = Object.keys(this.prompts); + if (names.length === 0) { + this.terminal.print("No saved prompts", "info"); return; + } + this.terminal.print("=== Saved Prompts ===", "info"); + names.forEach(name => { + const p = this.prompts[name]; + this.terminal.print(`📝 ${name}: ${p.template.substring(0, 50)}...`); + }); + } + deletePrompt(args) { + if (!args[0]) { + this.terminal.print("Usage: prompt-delete <name>", "warning"); return; + } + if (this.prompts[args[0]]) { + delete this.prompts[args[0]]; + this.savePrompts(); + this.terminal.print(`Prompt '${args[0]}' deleted`, "success"); + } else { + this.terminal.print(`Prompt '${args[0]}' not found`, "error"); + } + } + setVariable(args) { + if (args.length < 2) { + this.terminal.print("Usage: prompt-var <name> <value>", "warning"); return; + } + const name = args[0], value = args.slice(1).join(' '); + this.variables[name] = value; + this.terminal.print(`Variable set: ${name} = ${value}`, "success"); + } + listVariables() { + const names = Object.keys(this.variables); + if (names.length === 0) { + this.terminal.print("No variables set", "info"); return; + } + this.terminal.print("=== Variables ===", "info"); + names.forEach(name => { this.terminal.print(`${name}: ${this.variables[name]}`); }); + } + } diff --git a/modules/core/module-fetcher.js b/modules/core/module-fetcher.js new file mode 100644 index 0000000..fea84d2 --- /dev/null +++ b/modules/core/module-fetcher.js @@ -0,0 +1,65 @@ +class WebFetcherModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'fetch': 'fetch <url> — Fetch and display raw HTML/text content from a URL', + 'web': 'web <url> — Alias for fetch', + 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL', + }; + } + registerCommands() { + this.commands['fetch'] = (args) => this.fetchUrl(args); + this.commands['web'] = (args) => this.fetchUrl(args); + this.commands['extract'] = (args) => this.extractUrl(args); + } + async fetchUrl(args) { + if (!args[0]) { + this.terminal.print("Usage: fetch <url>", "warning"); + this.terminal.print("Example: fetch https://example.com", "info"); + return; + } + let url = args[0]; + if (!url.match(/^https?:\/\//i)) url = 'https://' + url; + this.terminal.print(`🌐 Fetching: ${url}`, "info"); + try { + const resp = await fetch('/api/fetch?url=' + encodeURIComponent(url)); + if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } + if (!resp.ok) { this.terminal.print('Fetch failed: HTTP ' + resp.status, 'error'); return; } + const text = await resp.text(); + const cleanText = text.replace(/\s+/g, ' ').trim(); + this.terminal.print(`\n=== Content from ${url} ===\n`, 'success'); + this.terminal.print(cleanText.substring(0, 3000) + (cleanText.length > 3000 ? '...' : '')); + this.terminal.print(`\n📏 Content Length: ${cleanText.length} chars`, 'info'); + } catch (e) { + this.terminal.print('Fetch failed: ' + e.message, 'error'); + } + } + async extractUrl(args) { + if (!args[0]) { + this.terminal.print("Usage: extract <url>", "warning"); + this.terminal.print("Example: extract https://example.com", "info"); + return; + } + let url = args[0]; + if (!url.match(/^https?:\/\//i)) url = 'https://' + url; + this.terminal.print(`📄 Extracting: ${url}`, "info"); + try { + const resp = await fetch('/api/extract?url=' + encodeURIComponent(url)); + if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Extract failed: ' + (err.error || 'HTTP ' + resp.status), 'error'); + return; + } + const data = await resp.json(); + if (data.title) this.terminal.print(`\n📰 ${data.title}\n`, 'success'); + this.terminal.print(data.content || '(no content extracted)'); + this.terminal.print(`\n📏 ${data.word_count || 0} words (via ${data.method || 'unknown'})`, 'info'); + } catch (e) { + this.terminal.print('Extract failed: ' + e.message, 'error'); + } + } +} diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js new file mode 100644 index 0000000..c4bc786 --- /dev/null +++ b/modules/core/module-fs.js @@ -0,0 +1,445 @@ +class FileSystemModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'ls': 'ls [path] [-la] — List files and directories', + 'cat': 'cat <file> — Display file contents', + 'put': 'put <file> <content> — Create or overwrite a file', + 'rm': 'rm <file> — Remove a file; use -r/-rf for directories', + 'mkdir': 'mkdir <dir> [-p] — Create a directory; -p creates parent paths', + 'rmdir': 'rmdir <dir> — Remove a directory', + 'mv': 'mv <source> <dest> — Move file or directory', + 'cp': 'cp <source> <dest> — Copy file or directory', + 'cd': 'cd <path> — Change current directory; cd / to go to root', + 'pwd': 'pwd — Print current working directory', + 'find': 'find <pattern> — Search files by name pattern', + 'grep': 'grep <pattern> [file] — Search file content for pattern', + 'head': 'head <file> [lines] — Show first N lines (default: 10)', + 'tail': 'tail <file> [lines] — Show last N lines (default: 10)', + 'sort': 'sort <file> — Sort lines of a file alphabetically', + 'wc': 'wc <file> — Count lines, words, and bytes', + 'touch': 'touch <file> — Create empty file or update timestamp', + 'tree': 'tree — Display directory structure as a tree', + 'du': 'du [path] — Estimate file/directory size', + 'upload': 'upload — Upload a file from your device', + 'download': 'download <file> — Download a file to your device', + 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON', + 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file', + }; + } + registerCommands() { + const self = this; + this.commands['ls'] = (args) => { + const target = args && args.length > 0 && !args[0].startsWith('-') ? args[0] : undefined; + const flags = (args || []).filter(a => a.startsWith('-')); + const showLong = flags.some(f => f.includes('l')); + const showHidden = flags.some(f => f.includes('a')); + const files = this.terminal.fs.ls(target); + if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; } + const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); + if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } + const maxName = Math.max(...filtered.map(f => f.name.length), 8); + if (showLong) { + filtered.forEach(f => { + const type = f.type === 'dir' ? 'd' : '-'; + const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; + const size = String(f.size || 0).padStart(6); + this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name); + }); + const total = filtered.reduce((s, f) => s + (f.size || 0), 0); + this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted'); + } else { + this.terminal.printClickable(filtered); + } + }; + this.commands['cat'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: cat <filename>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content !== null && content !== undefined) { + if (content.length === 0) this.terminal.print('(empty file)', 'info'); + else this.terminal.print(content); + } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error'); + }; + this.commands['echo'] = (args) => { + if (!args || args.length === 0) { this.terminal.print(''); return; } + const ri = args.indexOf('>'); + if (ri >= 0) { + const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' '); + if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); } + return; + } + this.terminal.print(args.join(' ')); + }; + this.commands['put'] = (args) => { + if(args.length < 2) return this.terminal.print('Usage: put <filename> <content>', 'warning'); + const name = args[0]; const content = args.slice(1).join(' '); + this.terminal.fs.writeFile(name, content); + this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success'); + }; + this.commands['rm'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: rm <filename>', 'warning'); + if(args[0] === '-r' || args[0] === '-rf') { + const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning'); + if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + t, 'success'); + else this.terminal.print('rm: ' + t + ': No such file or directory', 'error'); + return; + } + if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Removed: ' + args[0], 'success'); + else this.terminal.print('rm: ' + args[0] + ': No such file', 'error'); + }; + this.commands['mkdir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: mkdir <dirname>', 'warning'); + if (args[0] === '-p') { + if(!args[1]) return this.terminal.print('Usage: mkdir -p <path>', 'warning'); + if(this.terminal.fs.mkdirRecursive(args[1])) this.terminal.print('Created directories: ' + args[1], 'success'); + else this.terminal.print('mkdir: cannot create ' + args[1], 'error'); + return; + } + if(this.terminal.fs.mkdir(args[0])) this.terminal.print('Created directory: ' + args[0], 'success'); + else this.terminal.print('mkdir: cannot create ' + args[0] + ': File exists', 'error'); + }; + this.commands['rmdir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: rmdir <dirname>', 'warning'); + if (this.terminal.fs.deleteDirRecursive(args[0])) this.terminal.print('Removed directory: ' + args[0], 'success'); + else this.terminal.print('rmdir: ' + args[0] + ': No such directory', 'error'); + }; + this.commands['mv'] = (args) => { + if(!args[0] || !args[1]) return this.terminal.print('Usage: mv <source> <dest>', 'warning'); + const srcDir = this.terminal.fs.readDir(args[0]); + if (srcDir !== null) { + if (this.terminal.fs.moveDir(args[0], args[1])) { + this.terminal.print('Moved directory: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('mv: ' + args[0] + ': failed to move directory', 'error'); + } + return; + } + if (this.terminal.fs.moveFile(args[0], args[1])) { + this.terminal.print('Moved: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('mv: ' + args[0] + ': No such file or directory', 'error'); + } + }; + this.commands['cp'] = (args) => { + if(!args[0] || !args[1]) return this.terminal.print('Usage: cp <source> <dest>', 'warning'); + const srcDir = this.terminal.fs.readDir(args[0]); + if (srcDir !== null) { + if (this.terminal.fs.copyDir(args[0], args[1])) { + this.terminal.print('Copied directory: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('cp: ' + args[0] + ': failed to copy directory', 'error'); + } + return; + } + if (this.terminal.fs.copyFile(args[0], args[1])) { + this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error'); + } + }; + this.commands['cd'] = (args) => { + if (!args[0] || args[0] === '/') { + this.terminal.fs.currentPath = []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + return; + } + const segs = this.terminal.fs._toAbsoluteSegments(args[0]); + if (segs === null) { + this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error'); + return; + } + const dir = this.terminal.fs._resolveDir(args[0]); + if (dir) { + this.terminal.fs.currentPath = segs; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + } else { + this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error'); + } + }; + this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); }; + this.commands['find'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: find <pattern>', 'warning'); + const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : []; + if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info'); + results.forEach(r => this.terminal.print('./' + r)); + this.terminal.print('Found ' + results.length + ' match(es)', 'info'); + }; + this.commands['grep'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: grep <pattern> [filename]', 'warning'); + const pattern = args[0]; const target = args[1]; + if (target) { + const c = this.terminal.fs.readFile(target); + if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); + const lines = c.split('\n'); const re = new RegExp(pattern, 'i'); + let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } }); + if (count === 0) this.terminal.print('(no matches)', 'info'); + else this.terminal.print(count + ' match(es)', 'text-muted'); + } else { + const dir = this.terminal.fs.getCurrentDir(); + if (!dir) return this.terminal.print('grep: cannot search', 'error'); + let total = 0; const re = new RegExp(pattern, 'i'); + Object.keys(dir.content).forEach(name => { + const node = dir.content[name]; + if (node.type === 'file') { + (node.content || '').split('\n').forEach((line, i) => { + if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; } + }); + } + }); + if (total === 0) this.terminal.print('(no matches)', 'info'); + else this.terminal.print(total + ' total match(es)', 'text-muted'); + } + }; + this.commands['head'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: head <filename> [lines]', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error'); + const n = args[1] ? parseInt(args[1]) : 10; + this.terminal.print(c.split('\n').slice(0, n).join('\n')); + }; + this.commands['tail'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: tail <filename> [lines]', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('tail: ' + args[0] + ': No such file', 'error'); + const n = args[1] ? parseInt(args[1]) : 10; + this.terminal.print(c.split('\n').slice(-n).join('\n')); + }; + this.commands['sort'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: sort <filename>', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error'); + c.split('\n').sort().forEach(l => this.terminal.print(l)); + }; + this.commands['wc'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: wc <filename>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error'); + const lines = content.split('\n').length; + const words = content.split(/\s+/).filter(w => w).length; + this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]); + }; + this.commands['touch'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); + const e = this.terminal.fs.readFile(args[0]); + if (e === null || e === undefined) { + const segs = this.terminal.fs._toAbsoluteSegments(args[0]); + if (segs) { + const dirPath = segs.slice(0, -1).join('/') || '.'; + const name = segs[segs.length - 1]; + this.terminal.fs.mkdirRecursive(dirPath); + } + this.terminal.fs.writeFile(args[0], ''); + this.terminal.print('Created: ' + args[0], 'success'); + } + else { this.terminal.print('Touched: ' + args[0], 'success'); } + }; + this.commands['tree'] = () => { + const dir = this.terminal.fs.getCurrentDir(); + if (!dir) return this.terminal.print('(no directory)', 'warning'); + const lines = []; this._printTree(dir, '', lines); + lines.forEach(l => this.terminal.print(l)); + }; + this.commands['du'] = (args) => { + const t = args && args[0] ? args[0] : '.'; + if (t === '.') { + const dir = this.terminal.fs.getCurrentDir(); if (!dir) return; + let total = 0; Object.keys(dir.content).forEach(k => { total += dir.content[k].content ? dir.content[k].content.length : 0; }); + this.terminal.print(total + '\t' + t); + } else { + const c = this.terminal.fs.readFile(t); + if (c === null || c === undefined) return; + this.terminal.print(c.length + '\t' + t); + } + }; + this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; + this.commands['download'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: download <filename>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content !== null && content !== undefined) { + const blob = new Blob([content], {type: 'application/octet-stream'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = args[0]; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Downloaded: ' + args[0], 'success'); + } else { this.terminal.print('download: ' + args[0] + ': No such file', 'error'); } + }; + this.commands['vfs-export'] = () => { + const data = this.terminal.fs.root; + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], {type: 'application/json'}); + const sizeMB = blob.size / (1024 * 1024); + if (sizeMB > 5) { + this.terminal.print('Warning: VFS size is ' + sizeMB.toFixed(1) + ' MB — large download may take time', 'warning'); + } + const now = new Date(); + const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'webx11-filesystem-' + dateStr + '.json'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Exported VFS to webx11-filesystem-' + dateStr + '.json', 'success'); + }; + + // Session commands (save/load/export/import) + this.commands['session-save'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-save <name>', 'warning'); + const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_'); + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: this.terminal.fs.root, + currentPath: this.terminal.fs.currentPath, + version: 1 + }; + const filePath = '_sessions/' + name + '.json'; + this.terminal.fs.mkdirRecursive('_sessions'); + this.terminal.fs.writeFile(filePath, JSON.stringify(snapshot, null, 2)); + this.terminal.print('Session saved: ' + name, 'success'); + }; + this.commands['session-load'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-load <name>', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + const content = this.terminal.fs.readFile(filePath); + if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); + try { + const session = JSON.parse(content); + if (!session || session.type !== 'session' || !session.fsRoot) { + return this.terminal.print('Invalid session file: ' + args[0], 'error'); + } + this.terminal.fs.root = session.fsRoot; + this.terminal.fs.currentPath = session.currentPath || []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success'); + this.terminal.executeCommand('ls'); + } catch (e) { + this.terminal.print('Failed to load session: ' + e.message, 'error'); + } + }; + this.commands['session-list'] = () => { + this.terminal.fs.mkdirRecursive('_sessions'); + const files = this.terminal.fs.ls('_sessions'); + if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info'); + this.terminal.print('Saved sessions:', 'text-muted'); + files.forEach(f => { + if (f.type === 'file' && f.name.endsWith('.json')) { + try { + const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name)); + const label = f.name.replace('.json', ''); + const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown'; + const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0; + this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')'); + } catch (e) { + this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)'); + } + } + }); + }; + this.commands['session-delete'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-delete <name>', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + if (this.terminal.fs.deleteFile(filePath)) { + this.terminal.print('Session deleted: ' + args[0], 'success'); + } else { + this.terminal.print('Session not found: ' + args[0], 'error'); + } + }; + this.commands['session-export'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-export <name>', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + const content = this.terminal.fs.readFile(filePath); + if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); + const blob = new Blob([content], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'webx11-session-' + args[0] + '.json'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Exported session: ' + args[0], 'success'); + }; + this.commands['session-import'] = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const session = JSON.parse(evt.target.result); + if (!session || session.type !== 'session' || !session.fsRoot) { + this.terminal.print('Invalid session file (not a valid session export)', 'error'); + return; + } + this.terminal.fs.root = session.fsRoot; + this.terminal.fs.currentPath = session.currentPath || []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success'); + this.terminal.executeCommand('ls'); + } catch (err) { + this.terminal.print('Failed to import session: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }; + this.commands['vfs-import'] = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const data = JSON.parse(evt.target.result); + if (!data || !data.type) { + this.terminal.print('Invalid VFS file', 'error'); + return; + } + if (this.terminal.fs.restoreFromExport(data)) { + this.terminal.updatePrompt(); + this.terminal.print('VFS restored from: ' + file.name, 'success'); + this.terminal.executeCommand('ls'); + } else { + this.terminal.print('Failed to restore VFS', 'error'); + } + } catch (err) { + this.terminal.print('Failed to import VFS: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }; + } + _printTree(dir, prefix, lines) { + const names = Object.keys(dir.content || {}).sort(); + names.forEach((name, i) => { + const isLast = i === names.length - 1; + const node = dir.content[name]; + const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; + const label = node.type === 'dir' ? name + '/' : name; + lines.push(prefix + connector + label); + if (node.type === 'dir') { + this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines); + } + }); + } +} diff --git a/modules/core/module-io.js b/modules/core/module-io.js new file mode 100644 index 0000000..d7ff996 --- /dev/null +++ b/modules/core/module-io.js @@ -0,0 +1,80 @@ +// Extracted from newOS4 index.html — ModuleIOModule + class ModuleIOModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'export-mods': 'export-mods — Export all loaded modules to a JSON file', + 'import-mods': 'import-mods <filename> — Import modules from a JSON file', + 'backup': 'backup — Create a full system backup', + 'restore': 'restore <filename> — Restore system from a backup file', + 'share-mod': 'share-mod <module-name> — Share a module as JSON to clipboard' + }; + } + registerCommands() { + this.commands['export-mods'] = () => this.exportModules(); + this.commands['import-mods'] = (args) => this.importModules(args); + this.commands['backup'] = () => this.fullBackup(); + this.commands['restore'] = (args) => this.restoreBackup(args); + this.commands['share-mod'] = (args) => this.shareModule(args); + } + exportModules() { + const registry = this.terminal.moduleManager.moduleRegistry; + if (registry.length === 0) { + this.terminal.print("No modules to export", "warning"); return; + } + const exportData = { version: '4.1', exported: new Date().toISOString(), modules: registry }; + const json = JSON.stringify(exportData, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `webx11-modules-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print(`Exported ${registry.length} module(s)`, "success"); + } + importModules(args) { + this.terminal.print("⚠️ To import modules:", "warning"); + this.terminal.print("1. Upload the JSON file using 'upload'", "info"); + this.terminal.print("2. Use: import-mods <filename>", "info"); + } + async fullBackup() { + const backup = { + version: '4.1', timestamp: new Date().toISOString(), + filesystem: localStorage.getItem('webx11_filesystem'), + modules: localStorage.getItem('webx11_modules'), + ollama: { + endpoint: localStorage.getItem('webx11_ollama_endpoint'), + model: localStorage.getItem('webx11_ollama_model') + }, config: {} + }; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith('webx11_')) { backup.config[key] = localStorage.getItem(key); } + } + const json = JSON.stringify(backup, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `webx11-backup-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print("Full backup created", "success"); + } + restoreBackup(args) { + this.terminal.print("⚠️ To restore backup:", "warning"); + this.terminal.print("1. Upload the backup JSON file", "info"); + this.terminal.print("2. Use: restore <filename>", "info"); + } + shareModule(args) { + if (!args[0]) { + this.terminal.print("Usage: share-mod <module-name>", "warning"); return; + } + const mod = this.terminal.moduleManager.moduleRegistry.find(m => m.name === args[0]); + if (!mod) { + this.terminal.print(`Module '${args[0]}' not found`, "error"); return; + } + const shareData = { name: mod.name, url: mod.url, shared: new Date().toISOString() }; + const json = JSON.stringify(shareData, null, 2); + navigator.clipboard.writeText(json).then(() => { + this.terminal.print("Module info copied to clipboard", "success"); + }).catch(() => { this.terminal.print(json, "info"); }); + } + } diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js new file mode 100644 index 0000000..850d627 --- /dev/null +++ b/modules/core/module-loader.js @@ -0,0 +1,178 @@ +(function() { + 'use strict'; + + const BASE = 'modules/core/'; + + const FOUNDATION = [ + 'virtual-fs.js', + 'module-manager.js', + 'ollama-manager.js', + 'terminal.js' + ]; + + const BUILTIN = [ + 'module-system.js', + 'module-fs.js', + 'module-modmgr.js', + 'module-ollama.js', + 'module-my.js', + 'module-searxng.js', + 'module-servermon.js', + 'module-ollamamodels.js', + 'module-config.js', + 'module-simplegui.js', + 'module-io.js', + 'module-dynamicprompts.js', + 'module-aiassist.js', + 'module-aiasm.js', + 'module-smartctx.js', + 'module-duckduckgo.js', + 'module-fetcher.js', + 'module-skills.js', + 'secret-vault.js', + 'module-chatstudio.js' + ]; + + function getSelectedModules() { + try { + const saved = localStorage.getItem('webx11_startup_modules'); + if (!saved) return null; + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) return parsed; + } catch (e) {} + return null; + } + + function isSelected(name, selected) { + if (!selected) return true; + return selected.includes(name); + } + + function loadScript(url, filename) { + return new Promise((resolve) => { + const existing = document.querySelector(`script[src="${url}"]`); + if (existing) { resolve(); return; } + const el = document.createElement('script'); + el.src = url + (url.includes('?') ? '&t=' : '?t=') + Date.now(); + el.onload = () => resolve(); + el.onerror = () => { + console.warn(`[ModuleLoader] Failed to load: ${filename || url} — continuing`); + // Log to terminal if available + if (typeof window !== 'undefined' && window.webx11 && window.webx11.log) { + window.webx11.log('WARN', 'Loader', 'Failed to load: ' + (filename || url)); + } + resolve(); + }; + document.body.appendChild(el); + }); + } + + function setSplash(phase, current, total, filename) { + const fill = document.getElementById('splash-progress-fill'); + const phaseEl = document.getElementById('splash-phase'); + const fileEl = document.getElementById('splash-filename'); + if (fill && total > 0) { + fill.style.width = Math.round((current / total) * 100) + '%'; + } + if (phaseEl) phaseEl.textContent = phase; + if (fileEl) fileEl.textContent = filename || ''; + } + + function hideSplash() { + const overlay = document.getElementById('splash-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + setTimeout(() => { overlay.style.display = 'none'; }, 350); + } + } + + function showSplashError(msg) { + const el = document.getElementById('splash-error'); + const fill = document.getElementById('splash-progress-fill'); + if (el) { el.textContent = msg; el.classList.add('show'); } + if (fill) fill.style.background = 'var(--error-color)'; + } + + function reloadWithSelection() { + location.reload(); + } + + window.__moduleLoaderReload = reloadWithSelection; + + const CACHE = '?v=18'; + + async function init() { + try { + const selected = getSelectedModules(); + const total = FOUNDATION.length + BUILTIN.length; + let count = 0; + + // Phase 1: Foundation (always load) + setSplash('Loading foundation layer (1/3)', 0, total, ''); + for (const mod of FOUNDATION) { + count++; + setSplash('Loading foundation layer (1/3)', count, total, mod); + await loadScript(BASE + mod + CACHE, mod); + } + + // Phase 2: Built-in modules (filtered) + setSplash('Loading built-in modules (2/3)', count, total, ''); + for (const mod of BUILTIN) { + count++; + if (!isSelected(mod, selected)) { + console.log(`[ModuleLoader] Skipped: ${mod} (not in startup selection)`); + setSplash('Loading built-in modules (2/3)', count, total, '⏭ ' + mod); + await new Promise(r => setTimeout(r, 20)); + continue; + } + setSplash('Loading built-in modules (2/3)', count, total, mod); + await loadScript(BASE + mod + CACHE, mod); + } + + // Phase 3: Initialize terminal + setSplash('Initializing terminal (3/3)', total, total, ''); + const webx11 = new WebX11Terminal(); + window.webx11 = webx11; + + // Auto-connect to Ollama after init + setTimeout(async () => { + try { + if (webx11.ollamaManager && !webx11.ollamaManager.connected) { + webx11.log('INFO', 'System', 'WebX11 v4.2 started'); + webx11.log('INFO', 'System', 'Default model: ' + webx11.ollamaManager.currentModel); + await webx11.ollamaManager.connect(); + if (typeof webx11.updateOllamaStatus === 'function') { + webx11.updateOllamaStatus(); + } + webx11.print('🤖 Default model: ' + webx11.ollamaManager.currentModel, 'info'); + } + } catch (e) { + // Silently ignore — user can manually connect + } + }, 500); + + // 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']; + const extSelected = selected + ? extKeys.filter(k => isSelected(k + '.js', selected)) + : extKeys; + if (extSelected.length > 0) { + webx11._loadExtModules(extSelected); + } + } + + hideSplash(); + console.log('[ModuleLoader] All modules loaded successfully'); + } catch (err) { + console.error('[ModuleLoader] Fatal error:', err); + showSplashError('Loading failed — ' + err.message); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/modules/core/module-manager.js b/modules/core/module-manager.js new file mode 100644 index 0000000..8e9e911 --- /dev/null +++ b/modules/core/module-manager.js @@ -0,0 +1,138 @@ +// Extracted from newOS4 index.html — ModuleManager + class ModuleManager { + constructor(terminal) { + this.terminal = terminal; + this.modules = {}; + this.moduleRegistry = []; + this.loadFromStorage(); + } + loadFromStorage() { + try { + const saved = localStorage.getItem('webx11_modules'); + if (saved) { + this.moduleRegistry = JSON.parse(saved); + } + } catch (e) { console.error('Module load error:', e); } + } + saveToStorage() { + try { + localStorage.setItem('webx11_modules', JSON.stringify(this.moduleRegistry)); + } catch (e) { console.error('Module save error:', e); } + } + async loadModuleFromURL(url) { + try { + this.terminal.print(`Loading module from ${url}...`, 'info'); + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const code = await response.text(); + const ModuleClass = this.createModuleClass(code, url); + if (ModuleClass) { + const instance = new ModuleClass(); + this.registerModule(instance, url); + if (instance.init) { + await instance.init(); + } + this.terminal.print(`Module loaded successfully from ${url}`, 'success'); + return true; + } + } catch (error) { + this.terminal.print(`Failed to load module: ${error.message}`, 'error'); + return false; + } + } + createModuleClass(code, url) { + try { + const classMatch = code.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); + if (classMatch) { + const className = classMatch[1]; + const mod = new Function('return ' + code + '\nreturn ' + className)(); + return mod; + } + throw new Error('No valid module class found'); + } catch (e) { + this.terminal.print(`Module parse error: ${e.message}`, 'error'); + return null; + } + } + registerModule(instance, url = null) { + const name = instance.constructor.name; + this.modules[name] = instance; + instance.terminal = this.terminal; + if (instance.registerCommands) { instance.registerCommands(); } + // Only persist modules loaded from absolute URLs (not boot-time ext modules) + if (url && url.startsWith('http') && !this.moduleRegistry.find(m => m.name === name)) { + this.moduleRegistry.push({ + name: name, url: url, + loaded: new Date().toISOString(), enabled: true + }); + this.saveToStorage(); + } + console.log(`Module registered: ${name}`); + } + unregisterModule(name) { + if (this.modules[name]) { + delete this.modules[name]; + this.moduleRegistry = this.moduleRegistry.filter(m => m.name !== name); + this.saveToStorage(); + return true; + } + return false; + } + getModuleList() { + const allNames = new Set([...this.moduleRegistry.map(m => m.name), ...Object.keys(this.modules)]); + return Array.from(allNames).map(name => ({ + name, + url: (this.moduleRegistry.find(m => m.name === name) || {}).url || null, + loaded: this.modules[name] !== undefined + })); + } + clearAll() { + this.modules = {}; + this.moduleRegistry = []; + this.saveToStorage(); + } + getModuleRequirements(name) { + const mod = this.modules[name]; + if (mod && typeof mod.requirements === 'function') { + return mod.requirements(); + } + return null; + } + checkAllRequirements() { + const results = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (typeof mod.requirements === 'function') { + results[modName] = mod.requirements(); + } + } + return results; + } + getAllCommands() { + const commands = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (mod.commands) { + for (const cmd in mod.commands) { + commands[cmd] = modName; + } + } + } + return commands; + } + getAllHelp() { + const help = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (typeof mod.getHelpInfo === 'function') { + const modHelp = mod.getHelpInfo(); + if (modHelp) Object.assign(help, modHelp); + } else if (mod.commands) { + for (const cmd in mod.commands) { + if (!help[cmd]) help[cmd] = cmd + ' — (no description)'; + } + } + } + return help; + } + } diff --git a/modules/core/module-modmgr.js b/modules/core/module-modmgr.js new file mode 100644 index 0000000..1104883 --- /dev/null +++ b/modules/core/module-modmgr.js @@ -0,0 +1,40 @@ +// Extracted from newOS4 index.html — ModuleManagerModule + class ModuleManagerModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'modules': 'modules — Open the module manager GUI', + 'listmods': 'listmods — List all loaded modules with status', + 'loadmod': 'loadmod <url> — Load a module from a URL', + 'removemod': 'removemod <name> — Remove a loaded module by name', + 'startup-modules': 'startup-modules — Open the startup modules selector', + 'load-local': 'load-local <path> — Load a module from VFS or URL', + 'loadlocal': 'loadlocal <path> — Alias for load-local', + }; + } + registerCommands() { + this.commands['modules'] = () => { this.terminal.openModuleManager(); }; + this.commands['loadmod'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: loadmod <url>", 'warning'); + await this.terminal.moduleManager.loadModuleFromURL(args[0]); + this.terminal.renderModuleList(); + }; + this.commands['removemod'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: removemod <name>", 'warning'); + if(this.terminal.moduleManager.unregisterModule(args[0])) { + this.terminal.print(`Module '${args[0]}' removed`, 'success'); + } else { + this.terminal.print(`Module not found: ${args[0]}`, 'error'); + } + }; + this.commands['listmods'] = () => { + const mods = this.terminal.moduleManager.getModuleList(); + if(mods.length === 0) return this.terminal.print("No modules loaded"); + mods.forEach(m => { + const status = m.loaded ? '✓' : '✗'; + const badge = m.url ? '' : ' [Built-in]'; + this.terminal.print(`${status} ${m.name}${badge}`); + }); + }; + } + } diff --git a/modules/core/module-my.js b/modules/core/module-my.js new file mode 100644 index 0000000..5fc13ca --- /dev/null +++ b/modules/core/module-my.js @@ -0,0 +1,82 @@ +class MyModule { + constructor() { + this.commands = {}; this.terminal = null; this.userData = {}; + try { const d = localStorage.getItem('webx11_my_data'); if (d) this.userData = JSON.parse(d); } catch (e) {} + } + getHelpInfo() { + return { + 'my': 'my — Show module info and stored key count', + 'my-set': 'my-set <key> <value> — Store a key-value pair', + 'my-get': 'my-get <key> — Retrieve a stored value by key', + 'my-list': 'my-list — List all stored keys', + 'my-export': 'my-export — Export all stored data as JSON', + 'my-import': 'my-import <json> — Import data from JSON string', + 'my-clear': 'my-clear — Clear all stored data' + }; + } + _save() { + try { localStorage.setItem('webx11_my_data', JSON.stringify(this.userData)); } catch (e) {} + } + registerCommands() { + this.commands['my'] = () => this.showInfo(); + this.commands['my-set'] = (args) => this.setData(args); + this.commands['my-get'] = (args) => this.getData(args); + this.commands['my-list'] = () => this.listData(); + this.commands['my-export'] = () => this.exportData(); + this.commands['my-import'] = (args) => this.importData(args); + this.commands['my-clear'] = () => this.clearData(); + } + showInfo() { + this.terminal.print("=== My Module v2.0 ===", "info"); + this.terminal.print("Personal workspace for custom commands"); + this.terminal.print(`Stored keys: ${Object.keys(this.userData).length}`, "success"); + } + setData(args) { + if (args.length < 2) { + this.terminal.print("Usage: my-set <key> <value>", "warning"); return; + } + const key = args[0], value = args.slice(1).join(' '); + this.userData[key] = { value, timestamp: new Date().toISOString() }; + this._save(); + this.terminal.print(`Saved: ${key} = ${value}`, "success"); + } + getData(args) { + if (!args[0]) { + this.terminal.print("Usage: my-get <key>", "warning"); return; + } + if (this.userData[args[0]]) { + this.terminal.print(`${args[0]}: ${this.userData[args[0]].value}`, "info"); + } else { + this.terminal.print(`Key '${args[0]}' not found`, "error"); + } + } + listData() { + const keys = Object.keys(this.userData); + if (!keys.length) return this.terminal.print('No data stored.', 'info'); + this.terminal.print('=== My Data ===', 'info'); + keys.forEach(k => { + const v = this.userData[k]; + const type = typeof v.value; + this.terminal.print(` ${k} (${type}): ${String(v.value).substring(0, 40)}`); + }); + } + exportData() { + this.terminal.print(JSON.stringify(this.userData, null, 2)); + } + importData(args) { + if (!args[0]) return this.terminal.print('Usage: my-import <json>', 'warning'); + try { + const data = JSON.parse(args.join(' ')); + Object.assign(this.userData, data); + this._save(); + this.terminal.print(`Imported ${Object.keys(data).length} keys`, 'success'); + } catch (e) { + this.terminal.print('Invalid JSON: ' + e.message, 'error'); + } + } + clearData() { + this.userData = {}; + localStorage.removeItem('webx11_my_data'); + this.terminal.print("Personal data cleared", "success"); + } +} diff --git a/modules/core/module-ollama.js b/modules/core/module-ollama.js new file mode 100644 index 0000000..3eabe19 --- /dev/null +++ b/modules/core/module-ollama.js @@ -0,0 +1,52 @@ +// Extracted from newOS4 index.html — OllamaModule + class OllamaModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'ollama': 'ollama — Open the Ollama model manager UI', + 'ollamaconnect': 'ollamaconnect — Connect to the Ollama server', + 'ollamadisconnect': 'ollamadisconnect — Disconnect from the Ollama server', + 'ollamamodels': 'ollamamodels — List all available models from Ollama', + 'ollamasetmodel': 'ollamasetmodel <name> — Set the active Ollama model', + 'ollamasetproxy': 'ollamasetproxy [endpoint] — View or change the Ollama endpoint URL', + }; + } + registerCommands() { + this.commands['ollama'] = () => { this.terminal.openOllamaManager(); }; + this.commands['ollamaconnect'] = async () => { + await this.terminal.ollamaManager.connect(); + this.terminal.updateOllamaStatus(); + }; + this.commands['ollamadisconnect'] = () => { + this.terminal.ollamaManager.connected = false; + this.terminal.ollamaManager.models = []; + this.terminal.print('Ollama disconnected', 'info'); + this.terminal.updateOllamaStatus(); + }; + this.commands['ollamamodels'] = () => { + const models = this.terminal.ollamaManager.getModels(); + if(models.length === 0) return this.terminal.print("No models found. Connect first."); + models.forEach(m => this.terminal.print(`${m.name} (${(m.size/1e9).toFixed(1)}GB)`)); + }; + this.commands['ollamasetmodel'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: ollamasetmodel <name>", 'warning'); + this.terminal.ollamaManager.setModel(args[0]); + }; + this.commands['ollamasetproxy'] = (args) => { + if(!args[0]) { + const ep = this.terminal.ollamaManager.endpoint; + return this.terminal.print(`Current Ollama endpoint: ${ep}`, 'info'); + } + this.terminal.ollamaManager.setEndpoint(args[0]); + this.terminal.print(`Ollama endpoint set to: ${args[0]}`, 'success'); + }; + this.commands['ai'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ai <prompt>", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + this.commands['ask'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ask <prompt>", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + } + } diff --git a/modules/core/module-ollamamodels.js b/modules/core/module-ollamamodels.js new file mode 100644 index 0000000..9a7763a --- /dev/null +++ b/modules/core/module-ollamamodels.js @@ -0,0 +1,87 @@ +// Extracted from newOS4 index.html — OllamaModelsModule + class OllamaModelsModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'models': 'models — List all available Ollama models', + 'model-info': 'model-info <model-name> — Show details about a specific model', + 'model-pull': 'model-pull <model-name> — Pull a model from Ollama registry', + 'model-delete': 'model-delete <model-name> — Delete a model from local storage', + 'model-run': 'model-run <model-name> [prompt] — Run inference with a model' + }; + } + registerCommands() { + this.commands['models'] = () => this.listModels(); + this.commands['model-info'] = (args) => this.modelInfo(args); + this.commands['model-pull'] = (args) => this.pullModel(args); + this.commands['model-delete'] = (args) => this.deleteModel(args); + this.commands['model-run'] = (args) => this.runModel(args); + } + async listModels() { + const endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + try { + const response = await fetch(`${endpoint}/api/tags`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const models = data.models || []; + if (models.length === 0) { + this.terminal.print("No models found. Run 'ollama pull <model>' first.", "warning"); return; + } + this.terminal.print("=== Available Ollama Models ===", "info"); + this.terminal.printHTML(`<table style="width: 100%; border-collapse: collapse; margin: 10px 0;"> + <tr style="border-bottom: 1px solid #005500;"> + <th style="text-align: left; padding: 5px;">Name</th> + <th style="text-align: left; padding: 5px;">Size</th> + <th style="text-align: left; padding: 5px;">Family</th> + </tr>`); + models.forEach(m => { + const sizeGB = (m.size / 1e9).toFixed(2); + this.terminal.printHTML(`<tr style="border-bottom: 1px solid #111;"> + <td style="padding: 5px; color: #00ff00;">${m.name}</td> + <td style="padding: 5px; color: #888888;">${sizeGB} GB</td> + <td style="padding: 5px; color: #00aaff;">${m.details?.family || 'N/A'}</td> + </tr>`); + }); + this.terminal.printHTML(`</table>`); + } catch (e) { + this.terminal.print(`Failed to fetch models: ${e.message}`, "error"); + } + } + async modelInfo(args) { + if (!args[0]) { + this.terminal.print("Usage: model-info <model-name>", "warning"); return; + } + const endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + try { + const response = await fetch(`${endpoint}/api/show`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: args[0] }) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.terminal.print(`=== ${args[0]} ===`, "info"); + this.terminal.print(`License: ${data.license || 'N/A'}`); + this.terminal.print(`Parameters: ${data.details?.parameter_size || 'N/A'}`); + this.terminal.print(`Family: ${data.details?.family || 'N/A'}`); + } catch (e) { + this.terminal.print(`Failed to get model info: ${e.message}`, "error"); + } + } + pullModel(args) { + this.terminal.print("⚠️ Model pulling must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama pull ${args[0] || '<model-name>'}`, "info"); + } + deleteModel(args) { + this.terminal.print("⚠️ Model deletion must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama rm ${args[0] || '<model-name>'}`, "info"); + } + async runModel(args) { + if (!args[0]) { + this.terminal.print("Usage: model-run <model-name> [prompt]", "warning"); return; + } + const model = args[0]; + const prompt = args.slice(1).join(' ') || 'Hello!'; + this.terminal.print(`Running ${model}...`, "info"); + await this.terminal.ollamaManager.chat(prompt, model); + } + } diff --git a/modules/core/module-searxng.js b/modules/core/module-searxng.js new file mode 100644 index 0000000..f2c2e72 --- /dev/null +++ b/modules/core/module-searxng.js @@ -0,0 +1,73 @@ +class SearXNGModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'search': 'search <query> [--summarize] [--fetch] — Search using SearXNG; --summarize/-s adds AI overview, --fetch/-f fetches page content', + 's': 's <query> [--summarize] [--fetch] — Alias for search', + }; + } + registerCommands() { + this.commands['search'] = (args) => this.search(args); + this.commands['s'] = (args) => this.search(args); + } + async search(args) { + if (!args[0]) { + this.terminal.print("Usage: search <query> [--summarize] [--fetch]", "warning"); + this.terminal.print("Example: search quantum computing --summarize", "info"); + return; + } + const argsList = args.slice(); + const summarize = argsList.includes('--summarize') || argsList.includes('-s'); + const fetchContent = argsList.includes('--fetch') || argsList.includes('-f'); + const query = argsList.filter(a => !a.startsWith('-')).join(' '); + if (!query) { this.terminal.print('Search query is required', 'warning'); return; } + + this.terminal.print(`🔍 Searching: ${query}`, 'info'); + + let params = 'q=' + encodeURIComponent(query); + if (summarize) params += '&summarize=true'; + if (fetchContent) params += '&fetch_content=true'; + + try { + const resp = await fetch('/api/search?' + params); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Search failed: ' + (err.error || 'HTTP ' + resp.status), 'error'); + return; + } + const data = await resp.json(); + const results = data.results || []; + + if (results.length === 0) { + this.terminal.print('No results found.', 'info'); + return; + } + + this.terminal.print(`\n=== ${results.length} results (via ${data.source || 'unknown'}) ===\n`, 'success'); + + if (data.ai_overview) { + this.terminal.print('🤖 AI Overview:', 'info'); + this.terminal.print(data.ai_overview, 'ollama'); + this.terminal.print(''); + } + + results.forEach((r, i) => { + const snippet = r.content || ''; + const contentSnippet = r.content_snippet || ''; + this.terminal.printHTML(` + <div class="search-result"> + <strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong><br/> + <span style="color: #888888; font-size: 0.85em;">${r.url || 'No URL'}</span><br/> + <span style="color: #00ff00; font-size: 0.85em;">${(snippet || '').substring(0, 200)}...</span> + ${contentSnippet ? `<br/><span style="color: #aaaaaa; font-size: 0.8em;">📄 ${contentSnippet.substring(0, 150)}...</span>` : ''} + </div> + `); + }); + } catch (e) { + this.terminal.print('Search failed: ' + e.message, 'error'); + } + } +} diff --git a/modules/core/module-servermon.js b/modules/core/module-servermon.js new file mode 100644 index 0000000..23407db --- /dev/null +++ b/modules/core/module-servermon.js @@ -0,0 +1,107 @@ +// Extracted from newOS4 index.html — ServerMonitorModule + class ServerMonitorModule { + constructor() { + this.commands = {}; this.terminal = null; + this.servers = this.loadServers(); this.monitorInterval = null; + } + getHelpInfo() { + return { + 'server': 'Add a server to monitor: server <name> <url>', + 'servers': 'List all monitored servers', + 'server-check': 'Check a server status: server-check <name-or-index>', + 'server-remove': 'Remove a server: server-remove <name-or-index>', + 'monitor': 'Toggle auto-monitoring (checks every 60s)' + }; + } + loadServers() { + try { + const saved = localStorage.getItem('webx11_servers'); + return saved ? JSON.parse(saved) : []; + } catch (e) { return []; } + } + saveServers() { + localStorage.setItem('webx11_servers', JSON.stringify(this.servers)); + } + registerCommands() { + this.commands['server'] = (args) => this.addServer(args); + this.commands['servers'] = () => this.listServers(); + this.commands['server-check'] = (args) => this.checkServer(args); + this.commands['server-remove'] = (args) => this.removeServer(args); + this.commands['monitor'] = (args) => this.toggleMonitor(args); + } + addServer(args) { + if (args.length < 2) { + this.terminal.print("Usage: server <name> <url>", "warning"); return; + } + const name = args[0], url = args[1]; + this.servers.push({ name, url, status: 'unknown', lastCheck: null }); + this.saveServers(); + this.terminal.print(`Server '${name}' added`, "success"); + } + listServers() { + if (this.servers.length === 0) { + this.terminal.print("No servers configured", "info"); return; + } + this.terminal.print("=== Monitored Servers ===", "info"); + this.servers.forEach((s, i) => { + const statusIcon = s.status === 'online' ? '🟢' : s.status === 'offline' ? '🔴' : '⚪'; + this.terminal.print(`${i + 1}. ${statusIcon} ${s.name} - ${s.url}`); + if (s.lastCheck) { this.terminal.print(` Last check: ${s.lastCheck}`, "info"); } + }); + } + async checkServer(args) { + if (!args[0]) { + this.terminal.print("Usage: server-check <name-or-index>", "warning"); return; + } + const server = this.findServer(args[0]); + if (!server) { + this.terminal.print("Server not found", "error"); return; + } + this.terminal.print(`Checking ${server.name}...`, "info"); + try { + const start = Date.now(); + const response = await fetch(server.url, { method: 'HEAD', mode: 'no-cors' }); + const latency = Date.now() - start; + server.status = 'online'; server.lastCheck = new Date().toLocaleString(); server.latency = latency; + this.saveServers(); + this.terminal.print(`✅ ${server.name} is online (${latency}ms)`, "success"); + } catch (e) { + server.status = 'offline'; server.lastCheck = new Date().toLocaleString(); + this.saveServers(); + this.terminal.print(`❌ ${server.name} is offline`, "error"); + } + } + removeServer(args) { + if (!args[0]) { + this.terminal.print("Usage: server-remove <name-or-index>", "warning"); return; + } + const index = this.findServerIndex(args[0]); + if (index === -1) { + this.terminal.print("Server not found", "error"); return; + } + this.servers.splice(index, 1); + this.saveServers(); + this.terminal.print("Server removed", "success"); + } + toggleMonitor(args) { + if (this.monitorInterval) { + clearInterval(this.monitorInterval); + this.monitorInterval = null; + this.terminal.print("Auto-monitor stopped", "warning"); + } else { + this.terminal.print("Auto-monitor started (every 60s)", "success"); + this.monitorInterval = setInterval(() => { + this.servers.forEach((s, i) => { this.checkServer([String(i + 1)]); }); + }, 60000); + } + } + findServer(identifier) { + const index = this.findServerIndex(identifier); + return index !== -1 ? this.servers[index] : null; + } + findServerIndex(identifier) { + const idx = parseInt(identifier) - 1; + if (!isNaN(idx) && idx >= 0 && idx < this.servers.length) { return idx; } + return this.servers.findIndex(s => s.name.toLowerCase() === identifier.toLowerCase()); + } + } diff --git a/modules/core/module-simplegui.js b/modules/core/module-simplegui.js new file mode 100644 index 0000000..78fa9b5 --- /dev/null +++ b/modules/core/module-simplegui.js @@ -0,0 +1,67 @@ +// Extracted from newOS4 index.html — SimpleGUIModule + class SimpleGUIModule { + constructor() { this.commands = {}; this.terminal = null; this.guiElements = {}; } + getHelpInfo() { + return { + 'gui': 'gui — Show the GUI builder overview', + 'gui-button': 'gui-button <label> <command> — Add a clickable button', + 'gui-input': 'gui-input <placeholder> <varName> — Add a text input field', + 'gui-text': 'gui-text <content> — Add a text element', + 'gui-clear': 'gui-clear — Clear all GUI elements', + 'gui-run': 'gui-run <command> — Run a terminal command from the GUI' + }; + } + registerCommands() { + this.commands['gui'] = () => this.showGUI(); + this.commands['gui-button'] = (args) => this.createButton(args); + this.commands['gui-input'] = (args) => this.createInput(args); + this.commands['gui-text'] = (args) => this.createText(args); + this.commands['gui-clear'] = () => this.clearGUI(); + this.commands['gui-run'] = (args) => this.runCommand(args); + } + showGUI() { + this.terminal.print("=== Simple GUI Builder ===", "info"); + this.terminal.print("Commands: gui-button, gui-input, gui-text, gui-clear", "info"); + } + createButton(args) { + if (args.length < 2) { + this.terminal.print("Usage: gui-button <label> <command>", "warning"); return; + } + const label = args[0], command = args.slice(1).join(' '); + const id = 'gui-btn-' + Date.now(); + this.terminal.printHTML(`<div style="margin: 5px 0;"> + <button class="qa-btn" onclick="${command}" id="${id}">${label}</button> + </div>`); + this.guiElements[id] = { type: 'button', label, command }; + this.terminal.print(`Button '${label}' created`, "success"); + } + createInput(args) { + if (args.length < 2) { + this.terminal.print("Usage: gui-input <placeholder> <varName>", "warning"); return; + } + const placeholder = args[0], varName = args[1], id = 'gui-input-' + Date.now(); + this.terminal.printHTML(`<div style="margin: 5px 0;"> + <input type="text" id="${id}" placeholder="${placeholder}" + style="background: #0a0a0a; border: 1px solid #005500; + color: #00ff00; padding: 8px; width: 100%;" /> + </div>`); + this.guiElements[id] = { type: 'input', placeholder, varName }; + this.terminal.print(`Input '${placeholder}' created (var: ${varName})`, "success"); + } + createText(args) { + if (!args[0]) { + this.terminal.print("Usage: gui-text <content>", "warning"); return; + } + const content = args.join(' '); + this.terminal.printHTML(`<div style="margin: 5px 0; padding: 8px; + background: #111111; border-left: 3px solid #00aaff;">${content}</div>`); + this.terminal.print("Text element created", "success"); + } + clearGUI() { this.guiElements = {}; this.terminal.print("GUI elements cleared", "success"); } + runCommand(args) { + if (!args[0]) { + this.terminal.print("Usage: gui-run <command>", "warning"); return; + } + this.terminal.executeCommand(args.join(' ')); + } + } diff --git a/modules/core/module-skills.js b/modules/core/module-skills.js new file mode 100644 index 0000000..ed6e14f --- /dev/null +++ b/modules/core/module-skills.js @@ -0,0 +1,271 @@ +// Extracted from newOS4 index.html — SkillsModule (v4.2 — Self-improving AI) + class SkillsModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'skill-list': 'skill-list — List all installed skills', + 'skill-view': 'skill-view <name> — View the content of a skill', + 'skill-create': 'skill-create <name> [desc] [category] [tags] — Create a new skill', + 'skill-patch': 'skill-patch <name> [content...] — Update skill content (no args to view current)', + 'skill-delete': 'skill-delete <name> — Delete a skill', + 'skill-export': 'skill-export <name> — Export a skill to WebDAV', + 'skill-import': 'skill-import <name> — Import a skill from WebDAV', + 'skill-sync': 'skill-sync — Bidirectional sync all skills with WebDAV', + }; + } + ensureSkillsDir() { + if (this.terminal && this.terminal.fs) { + const fs = this.terminal.fs; + // Ensure /skills/ directory exists + if (!fs.root.content['skills']) { + fs.root.content['skills'] = { type: 'dir', content: {} }; + fs.saveToStorage(); + } + } + } + registerCommands() { + this.commands['skill-list'] = () => this.listSkills(); + this.commands['skill-view'] = (args) => this.viewSkill(args); + this.commands['skill-create'] = (args) => this.createSkill(args); + this.commands['skill-patch'] = (args) => this.patchSkill(args); + this.commands['skill-delete'] = (args) => this.deleteSkill(args); + this.commands['skill-export'] = (args) => this.exportSkill(args); + this.commands['skill-import'] = (args) => this.importSkill(args); + this.commands['skill-sync'] = () => this.syncSkills(); + } + listSkills() { + this.ensureSkillsDir(); + const index = this.terminal.fs.fsReadJSON('/skills/index.json') || {}; + const names = Object.keys(index); + if (names.length === 0) { + this.terminal.print('No skills found.', 'info'); + return; + } + this.terminal.print(`=== Skills (${names.length}) ===`, 'info'); + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + this.terminal.print(`📘 ${name} — ${s.description} [${s.category}] ${tags ? '(' + tags + ')' : ''}`); + }); + } + viewSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-view <name>', 'warning'); + return; + } + const content = this.terminal.fs.fsRead(`/skills/${name}.md`); + if (!content) { + this.terminal.print(`Skill '${name}' not found`, 'error'); + return; + } + this.terminal.print(`=== ${name} ===`, 'info'); + this.terminal.print(content, 'info'); + } + createSkill(args) { + this.ensureSkillsDir(); + const name = args[0]; + if (!name) { + this.terminal.print('Usage: skill-create <name> [description] [category] [tag1,tag2,...]', 'warning'); + return; + } + const description = args[1] || 'No description'; + const category = args[2] || 'general'; + const tags = (args[3] || '').split(',').filter(Boolean); + const fs = this.terminal.fs; + if (fs.fsRead(`/skills/${name}.md`)) { + this.terminal.print(`Skill '${name}' already exists. Use skill-patch to modify it.`, 'warning'); + return; + } + const content = '---\nname: ' + name + '\ndescription: ' + description + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + category + '\n---\n## Steps\n1. (to be filled)\n'; + fs.fsWrite(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, category, tags }; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`Skill '${name}' created. Edit its content with skill-patch ${name}`, 'success'); + } + patchSkill(args) { + const name = args[0]; + if (!name) { + this.terminal.print('Usage: skill-patch <name> [content...]', 'warning'); + return; + } + const fs = this.terminal.fs; + const existing = fs.fsRead(`/skills/${name}.md`); + if (!existing) { + this.terminal.print(`Skill '${name}' not found. Use skill-create first.`, 'error'); + return; + } + if (args.length < 2) { + // No new content — show the skill so user can copy-paste + this.terminal.print(`=== ${name} (current content) ===`, 'info'); + this.terminal.print(existing, 'info'); + this.terminal.print('---', 'info'); + this.terminal.print('Pass new content as additional arguments: skill-patch <name> <full content>', 'warning'); + return; + } + const newContent = args.slice(1).join(' '); + fs.fsWrite(`/skills/${name}.md`, newContent); + this.terminal.print(`Skill '${name}' patched`, 'success'); + } + deleteSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-delete <name>', 'warning'); + return; + } + const fs = this.terminal.fs; + if (!fs.fsRead(`/skills/${name}.md`)) { + this.terminal.print(`Skill '${name}' not found`, 'error'); + return; + } + fs.fsWrite(`/skills/${name}.md`, ''); + const index = fs.fsReadJSON('/skills/index.json') || {}; + delete index[name]; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`Skill '${name}' deleted`, 'success'); + } + getWebDAVModule() { + return this.terminal && this.terminal.moduleManager.modules['WebDAVModule']; + } + async exportSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-export <name>', 'warning'); + return; + } + const wd = this.getWebDAVModule(); + if (!wd || !wd.config || !wd.config.url) { + this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); + return; + } + this.ensureSkillsDir(); + const fs = this.terminal.fs; + const content = fs.fsRead(`/skills/${name}.md`); + if (!content) { + this.terminal.print(`Skill '${name}' not found in VFS`, 'error'); + return; + } + const index = fs.fsReadJSON('/skills/index.json') || {}; + const meta = index[name]; + try { + // Ensure remote skills dir + if (typeof wd._ensureDir === 'function') await wd._ensureDir('skills'); + // Push the skill file + const resp1 = await wd._webdavRequest('PUT', 'skills/' + name + '.md', { + body: content, + headers: { 'Content-Type': 'text/markdown' } + }); + if (!resp1.ok && resp1.status !== 201 && resp1.status !== 204) { + throw new Error('Upload failed: HTTP ' + resp1.status); + } + // Push updated index + const indexResp = await wd._webdavRequest('PUT', 'skills/index.json', { + body: JSON.stringify(index, null, 2), + headers: { 'Content-Type': 'application/json' } + }); + if (indexResp.ok || indexResp.status === 201 || indexResp.status === 204) { + this.terminal.print(`Skill '${name}' exported to WebDAV skills/`, 'success'); + } + } catch (e) { + this.terminal.print('Export failed: ' + e.message, 'error'); + } + } + async importSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-import <name>', 'warning'); + return; + } + const wd = this.getWebDAVModule(); + if (!wd || !wd.config || !wd.config.url) { + this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); + return; + } + try { + const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md'); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const content = await resp.text(); + this.ensureSkillsDir(); + const fs = this.terminal.fs; + fs.fsWrite(`/skills/${name}.md`, content); + // Try to pull the index to get metadata; if fails, create minimal entry + let meta = { name, description: 'Imported from WebDAV', category: 'general', tags: [] }; + try { + const indexResp = await wd._webdavRequest('GET', 'skills/index.json'); + if (indexResp.ok) { + const remoteIndex = JSON.parse(await indexResp.text()); + if (remoteIndex && remoteIndex[name]) { + meta = remoteIndex[name]; + } + } + } catch (e) { /* use default meta */ } + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = meta; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`Skill '${name}' imported from WebDAV (${content.length} bytes)`, 'success'); + } catch (e) { + this.terminal.print('Import failed: ' + e.message, 'error'); + } + } + async syncSkills() { + const wd = this.getWebDAVModule(); + if (!wd || !wd.config || !wd.config.url) { + this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); + return; + } + this.ensureSkillsDir(); + const fs = this.terminal.fs; + const localIndex = fs.fsReadJSON('/skills/index.json') || {}; + const localNames = new Set(Object.keys(localIndex)); + this.terminal.print('Syncing skills with WebDAV...', 'info'); + let imported = 0, exported = 0; + try { + // Try to get remote index + let remoteIndex = {}; + try { + const resp = await wd._webdavRequest('GET', 'skills/index.json'); + if (resp.ok) { + remoteIndex = JSON.parse(await resp.text()) || {}; + } + } catch (e) { /* no remote index yet */ } + const remoteNames = new Set(Object.keys(remoteIndex)); + // Export local-only skills to remote + for (const name of localNames) { + if (!remoteNames.has(name)) { + const content = fs.fsRead(`/skills/${name}.md`); + if (content) { + await wd._ensureDir('skills'); + await wd._webdavRequest('PUT', 'skills/' + name + '.md', { + body: content, + headers: { 'Content-Type': 'text/markdown' } + }); + exported++; + } + } + } + // Import remote-only skills to local + for (const name of remoteNames) { + if (!localNames.has(name)) { + const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md'); + if (resp.ok) { + const content = await resp.text(); + fs.fsWrite(`/skills/${name}.md`, content); + localIndex[name] = remoteIndex[name]; + imported++; + } + } + } + // Push updated local index back to remote + fs.fsWriteJSON('/skills/index.json', localIndex); + await wd._ensureDir('skills'); + await wd._webdavRequest('PUT', 'skills/index.json', { + body: JSON.stringify(localIndex, null, 2), + headers: { 'Content-Type': 'application/json' } + }); + this.terminal.print(`Sync complete: ${exported} exported, ${imported} imported`, 'success'); + } catch (e) { + this.terminal.print('Sync failed: ' + e.message, 'error'); + } + } + } diff --git a/modules/core/module-smartctx.js b/modules/core/module-smartctx.js new file mode 100644 index 0000000..2b4712d --- /dev/null +++ b/modules/core/module-smartctx.js @@ -0,0 +1,93 @@ +// Extracted from newOS4 index.html — SmartContextModule + class SmartContextModule { + constructor() { + this.commands = {}; this.terminal = null; + this.contextProfiles = this.loadProfiles(); this.activeProfile = null; + } + getHelpInfo() { + return { + 'context': 'context [name] — Set or view current context profile', + 'contexts': 'contexts — List all available context profiles', + 'context-new': 'context-new <name> <displayName> <systemPrompt> — Create a new context profile', + 'context-delete': 'context-delete <name> — Delete a context profile', + 'smart-ask': 'smart-ask <question> — Ask a question using the active context' + }; + } + loadProfiles() { + try { + const saved = localStorage.getItem('webx11_context_profiles'); + return saved ? JSON.parse(saved) : this.getDefaultProfiles(); + } catch (e) { return this.getDefaultProfiles(); } + } + saveProfiles() { + localStorage.setItem('webx11_context_profiles', JSON.stringify(this.contextProfiles)); + } + getDefaultProfiles() { + return { + 'coding': { name: 'Coding Assistant', systemPrompt: "You are an expert programmer.", temperature: 0.7 }, + 'debug': { name: 'Debug Helper', systemPrompt: "You are a debugging expert.", temperature: 0.5 }, + 'creative': { name: 'Creative Writer', systemPrompt: "You are a creative writer.", temperature: 0.9 }, + 'technical': { name: 'Technical Docs', systemPrompt: "You are a technical writer.", temperature: 0.3 }, + 'chat': { name: 'Casual Chat', systemPrompt: "You are a friendly assistant.", temperature: 0.8 } + }; + } + registerCommands() { + this.commands['context'] = (args) => this.setContext(args); + this.commands['contexts'] = () => this.listContexts(); + this.commands['context-new'] = (args) => this.createContext(args); + this.commands['context-delete'] = (args) => this.deleteContext(args); + this.commands['smart-ask'] = (args) => this.smartAsk(args); + } + setContext(args) { + if (!args[0]) { + this.terminal.print(`Current context: ${this.activeProfile || 'none'}`, "info"); return; + } + if (this.contextProfiles[args[0]]) { + this.activeProfile = args[0]; + this.terminal.print(`Context set to: ${this.contextProfiles[args[0]].name}`, "success"); + } else { + this.terminal.print(`Profile '${args[0]}' not found`, "error"); + } + } + listContexts() { + this.terminal.print("=== Context Profiles ===", "info"); + Object.entries(this.contextProfiles).forEach(([key, profile]) => { + const active = key === this.activeProfile ? ' ✅' : ''; + this.terminal.print(`${key}: ${profile.name}${active}`); + }); + } + createContext(args) { + if (args.length < 3) { + this.terminal.print("Usage: context-new <name> <displayName> <systemPrompt>", "warning"); return; + } + const name = args[0], displayName = args[1], systemPrompt = args.slice(2).join(' '); + this.contextProfiles[name] = { name: displayName, systemPrompt, temperature: 0.7 }; + this.saveProfiles(); + this.terminal.print(`Context profile '${name}' created`, "success"); + } + deleteContext(args) { + if (!args[0]) { + this.terminal.print("Usage: context-delete <name>", "warning"); return; + } + if (this.contextProfiles[args[0]]) { + delete this.contextProfiles[args[0]]; + this.saveProfiles(); + if (this.activeProfile === args[0]) { this.activeProfile = null; } + this.terminal.print(`Context profile '${args[0]}' deleted`, "success"); + } else { + this.terminal.print(`Profile '${args[0]}' not found`, "error"); + } + } + async smartAsk(args) { + if (!args[0]) { + this.terminal.print("Usage: smart-ask <your-question>", "warning"); return; + } + let systemPrompt = "You are a helpful assistant."; + if (this.activeProfile && this.contextProfiles[this.activeProfile]) { + systemPrompt = this.contextProfiles[this.activeProfile].systemPrompt; + this.terminal.print(`Using context: ${this.contextProfiles[this.activeProfile].name}`, "info"); + } + const fullPrompt = `${systemPrompt}\n\nUser: ${args.join(' ')}`; + await this.terminal.ollamaManager.chat(fullPrompt); + } + } diff --git a/modules/core/module-system.js b/modules/core/module-system.js new file mode 100644 index 0000000..f21946a --- /dev/null +++ b/modules/core/module-system.js @@ -0,0 +1,154 @@ +// Extracted from newOS4 index.html — SystemModule + class SystemModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'help': 'help [command] — Show all commands or usage for a specific command', + 'echo': 'echo <text...> — Print text to the terminal', + 'mem': 'mem — Show current memory usage', + 'status': 'status — Display system status (network, files, modules, Ollama)', + 'preview': 'preview <file.html> — Render an HTML file from the VFS in a preview pane', + 'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)', + 'reset-vfs': 'reset-vfs [--confirm] — Clear only the virtual filesystem (keeps settings, modules, AI profile)', + 'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)', + 'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)', + 'setfont': 'setfont <8-32> — Alias for font-size', + 'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)', + 'checkpoint': 'checkpoint [name] — Create a VFS snapshot (name defaults to ISO timestamp)', + 'checkpoint-list': 'checkpoint-list — List all VFS checkpoints', + 'rollback': 'rollback <name> — Restore VFS from a checkpoint', + 'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint', + 'commands': 'commands — List all commands with module source and requirements status', + 'check': 'check — Show overall system health including module requirements', + }; + } + registerCommands() { + this.commands['help'] = (args) => this.handleHelp(args); + this.commands['echo'] = (args) => this.terminal.print(args.join(' ')); + this.commands['mem'] = () => { + const mem = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A'; + this.terminal.print(`Memory Usage: ${mem} MB`, 'info'); + document.getElementById('header-status').innerText = `Mem: ${mem}MB`; + }; + this.commands['status'] = () => { + this.terminal.print(`System: WebX11 v4.2`); + this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`); + this.terminal.print(`Files: ${this.terminal.fs.ls().length} items`); + this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`); + this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`); + }; + this.commands['preview'] = (args) => this.terminal.openPreview(args); + this.commands['factory-reset'] = (args) => { + if (!args[0] || args[0] !== '--confirm') { + this.terminal.print('⚠️ FACTORY RESET will clear all data:', 'error'); + this.terminal.print(' • Filesystem (all files + directories)', 'warning'); + this.terminal.print(' • AI memory, patterns, user profile', 'warning'); + this.terminal.print(' • Ollama endpoint, model selection', 'warning'); + this.terminal.print(' • Terminal history, font size', 'warning'); + this.terminal.print(' • All config, modules, packages', 'warning'); + this.terminal.print(' • Mindmaps, notes, bookmarks', 'warning'); + this.terminal.print(' • Themes, context profiles, servers', 'warning'); + this.terminal.print(' • Chat settings, my data', 'warning'); + this.terminal.print('Type: factory-reset --confirm to proceed', 'error'); + return; + } + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith('webx11_')) localStorage.removeItem(key); + } + this.terminal.fs.root = { type: 'dir', content: {} }; + this.terminal.fs.currentPath = []; + this.terminal.outputContainer.innerHTML = ''; + this.terminal.print('✅ Factory reset complete. Reloading...', 'success'); + setTimeout(() => location.reload(), 1000); + }; + this.commands['reset-vfs'] = (args) => { + if (!args[0] || args[0] !== '--confirm') { + this.terminal.print('⚠️ RESET VFS will clear all files and directories:', 'warning'); + this.terminal.print(' • Virtual filesystem (all files + directories)', 'warning'); + this.terminal.print(' • VFS will be reset to empty root', 'warning'); + this.terminal.print(' • All settings, modules, AI profile preserved', 'info'); + this.terminal.print('Type: reset-vfs --confirm to proceed', 'error'); + return; + } + this.terminal.fs.root = { type: 'dir', content: {} }; + this.terminal.fs.currentPath = []; + this.terminal.fs.saveToStorage(); + this.terminal.print('✅ VFS reset complete. Reloading...', 'success'); + setTimeout(() => location.reload(), 1000); + }; + this.commands['logs'] = (args) => { + const n = parseInt(args[0]) || 20; + const level = args[1] ? args[1].toUpperCase() : null; + const content = this.terminal.fs.readFile('/logs/current.log'); + if (!content) { this.terminal.print('No logs yet', 'info'); return; } + let lines = content.split('\n').filter(l => l); + if (level) lines = lines.filter(l => l.includes('[' + level + ']')); + const tail = lines.slice(-n); + if (tail.length === 0) { this.terminal.print('No matching log entries', 'info'); return; } + this.terminal.print('--- Logs (last ' + tail.length + ') ---', 'info'); + tail.forEach(l => this.terminal.print(l, l.includes('[ERROR]') ? 'error' : 'info')); + }; + } + handleHelp(args) { + const helpMap = this.terminal.moduleManager.getAllHelp(); + // Single command lookup + if (args && args.length > 0) { + const cmd = args[0].toLowerCase(); + const allCommands = this.terminal.moduleManager.getAllCommands(); + if (allCommands[cmd]) { + const helpEntry = helpMap[cmd]; + if (helpEntry) { + this.terminal.print(helpEntry, 'info'); + } else { + this.terminal.print(`No help available for ${cmd}`, 'warning'); + } + } else { + this.terminal.print(`No help available for ${cmd}`, 'warning'); + } + return; + } + // Categorize all commands + const cats = { + files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff'], + 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'], + system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs'], + net: ['server','servers','server-check','server-remove','monitor'], + mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'], + ollama: ['ollama','ollamaconnect','ollamadisconnect','ollamamodels','ollamasetmodel','ollamasetproxy','models','model-info','model-pull','model-delete','model-run'], + skills: ['skill-list','skill-view','skill-create','skill-patch','skill-delete','skill-export','skill-import','skill-sync','skill-pack-core'], + config: ['config','config-set','config-list','config-delete','config-export','config-reset'], + webx: ['webxai','wx','webx','context','contexts','context-new','context-delete','smart-ask','prompt','prompt-save','prompt-list','prompt-delete','prompt-var','prompt-vars'], + data: ['my','my-set','my-get','my-list','my-export','my-import','my-clear','export-mods','import-mods','backup','restore','share-mod'], + ext: ['chat','chat-setapi','chat-setmodel','chat-ask','webdav-config','webdav-status','webdav-ls','webdav-push','webdav-pull','webdav-backup','webdav-restore','webdav-backup-list'], + gui: ['gui','gui-button','gui-input','gui-text','gui-clear','gui-run'], + }; + const labels = { + files:'📁 Filesystem', ai:'🤖 AI', search:'🔍 Search & Web', tools:'🔧 Tools', + system:'⚙️ System', net:'📡 Network', mods:'🧩 Modules', ollama:'🦙 Ollama', + skills:'📚 Skills', config:'⚙️ Config', webx:'🌐 WebX', data:'💾 Data', + ext:'🧰 Extras', gui:'🖥️ GUI' + }; + let result = '╔══════════════════════════════════════╗\n║ 🔷 WebX11 v4.2 — All Commands ║\n╚══════════════════════════════════════╝\n\n'; + const total = Object.keys(helpMap).length; + result += `📊 ${total} commands loaded\n\n`; + for (const [key, cmds] of Object.entries(cats)) { + const present = cmds.filter(c => helpMap[c]); + if (present.length === 0) continue; + result += labels[key] + '\n'; + present.forEach(c => { + const desc = (helpMap[c] || c).split(' — ')[1] || ''; + const short = desc.substring(0, 55); + result += ` ${c.padEnd(16)}${short}\n`; + }); + result += '\n'; + } + result += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n help <command> → detailed usage\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + this.terminal.print(result.trim(), 'info'); + } + } diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js new file mode 100644 index 0000000..c790e6f --- /dev/null +++ b/modules/core/ollama-manager.js @@ -0,0 +1,842 @@ +// Extracted from newOS4 index.html — OllamaManager / LLMRouter +// Refactored to support multiple LLM providers with auto-fallback +// Class name kept as OllamaManager for backward compatibility +class OllamaManager { + constructor(terminal) { + this.terminal = terminal; + // --- Backward-compat properties (reflected from active provider) --- + this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + this.connected = false; + this.models = []; + this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; + this.responseHooks = []; + this._abortController = null; + // Build fallback endpoints + this.fallbackEndpoints = []; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + if (origin) { + this.fallbackEndpoints.push(origin + '/ollama/'); + } + + // --- Multi-provider architecture --- + this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled } + this.currentProvider = null; // currently active provider object + this.fallbackCount = 0; + this._loadProviders(); + + // If no active provider set, pick the highest-priority enabled one + if (!this.currentProvider) { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + } + + // ─── Provider management ───────────────────────────────────────────────── + + _loadProviders() { + const stored = this._getSecret('webx11_llm_providers'); + if (stored) { + try { + this.providers = JSON.parse(stored); + // Ensure all entries have the 'enabled' field + this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; }); + } catch (e) { + this.providers = []; + } + } + if (this.providers.length === 0) { + // Default single provider: Ollama + this.providers = [{ + name: 'Ollama', + type: 'ollama', + endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434', + apiKey: '', + model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: 0, + enabled: true + }]; + } + } + + _saveProviders() { + this._setSecret('webx11_llm_providers', JSON.stringify(this.providers)); + } + + _getSecret(key) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') { + return window.webx11_secretVault.decrypt(key); + } + return localStorage.getItem(key); + } + + _setSecret(key, value) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') { + window.webx11_secretVault.encrypt(key, value); + } else { + localStorage.setItem(key, value); + } + } + + _getSortedEnabledProviders() { + return this.providers + .filter(p => p.enabled) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); + } + + _syncCompatProperties(provider) { + if (!provider) return; + this.endpoint = provider.endpoint; + this.currentModel = provider.model || this.currentModel; + // Don't override backward-compat localStorage unless explicitly set + } + + _getProviderByType(type) { + return this.providers.find(p => p.type === type && p.enabled); + } + + addProvider(name, type, endpoint, apiKey, model, priority) { + const existing = this.providers.findIndex(p => p.name === name); + const entry = { + name: name, + type: type || 'ollama', + endpoint: endpoint || 'http://localhost:11434', + apiKey: apiKey || '', + model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: priority !== undefined ? priority : this.providers.length, + enabled: true + }; + if (existing >= 0) { + this.providers[existing] = entry; + } else { + this.providers.push(entry); + } + this._saveProviders(); + // If this is the new highest-priority enabled provider, activate it + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0 && sorted[0].name === entry.name) { + this.currentProvider = entry; + this._syncCompatProperties(entry); + } + return true; + } + + removeProvider(name) { + const idx = this.providers.findIndex(p => p.name === name); + if (idx < 0) return false; + this.providers.splice(idx, 1); + if (this.currentProvider && this.currentProvider.name === name) { + this.currentProvider = null; + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + this._saveProviders(); + return true; + } + + listProviders() { + return this.providers; + } + + setActiveProvider(name) { + const provider = this.providers.find(p => p.name === name); + if (!provider) { + this.terminal.print(`Provider "${name}" not found`, 'error'); + return false; + } + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success'); + return true; + } + + getActiveProvider() { + return this.currentProvider; + } + + getProviderStats() { + const sorted = this._getSortedEnabledProviders(); + const connectedProvider = this.connected + ? (this.currentProvider ? this.currentProvider.name : 'unknown') + : null; + return { + currentProvider: this.currentProvider ? this.currentProvider.name : null, + totalProviders: this.providers.length, + connectedProvider: connectedProvider, + fallbackCount: this.fallbackCount + }; + } + + // ─── Connection ────────────────────────────────────────────────────────── + + async connect() { + const sortedProviders = this._getSortedEnabledProviders(); + if (sortedProviders.length === 0) { + this.terminal.print('No enabled providers configured', 'error'); + this.connected = false; + return false; + } + + // Also build endpoint-based fallback list for backward compat + const allEndpoints = []; + // First try the original endpoint + fallbackEndpoints for plain Ollama connect + if (this._getProviderByType('ollama')) { + allEndpoints.push(this.endpoint); + for (const fb of this.fallbackEndpoints) { + if (!allEndpoints.includes(fb)) allEndpoints.push(fb); + } + } + + // Try each provider in priority order + for (const provider of sortedProviders) { + let success = false; + if (provider.type === 'ollama') { + // Try provider endpoint first, then fallback endpoints + const eps = [provider.endpoint]; + for (const fb of this.fallbackEndpoints) { + if (!eps.includes(fb)) eps.push(fb); + } + for (const ep of eps) { + try { + this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + this.currentProvider = provider; + provider.endpoint = ep; // may have been updated via fallback + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + break; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message); + this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + } else { + // OpenAI-compatible provider (OpenRouter, etc.) + try { + this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/models`, { + method: 'GET', headers + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = (data.data || data.models || []).map(m => ({ + name: m.id || m.name, + size: m.size || 0 + })); + this.connected = true; + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message); + this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning'); + } + } + + if (success) return true; + } + + // If provider-based connect failed, try bare endpoint fallback (legacy) + for (const ep of allEndpoints) { + try { + this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + if (ep !== this.endpoint) { + this.endpoint = ep; + this.saveSettings(); + this.terminal.print(`Endpoint updated to: ${ep}`, 'info'); + } + this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this.saveSettings(); + return true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message); + this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + + this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers'); + this.connected = false; + return false; + } + + // ─── Chat (non-streaming) ──────────────────────────────────────────────── + + async chat(prompt, model = null) { + if (!this.connected) { + this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error'); + return null; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected. Use ollamamodels to select.', 'error'); + return null; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithProvider(provider, prompt, useModel); + if (result !== null && result !== undefined) { + // Success — if we fell back, increment fallbackCount + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + lastError = result; + } + + if (lastError === null) { + this.terminal.print(`Ollama error: No provider responded`, 'error'); + } + return null; + } + + async _chatWithProvider(provider, prompt, model) { + const useModel = model || provider.model || this.currentModel; + let fullText = ''; + try { + if (provider.type === 'ollama') { + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.response) { + fullText += parsed.response; + this.terminal.printStreamingResponse(parsed.response, !isFirstToken); + isFirstToken = false; + } + } catch (e) { + // Skip malformed partial lines + } + } + } + return fullText || null; + } else { + // OpenAI-compatible provider + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: true + }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullText += content; + this.terminal.printStreamingResponse(content, !isFirstToken); + isFirstToken = false; + } + } catch (e) { + // Skip malformed SSE lines + } + } + } + } + return fullText || null; + } + } catch (error) { + if (fullText && fullText.length > 0) { + this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true); + return fullText; + } + return null; // signal failure so caller can try next provider + } + } + + // ─── Chat Stream ───────────────────────────────────────────────────────── + + async chatStream(prompt, onToken, onComplete, model = null) { + if (!this.connected) { this.terminal.print('Ollama not connected.', 'error'); onComplete && onComplete(null); return; } + const useModel = model || this.currentModel; + if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; } + + const providersToTry = this._getFallbackChain(); + let triedAny = false; + + for (const provider of providersToTry) { + triedAny = true; + const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel); + if (result !== null) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + } + + if (!triedAny) { + this.terminal.print('No model selected.', 'error'); + onComplete && onComplete(null); + } else { + this.terminal.print(`Stream error: No provider responded`, 'error'); + onComplete && onComplete(null); + } + return null; + } + + async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) { + const useModel = model || provider.model || this.currentModel; + try { + if (provider.type === 'ollama') { + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.response) { + fullResponse += obj.response; + onToken && onToken(obj.response, fullResponse); + } + } catch (e) { continue; } + } + } + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer); + if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + } catch (e) {} + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } else { + // OpenAI-compatible provider + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: true + }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullResponse += content; + onToken && onToken(content, fullResponse); + } + } catch (e) { continue; } + } + } + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } + } catch (error) { + this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error'); + onComplete && onComplete(null); + return null; + } + } + + // ─── Chat With Tools ───────────────────────────────────────────────────── + + async chatWithTools(messages, tools, model = null) { + if (!this.connected) { + this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error'); + return { message: null, tool_calls: null, error: 'Not connected' }; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected.', 'error'); + return { message: null, tool_calls: null, error: 'No model' }; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel); + if (result && !result.error) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + // Only keep non-cancellation errors for fallback + if (result && result.error && result.error !== 'Cancelled') { + lastError = result; + } + } + + return lastError || { message: null, tool_calls: null, error: 'All providers failed' }; + } + + async _chatWithToolsWithProvider(provider, messages, tools, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + + try { + if (provider.type === 'ollama') { + const body = { model: useModel, messages, stream: false }; + if (tools && tools.length > 0) body.tools = tools; + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + this._abortController = null; + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + if (response.status === 400) { + return await this.chatWithToolsFallback(messages, tools, useModel, signal); + } + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + return { + message: data.message || null, + tool_calls: data.message?.tool_calls || null, + error: null + }; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + if (tools && tools.length > 0) { + body.tools = tools; + } + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal + }); + this._abortController = null; + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + const choice = data.choices?.[0]; + const message = choice?.message || null; + const toolCalls = message?.tool_calls || null; + return { + message: message ? { role: message.role || 'assistant', content: message.content || '' } : null, + tool_calls: toolCalls ? toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })) : null, + error: null + }; + } + } catch (error) { + if (error.name === 'AbortError') { + this.terminal.print('⏹ AI request cancelled by user', 'warning'); + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + if (error.message === 'Failed to fetch' && provider.type === 'ollama') { + return await this.chatWithToolsFallback(messages, tools, useModel); + } + this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error'); + return { message: null, tool_calls: null, error: error.message }; + } finally { + this._abortController = null; + } + } + + async chatWithToolsFallback(messages, tools, model, signal) { + const provider = this.currentProvider; + const useModel = model || (provider ? provider.model : this.currentModel); + if (provider && provider.type !== 'ollama') { + // For OpenAI-compatible providers, try without tools + this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning'); + try { + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', headers, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.choices?.[0]?.message?.content || ''; + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // Original Ollama fallback + this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning'); + try { + const body = { model: useModel, messages, stream: false }; + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.message?.content || data.response || ''; + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // ─── Internal helpers ──────────────────────────────────────────────────── + + _getFallbackChain() { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length === 0) return []; + + // Start with the current provider (if any), then all others sorted by priority + const chain = []; + const seen = new Set(); + + if (this.currentProvider && this.currentProvider.enabled) { + chain.push(this.currentProvider); + seen.add(this.currentProvider.name); + } + + for (const p of sorted) { + if (!seen.has(p.name)) { + chain.push(p); + seen.add(p.name); + } + } + + return chain; + } + + // ─── Backward-compat methods ───────────────────────────────────────────── + + setEndpoint(url) { + this.endpoint = url; + // Also update the current provider's endpoint if it exists + if (this.currentProvider) { + this.currentProvider.endpoint = url; + this._saveProviders(); + } + this.saveSettings(); + } + + setModel(name) { + this.currentModel = name; + // Also update the current provider's model + if (this.currentProvider) { + this.currentProvider.model = name; + this._saveProviders(); + } + this.saveSettings(); + this.terminal.print(`Model set to: ${name}`, 'success'); + } + + saveSettings() { + localStorage.setItem('webx11_ollama_endpoint', this.endpoint); + localStorage.setItem('webx11_ollama_model', this.currentModel); + } + + getModels() { return this.models; } + + addResponseHook(fn) { this.responseHooks.push(fn); } + + removeResponseHook(fn) { this.responseHooks = this.responseHooks.filter(h => h !== fn); } + + fireResponseHooks(text) { if (text) this.responseHooks.forEach(fn => { try { fn(text); } catch (e) {} }); } + + abort() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + return true; + } + return false; + } +} diff --git a/modules/core/secret-vault.js b/modules/core/secret-vault.js new file mode 100644 index 0000000..e62192a --- /dev/null +++ b/modules/core/secret-vault.js @@ -0,0 +1,459 @@ +// Extracted from newOS4 index.html — SecretVault +class SecretVault { + constructor() { + this.commands = {}; + this.terminal = null; + this._key = null; + this._prefix = 'webx11_secret_'; + this._saltKey = 'webx11_secret_salt'; + this._verifyName = 'secret_vault_verified'; + this._openModeKey = 'webx11_secret_open_key'; + } + + getHelpInfo() { + return { + 'secret-set': 'secret-set <name> <value> — Encrypt and store a secret', + 'secret-get': 'secret-get <name> — Decrypt and display a secret value', + 'secret-list': 'secret-list — List all stored secret names', + 'secret-del': 'secret-del <name> — Remove a stored secret', + 'secret-lock': 'secret-lock — Lock the vault (clear derived key from memory)', + 'secret-unlock': 'secret-unlock <pin> — Unlock the vault with a PIN code', + 'secret-setpin': 'secret-setpin <pin> — Set or change the vault PIN code', + 'secret-status': 'secret-status — Show vault status (locked/unlocked/pin set/open mode)', + }; + } + + registerCommands() { + this.commands['secret-set'] = (args) => this._cmdSet(args); + this.commands['secret-get'] = (args) => this._cmdGet(args); + this.commands['secret-list'] = () => this._cmdList(); + this.commands['secret-del'] = (args) => this._cmdDel(args); + this.commands['secret-lock'] = () => this._cmdLock(); + this.commands['secret-unlock'] = (args) => this._cmdUnlock(args); + this.commands['secret-setpin'] = (args) => this._cmdSetPin(args); + this.commands['secret-status'] = () => this._cmdStatus(); + } + + // ---------- Core API ---------- + + isPinSet() { + return localStorage.getItem(this._saltKey) !== null; + } + + isLocked() { + // Locked only if PIN is set but key is not derived in memory + return this.isPinSet() && this._key === null; + } + + async setPin(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + // Generate new salt + const salt = this._generateSalt(); + localStorage.setItem(this._saltKey, this._arrayToBase64(salt)); + + // Derive new key from the new PIN + const newKey = await this._deriveKeyFromPin(pin, salt); + + if (this.isPinSet() && this._key !== null) { + // Re-encrypt all existing secrets with new key + await this._reEncryptAll(newKey); + } + + // Store the new key in memory + this._key = newKey; + + // Store a verification token so we know the PIN works + await this._encryptWithKey(this._verifyName, 'verified', this._key); + + return { success: true }; + } + + lock() { + this._key = null; + return { success: true }; + } + + async unlock(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + if (!this.isPinSet()) { + return { error: 'No PIN has been set. Use secret-setpin first.' }; + } + + const saltBase64 = localStorage.getItem(this._saltKey); + if (!saltBase64) { + return { error: 'Salt not found. Vault may be corrupted.' }; + } + + const salt = this._base64ToArray(saltBase64); + const key = await this._deriveKeyFromPin(pin, salt); + + // Verify PIN by checking the verification token + const verifyData = await this._decryptWithKey(this._verifyName, key); + if (verifyData === null) { + return { error: 'Invalid PIN' }; + } + + this._key = key; + return { success: true }; + } + + async encrypt(name, value) { + if (this.isLocked()) { + return { error: 'Vault is locked' }; + } + + // Ensure key is available + let key = this._key; + if (!key) { + // Open mode: use or generate a session-like key + key = await this._getOpenModeKey(); + } + + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + return this._encryptWithKey(name, stringValue, key); + } + + async decrypt(name) { + if (this.isLocked()) { + return null; + } + + let key = this._key; + if (!key) { + key = await this._getOpenModeKey(); + if (!key) return null; + } + + return this._decryptWithKey(name, key); + } + + remove(name) { + localStorage.removeItem(this._prefix + name); + return { success: true }; + } + + list() { + const names = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this._prefix)) { + names.push(key.substring(this._prefix.length)); + } + } + return names; + } + + has(name) { + return localStorage.getItem(this._prefix + name) !== null; + } + + /** + * Scans text for any stored secret values and replaces them with '[REDACTED]'. + * Used by terminal and AI context to prevent leaking secrets in output. + */ + async redactSecret(text) { + if (!text || typeof text !== 'string') return text; + + const names = this.list(); + if (names.length === 0) return text; + + let redacted = text; + + for (const name of names) { + try { + const value = await this.decrypt(name); + if (value && typeof value === 'string' && value.length > 0) { + // Escape special regex characters in the secret value + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + try { + const regex = new RegExp(escaped, 'g'); + redacted = redacted.replace(regex, '[REDACTED]'); + } catch (e) { + // If regex fails (e.g., complex Unicode), try manual replacement + while (redacted.includes(value)) { + redacted = redacted.replace(value, '[REDACTED]'); + } + } + } + } catch (e) { + // Skip secrets that can't be decrypted + } + } + + return redacted; + } + + // ---------- Internal crypto methods ---------- + + _generateSalt() { + const salt = new Uint8Array(16); + crypto.getRandomValues(salt); + return salt; + } + + _generateIV() { + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + return iv; + } + + _arrayToBase64(array) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(array))); + } + + _base64ToArray(base64) { + const binary = atob(base64); + const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + return array; + } + + async _deriveKeyFromPin(pin, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(pin), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _getOpenModeKey() { + // In open mode (no PIN set), we use a persistent 32-char hex key in localStorage + // This is less secure than PIN mode but better than plaintext + let keyHex = localStorage.getItem(this._openModeKey); + if (!keyHex) { + keyHex = this._generateOpenKey(); + localStorage.setItem(this._openModeKey, keyHex); + } + return this._hexKeyToCryptoKey(keyHex); + } + + _generateOpenKey() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _hexKeyToCryptoKey(hex) { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return crypto.subtle.importKey( + 'raw', + bytes, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _encryptWithKey(name, value, key) { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(value); + const iv = this._generateIV(); + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + data + ); + + const encryptedArray = new Uint8Array(encrypted); + const storageData = JSON.stringify({ + iv: this._arrayToBase64(iv), + data: this._arrayToBase64(encryptedArray), + }); + + localStorage.setItem(this._prefix + name, storageData); + return { success: true }; + } catch (e) { + return { error: 'Encryption failed: ' + e.message }; + } + } + + async _decryptWithKey(name, key) { + try { + const storageData = localStorage.getItem(this._prefix + name); + if (!storageData) return null; + + const parsed = JSON.parse(storageData); + if (!parsed.iv || !parsed.data) return null; + + const iv = this._base64ToArray(parsed.iv); + const encryptedData = this._base64ToArray(parsed.data); + + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (e) { + return null; + } + } + + async _reEncryptAll(newKey) { + const names = this.list(); + const oldKey = this._key; + + for (const name of names) { + // Skip verification token — it's regenerated in setPin + if (name === this._verifyName) continue; + + const plaintext = await this._decryptWithKey(name, oldKey); + if (plaintext !== null) { + await this._encryptWithKey(name, plaintext, newKey); + } + } + + // Update verification token with new key + await this._encryptWithKey(this._verifyName, 'verified', newKey); + } + + // ---------- Command handlers ---------- + + async _cmdSet(args) { + if (args.length < 2) { + this.terminal.print('Usage: secret-set <name> <value>', 'warning'); + return; + } + const name = args[0]; + const value = args.slice(1).join(' '); + const result = await this.encrypt(name, value); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print(`Secret '${name}' saved`, 'success'); + } + } + + async _cmdGet(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-get <name>', 'warning'); + return; + } + const value = await this.decrypt(args[0]); + if (value === null) { + if (this.isLocked()) { + this.terminal.print('Vault is locked. Use secret-unlock <pin>', 'warning'); + } else { + this.terminal.print(`Secret '${args[0]}' not found`, 'error'); + } + } else { + this.terminal.print(`Secret '${args[0]}': ${value}`, 'info'); + } + } + + _cmdList() { + const names = this.list(); + if (names.length === 0) { + this.terminal.print('No secrets stored', 'info'); + return; + } + this.terminal.print('=== Stored Secrets ===', 'info'); + names.forEach(n => { + // Skip internal verification token + if (n === this._verifyName) return; + this.terminal.print(` - ${n}`); + }); + } + + _cmdDel(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-del <name>', 'warning'); + return; + } + const result = this.remove(args[0]); + if (result.success) { + this.terminal.print(`Secret '${args[0]}' deleted`, 'success'); + } + } + + _cmdLock() { + const result = this.lock(); + if (result.success) { + this.terminal.print('Vault locked', 'success'); + } + } + + async _cmdUnlock(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-unlock <pin>', 'warning'); + return; + } + const result = await this.unlock(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('Vault unlocked', 'success'); + } + } + + async _cmdSetPin(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-setpin <pin>', 'warning'); + return; + } + const result = await this.setPin(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('PIN set successfully. Vault is now in PIN-protected mode.', 'success'); + } + } + + _cmdStatus() { + const pinSet = this.isPinSet(); + const locked = this.isLocked(); + const hasKey = this._key !== null; + + this.terminal.print('=== Secret Vault Status ===', 'info'); + this.terminal.print(`PIN set: ${pinSet ? 'Yes' : 'No'}`, pinSet ? 'success' : 'info'); + if (pinSet) { + this.terminal.print(`Vault: ${locked ? '🔒 Locked' : '🔓 Unlocked'}`, locked ? 'warning' : 'success'); + } else { + this.terminal.print(`Mode: Open (no PIN — using persistent key)`, 'info'); + } + this.terminal.print(`Secrets stored: ${this.list().filter(n => n !== this._verifyName).length}`, 'info'); + } +} diff --git a/modules/core/terminal.js b/modules/core/terminal.js new file mode 100644 index 0000000..0a9538f --- /dev/null +++ b/modules/core/terminal.js @@ -0,0 +1,1362 @@ +// Extracted from newOS4 index.html — WebX11Terminal + class WebX11Terminal { + constructor() { + this.outputContainer = document.getElementById('terminal-container'); + this.inputField = document.getElementById('command-input'); + this.dropdown = document.getElementById('autocomplete-dropdown'); + this.statusBar = document.getElementById('status-text'); + this.networkStatus = document.getElementById('network-status'); + this.fs = new VirtualFileSystem(); + this.secretVault = null; + try { + if (typeof SecretVault !== 'undefined') { + this.secretVault = new SecretVault(this); + window.webx11_secretVault = this.secretVault; + } + } catch (e) { console.warn('SecretVault init:', e.message); } + this.moduleManager = new ModuleManager(this); + this.ollamaManager = new OllamaManager(this); + this._pipeInput = null; + this._pipeBufferSize = 5000; + this._historyBuffer = []; + this._historyMaxLines = 50; + this.MAX_OUTPUT_LINES = 200; + this._aiBusy = false; + this._dirty = false; + this.history = JSON.parse(localStorage.getItem('webx11_command_history') || '[]'); + this.historyIndex = this.history.length; + this.selectedSuggestion = -1; + this.suggestions = []; + this._isCdAutocomplete = false; + this._autocompletePrefix = ''; + this.initEventListeners(); + this.updateNetworkStatus(); + this.loadBuiltInModules(); + this._loadHistory(); + // Auto-restore session + this._autoRestoreSession(); + this._restoreFontSize(); + // Init auto-save timer — 60s after boot, then every 60s + setTimeout(() => this._initAutoSave(), 30000); + // Init sound toggle button text + const soundBtn = document.getElementById('sound-toggle-btn'); + if (soundBtn) { + soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '🔇 Muted' : '🔊 Sound'; + } + setTimeout(() => this.inputField.focus(), 100); + } + initEventListeners() { + this.inputField.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; + } else if (!this._isCdAutocomplete) { + this.inputField.value = this.suggestions[this.selectedSuggestion]; + } + } + const cmd = this.inputField.value; + this.history.push(cmd); + this.historyIndex = this.history.length; + this._saveCommandHistory(); + this.executeCommand(cmd); + this.inputField.value = ''; + this.inputField.style.height = 'auto'; + this.hideAutocomplete(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (this.historyIndex > 0) { + this.historyIndex--; + this.inputField.value = this.history[this.historyIndex]; + } + this.hideAutocomplete(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++; + this.inputField.value = this.history[this.historyIndex]; + } else { + this.historyIndex = this.history.length; + this.inputField.value = ''; + } + this.hideAutocomplete(); + } else if (e.key === 'Tab') { + e.preventDefault(); + if (this.suggestions.length > 0) { + this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length; + this.updateAutocompleteDisplay(); + } + } else if (e.key === 'Escape') { + if (this._aiBusy) { + this.executeCommand('ai-stop'); + } else { + this.hideAutocomplete(); + } + } + }); + this.inputField.addEventListener('input', () => { + this.inputField.style.height = 'auto'; + this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px'; + this.showAutocomplete(); + }); + this.inputField.addEventListener('focus', () => { + this.showAutocomplete(); + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('#input-area')) { + this.hideAutocomplete(); + } + }); + window.addEventListener('online', () => this.updateNetworkStatus()); + window.addEventListener('offline', () => this.updateNetworkStatus()); + document.getElementById('file-upload-input').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (evt) => { + this.fs.writeFile(file.name, evt.target.result); + this.markDirty(); + this.print(`File '${file.name}' uploaded successfully.`, 'success'); + }; + reader.readAsText(file); + } + e.target.value = ''; + }); + // Voice mic button + // Sound toggle button + const soundBtn = document.getElementById('sound-toggle-btn'); + if (soundBtn) { + soundBtn.addEventListener('click', () => { + const current = localStorage.getItem('webx11_config_ai_sound'); + if (current === 'off') { + localStorage.setItem('webx11_config_ai_sound', 'on'); + soundBtn.textContent = '🔊 Sound'; + } else { + localStorage.setItem('webx11_config_ai_sound', 'off'); + soundBtn.textContent = '🔇 Muted'; + } + }); + } + // Mic toggle button + const micToggleBtn = document.getElementById('voice-toggle-btn'); + if (micToggleBtn) { + micToggleBtn.addEventListener('click', () => this.toggleMicVisibility()); + } + } + updateNetworkStatus() { + const isOnline = navigator.onLine; + this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline'; + this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)'; + } + updatePrompt() { + const path = '/' + this.fs.currentPath.join('/'); + const label = document.querySelector('.prompt-label'); + if (!label) return; + if (localStorage.getItem('webx11_smart_prompt') === 'off') { + label.textContent = path === '/' ? '$' : path + ' $'; + return; + } + const indicators = []; + try { + const p = localStorage.getItem('webx11_user_profile'); + if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); } + } catch (e) {} + try { + const idx = this.fs.fsReadJSON('/skills/index.json'); + if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); } + } catch (e) {} + try { + const memIdx = this.fs.fsReadJSON('/memory/index.json'); + if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); } + } catch (e) {} + try { + if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { + indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB'); + } + } catch (e) {} + try { + const md = this.fs.readDir('_mods'); + if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); } + } catch (e) {} + const prefix = indicators.length ? indicators.join(' ') + ' ' : ''; + label.textContent = prefix + (path === '/' ? '$' : path + ' $'); + } + print(text, type = '') { + const line = document.createElement('div'); + line.className = `output-line ${type}`; + line.textContent = text; + this.outputContainer.appendChild(line); + this._historyBuffer.push({ l: text, t: type }); + if (this._historyBuffer.length > this._historyMaxLines) { + this._historyBuffer.shift(); + } + this._saveHistory(); + this.scrollToBottom(); + this._trimOutput(); + } + printAIResponse(text) { + const wrapper = document.createElement('div'); + wrapper.className = 'output-line ollama'; + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + wrapper.innerHTML = ` + <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button> + <div class="ai-content">${safeText}</div> + `; + this.outputContainer.appendChild(wrapper); + this.scrollToBottom(); + this._trimOutput(); + } + printStreamingResponse(text, append) { + if (!append) { + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + const wrapper = document.createElement('div'); + wrapper.className = 'output-line ollama'; + wrapper.innerHTML = ` + <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button> + <div class="ai-content">${safeText}</div> + `; + this.outputContainer.appendChild(wrapper); + } else { + const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content'); + if (contentDiv) { + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + contentDiv.textContent += safeText; + } + } + this.scrollToBottom(); + this._trimOutput(); + } + copyToClipboard(btn) { + const contentDiv = btn.nextElementSibling; + const text = contentDiv.innerText; + navigator.clipboard.writeText(text).then(() => { + const originalText = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = originalText; }, 2000); + }).catch(err => { console.error('Failed to copy: ', err); }); + } + printClickable(items) { + const line = document.createElement('div'); + line.className = 'output-line'; + items.forEach(f => { + const span = document.createElement('span'); + const safeName = f.name.replace(/</g, '<').replace(/>/g, '>'); + if (f.type === 'dir') { + span.className = 'clickable-dir'; + span.textContent = '[dir]' + f.name + '/'; + span.onclick = () => { + this.executeCommand('cd ' + f.name); + setTimeout(() => this.executeCommand('ls'), 50); + }; + } else { + span.className = 'clickable-file'; + span.textContent = ' ' + f.name; + span.onclick = () => this.executeCommand('cat ' + f.name); + } + line.appendChild(span); + }); + this.outputContainer.appendChild(line); + this.scrollToBottom(); + } + printHTML(html) { + const line = document.createElement('div'); + line.className = 'output-line'; + line.innerHTML = html; + this.outputContainer.appendChild(line); + this.scrollToBottom(); + } + _loadHistory() { + try { + const saved = localStorage.getItem('webx11_terminal_output'); + if (!saved) return; + const entries = JSON.parse(saved); + if (!Array.isArray(entries)) return; + this._historyBuffer = entries.slice(-this._historyMaxLines); + // Check localStorage quota warning + let quotaWarned = false; + try { + const testKey = '_quota_test_' + Date.now(); + localStorage.setItem(testKey, 'x'.repeat(1024)); + localStorage.removeItem(testKey); + } catch (e) { + quotaWarned = true; + } + // Lazy-load history: render first 20 immediately, batch the rest + const batchSize = 20; + const toRender = entries.slice(-this._historyMaxLines); + const initial = toRender.slice(0, batchSize); + for (const entry of initial) { + const line = document.createElement('div'); + line.className = `output-line ${entry.t || ''}`; + line.textContent = entry.l || ''; + this.outputContainer.appendChild(line); + } + if (toRender.length > batchSize) { + const remaining = toRender.slice(batchSize); + const doBatch = (index) => { + if (index >= remaining.length) { + this.scrollToBottom(); + return; + } + requestAnimationFrame(() => { + const end = Math.min(index + batchSize, remaining.length); + for (let i = index; i < end; i++) { + const line = document.createElement('div'); + line.className = `output-line ${remaining[i].t || ''}`; + line.textContent = remaining[i].l || ''; + this.outputContainer.appendChild(line); + } + doBatch(end); + }); + }; + doBatch(0); + } else { + this.scrollToBottom(); + } + if (quotaWarned) { + const warnLine = document.createElement('div'); + warnLine.className = 'output-line warning'; + warnLine.textContent = '⚠ localStorage is near quota — history may be incomplete'; + this.outputContainer.appendChild(warnLine); + } + } catch (e) { + // Silently ignore corrupt history data + } + } + _saveHistory() { + try { + const slice = this._historyBuffer.slice(-30); + localStorage.setItem('webx11_terminal_output', JSON.stringify(slice)); + } catch (e) { + // Silently ignore storage errors (e.g. quota exceeded) + } + } + _saveCommandHistory() { + try { + localStorage.setItem('webx11_command_history', JSON.stringify(this.history)); + } catch (e) { + // Silently ignore storage errors + } + } + _trimOutput() { + const container = this.outputContainer; + while (container.children.length > this.MAX_OUTPUT_LINES) { + container.removeChild(container.firstChild); + } + } + markDirty() { + this._dirty = true; + } + _initAutoSave() { + if (this._autoSaveTimer) return; + this._autoSaveTimer = setInterval(() => { + // Auto-trim on memory pressure + try { + if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { + const oldMax = this.MAX_OUTPUT_LINES; + this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100; + this._trimOutput(); + this.MAX_OUTPUT_LINES = oldMax; + this.print('🧹 Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted'); + } + } catch (e) {} + if (!this._dirty) return; + this._autoSaveSession(); + this._dirty = false; + }, 180000); + } + _autoSaveSession() { + try { + this.fs.mkdirRecursive('_sessions'); + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: this.fs.root, + currentPath: this.fs.currentPath, + version: 1, + terminalLines: this._historyBuffer.slice(-50) + }; + this.fs.writeFile('_sessions/_auto.json', JSON.stringify(snapshot, null, 2)); + // Also persist AI context concurrently + try { + const ctx = localStorage.getItem('webx11_ai_context'); + if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx); + } catch (e) {} + } catch (e) { + // Silently handle auto-save failures + } + } + _autoRestoreSession() { + try { + this.fs.mkdirRecursive('_sessions'); + const autoSession = this.fs.readFile('_sessions/_auto.json'); + if (autoSession) { + const data = JSON.parse(autoSession); + if (data && data.fsRoot) { + this.fs.root = data.fsRoot; + this.fs.currentPath = data.currentPath || []; + this.fs.saveToStorage(); + this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success'); + // Defer AI context restoration (AI module loads after terminal init) + try { + const aiCtx = this.fs.readFile('_sessions/_auto-context.json'); + if (aiCtx) { + localStorage.setItem('webx11_ai_context', aiCtx); + } + } catch (e) {} + } + } + } catch (e) { + // Silent on first boot — no auto-session yet + } + } + scrollToBottom() { + this.outputContainer.scrollTop = this.outputContainer.scrollHeight; + } + showAutocomplete() { + const input = this.inputField.value; + const trimmed = input.trim(); + const commands = this.moduleManager.getAllCommands(); + this.suggestions = []; + this.selectedSuggestion = -1; + this._isCdAutocomplete = false; + + if (trimmed.length === 0) { + this.hideAutocomplete(); + return; + } + + const parts = input.split(' '); + const cmdPart = parts[0].toLowerCase(); + const lowerInput = input.toLowerCase(); + + // Commands that take file/dir arguments for autocomplete + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view']; + const dirArgCommands = ['ls', 'cd']; + // Session commands that autocomplete from _sessions/*.json + const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save']; + + // If we have more than one word, we're past the command name — autocomplete file/dir/session names + if (parts.length > 1 && parts[1].length > 0) { + const partial = parts.slice(1).join(' '); + const partialTrimmed = partial.trim(); + + if (partialTrimmed === '..') { + this.hideAutocomplete(); + return; + } + + // Determine if we should show session names, dirs only, or both + if (sessionArgCommands.includes(cmdPart)) { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + this.suggestions = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')) + .filter(n => n.startsWith(partialTrimmed)); + } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { + // Dir-only commands + const entries = this.fs.ls().filter(f => f.type === 'dir'); + this.suggestions = entries + .filter(e => e.name.startsWith(partialTrimmed)) + .map(e => e.name + '/'); + } else if (fileArgCommands.includes(cmdPart)) { + const all = this.fs.ls(); + this.suggestions = all + .filter(e => e.name.startsWith(partialTrimmed)) + .map(e => e.type === 'dir' ? e.name + '/' : e.name); + } else { + this.hideAutocomplete(); + return; + } + + this._autocompletePrefix = cmdPart + ' '; + if (this.suggestions.length > 0) { + this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Just typed a space after command — show all matching entries + if (parts.length > 1) { + if (sessionArgCommands.includes(cmdPart)) { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + this.suggestions = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')); + } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { + const dirs = this.fs.ls().filter(f => f.type === 'dir'); + this.suggestions = dirs.map(d => d.name + '/'); + } else if (fileArgCommands.includes(cmdPart)) { + const all = this.fs.ls(); + this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name); + } else { + this.hideAutocomplete(); + return; + } + this._autocompletePrefix = cmdPart + ' '; + if (this.suggestions.length > 0) { + this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block + if (lowerInput.startsWith('cd ')) { + const dirs = this.fs.ls().filter(f => f.type === 'dir'); + this.suggestions = dirs.map(d => d.name + '/'); + if (this.suggestions.length > 0) { + this._isCdAutocomplete = true; + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Command name autocomplete (first word only) — also clears any stale prefix + this._autocompletePrefix = ''; + for (const cmd in commands) { + if (cmd.startsWith(cmdPart) && cmd !== cmdPart) { + this.suggestions.push(cmd); + } + } + + if (this.suggestions.length > 0) { + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + } + renderAutocomplete() { + this.dropdown.innerHTML = ''; + this.suggestions.forEach((item, index) => { + const div = document.createElement('div'); + div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`; + div.innerHTML = this._isCdAutocomplete + ? `<span class="cmd-name" style="color:var(--accent-color)">${item}</span>` + : `<span class="cmd-name">${item}</span>`; + div.onclick = () => { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + item; + } else if (this._isCdAutocomplete) { + this.inputField.value = 'cd ' + item; + } else { + this.inputField.value = item + ' '; + } + this.hideAutocomplete(); + this.inputField.focus(); + }; + this.dropdown.appendChild(div); + }); + this.dropdown.style.display = 'block'; + } + updateAutocompleteDisplay() { + const items = this.dropdown.querySelectorAll('.autocomplete-item'); + items.forEach((item, index) => { + item.classList.toggle('selected', index === this.selectedSuggestion); + }); + if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; + } else if (this._isCdAutocomplete) { + this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion]; + } else { + this.inputField.value = this.suggestions[this.selectedSuggestion] + ' '; + } + } + } + hideAutocomplete() { + this.dropdown.style.display = 'none'; + this.suggestions = []; + this.selectedSuggestion = -1; + this._isCdAutocomplete = false; + this._autocompletePrefix = ''; + } + loadBuiltInModules() { + this._safeRegister('SystemModule', 'system'); + this._safeRegister('FileSystemModule', 'filesystem'); + this._safeRegister('ModuleManagerModule', 'module manager'); + this._safeRegister('OllamaModule', 'ollama'); + this._safeRegister('SearXNGModule', 'searxng search'); + this._safeRegister('ConfigManagerModule', 'config'); + this._safeRegister('WebX11AIAssistantModule', 'AI assistant'); + this._safeRegister('WebFetcherModule', 'web fetcher'); + this._safeRegister('WebXAIAliasModule', 'AI alias'); + this._safeRegister('SkillsModule', 'skills'); + this._safeRegister('SecretVault', 'secret vault'); + this.reloadPersistedModules(); + this._safeRegister('ChatStudioModule', 'chat studio'); + } + _safeRegister(className, label) { + let cls; + try { + cls = eval(className); + } catch (e) { + console.log(`[SafeRegister] Skipped ${label}: class not loaded (skipped in boot selection)`); + return; + } + if (typeof cls === 'undefined') { + console.log(`[SafeRegister] Skipped ${label}: class not defined`); + return; + } + try { this.moduleManager.registerModule(new cls()); } + 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']; + try { + const res = await fetch('packages.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const index = await res.json(); + for (const key of autoLoadKeys) { + const pkg = index.packages[key]; + if (pkg) { + await this.moduleManager.loadModuleFromURL(pkg.url); + } + } + } catch (err) { + this.print(`Failed to auto-load ext modules: ${err.message}`, 'error'); + } + } + async reloadPersistedModules() { + const registry = this.moduleManager.moduleRegistry; + const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + for (const mod of registry) { + if (mod.enabled && mod.url) { + // Skip ext modules — they load via _loadExtModules + const name = (mod.url.match(/(\w+)\.js/) || [])[1]; + if (name && skipList.includes(name)) continue; + await this.moduleManager.loadModuleFromURL(mod.url); + } + } + } + async executeCommand(rawInput) { + if (!rawInput.trim()) return; + this.markDirty(); + this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt'); + const pipeParts = this.splitPipe(rawInput.trim()); + if (pipeParts.length > 1) { + await this.executePipeChain(pipeParts); + return; + } + const parts = rawInput.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + if (cmd === 'startup-modules') { + this.openStartupModules(); + return; + } + if (cmd === 'font-size' || cmd === 'setfont') { + this.setFontSize(args[0]); + return; + } + if (cmd === 'load-local' || cmd === 'loadlocal') { + this.loadLocalModule(args[0]); + return; + } + if (cmd === 'smart-prompt') { + const val = args[0] && args[0] === 'off' ? 'off' : 'on'; + localStorage.setItem('webx11_smart_prompt', val); + this.print('Smart prompt ' + val, 'success'); + this.updatePrompt(); + return; + } + if (cmd === 'mem') { + this.printMemoryStats(); + return; + } + if (cmd === 'clear') { + this.outputContainer.innerHTML = ''; + this._historyBuffer = []; + localStorage.removeItem('webx11_terminal_output'); + return; + } + let executed = false; + 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); + executed = true; + } catch (err) { + this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); + } + break; + } + } + if (!executed) { + // Route unknown commands to the AI agent + try { + const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.print('→', 'info'); + await aiMod.chat([rawInput.trim()]); + return; + } + } catch (e) { + this.print(`Agent error: ${e.message}`, 'error'); + return; + } + } + } + splitPipe(input) { + const parts = []; + let current = ''; + let inQuote = false; + for (const ch of input) { + if (ch === '"' || ch === "'") { inQuote = !inQuote; current += ch; } + else if (ch === '|' && !inQuote) { parts.push(current.trim()); current = ''; } + else { current += ch; } + } + parts.push(current.trim()); + return parts; + } + async executePipeChain(pipeParts) { + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const cmdText = pipeParts[i]; + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + this._pipeInput = pipeInput; + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + } + this._pipeInput = null; + } + async capturePipeOutput(cmd, args) { + const originalPrint = this.print.bind(this); + let output = ''; + this.print = (text, type) => { output += text + '\n'; }; + await this.dispatchCommand(cmd, args); + this.print = originalPrint; + const trimmed = output.trim(); + if (trimmed.length > this._pipeBufferSize) { + return trimmed.slice(0, this._pipeBufferSize); + } + return trimmed; + } + async dispatchCommand(cmd, args) { + if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } + 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; } + catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } + } + } + // Route unknown commands to the AI agent + try { + const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([cmd + ' ' + args.join(' ')]); + return; + } + } catch (e) { + this.print(`Agent error: ${e.message}`, 'error'); + } + this.print(`Command not found: ${cmd}`, 'error'); + } + openFileBrowser() { + const modal = document.getElementById('file-browser'); + const content = document.getElementById('fb-content'); + content.innerHTML = ''; + const files = this.fs.ls(); + if (files.length === 0) { + content.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Directory Empty</div>'; + } else { + files.forEach(f => { + const div = document.createElement('div'); + div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`; + div.innerHTML = `<span>${f.type === 'dir' ? '📁' : '📄'} ${f.name}</span> <span style="font-size:0.8em">${f.size}b</span>`; + div.onclick = () => { + if (f.type === 'file') { + const content = this.fs.readFile(f.name); + this.print(`--- ${f.name} ---`); + this.print(content); + this.print('--- End ---'); + this.closeFileBrowser(); + } + }; + content.appendChild(div); + }); + } + modal.style.display = 'flex'; + } + closeFileBrowser() { + document.getElementById('file-browser').style.display = 'none'; + this.inputField.focus(); + } + openModuleManager() { + const modal = document.getElementById('module-manager'); + this.renderModuleList(); + modal.style.display = 'flex'; + } + closeModuleManager() { + document.getElementById('module-manager').style.display = 'none'; + this.inputField.focus(); + } + renderModuleList() { + const list = document.getElementById('module-list'); + list.innerHTML = ''; + const modules = this.moduleManager.getModuleList(); + if (modules.length === 0) { + list.innerHTML = '<div class="text-center" style="padding:20px; color:#666">No modules loaded</div>'; + return; + } + modules.forEach(mod => { + const div = document.createElement('div'); + div.className = `module-item ${mod.loaded ? '' : 'disabled'}`; + const badge = mod.url ? '' : '<span class="module-badge builtin">Built-in</span>'; + div.innerHTML = ` + <div> + <strong>${mod.name}</strong>${badge} + <div style="font-size:0.8em; color:#666">${mod.url || 'Built-in'}</div> + </div> + ${mod.url ? `<button class="btn btn-danger" onclick="webx11.removeModule('${mod.name}')" style="padding:4px 8px;">✕</button>` : ''} + `; + list.appendChild(div); + }); + } + async loadModuleFromURL() { + const url = document.getElementById('module-url-input').value.trim(); + if (!url) { alert('Please enter a URL'); return; } + const success = await this.moduleManager.loadModuleFromURL(url); + if (success) { + document.getElementById('module-url-input').value = ''; + this.renderModuleList(); + } + } + removeModule(name) { + if (this.moduleManager.unregisterModule(name)) { + this.print(`Module '${name}' removed`, 'success'); + this.renderModuleList(); + } + } + clearAllModules() { + if (confirm('Remove all custom modules? Built-in modules will remain.')) { + this.moduleManager.clearAll(); + this.renderModuleList(); + this.print('All custom modules cleared', 'success'); + } + } + openOllamaManager() { + const modal = document.getElementById('ollama-manager'); + document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint; + this.updateOllamaStatus(); + this.renderOllamaModels(); + modal.style.display = 'flex'; + } + closeOllamaManager() { + document.getElementById('ollama-manager').style.display = 'none'; + this.inputField.focus(); + } + openPreview(args) { + let path = ''; + if (typeof args === 'string') path = args.trim(); + else if (Array.isArray(args)) path = args.join(' ').trim(); + if (!path) { + this.print('Usage: preview <file.html>', 'warning'); + return; + } + // Remove leading slash for VFS paths + const vfsPath = path.replace(/^\/*/, ''); + let content = this.fs.readFile(vfsPath); + if (content === null) content = this.fs.fsRead('/' + vfsPath); + if (content === null) { + this.print(`File not found: ${path}`, 'error'); + return; + } + // Use srcdoc instead of blob URL — works reliably on mobile browsers + const iframe = document.getElementById('preview-iframe'); + iframe.srcdoc = content; + document.getElementById('preview-title').textContent = `📄 ${path}`; + document.getElementById('preview-modal').style.display = 'flex'; + } + closePreview() { + document.getElementById('preview-modal').style.display = 'none'; + const iframe = document.getElementById('preview-iframe'); + iframe.srcdoc = ''; + iframe.src = 'about:blank'; + this.inputField.focus(); + } + openSkillsManager() { + const modal = document.getElementById('skills-manager'); + // Ensure /skills/ directory exists + const skillsMod = this.moduleManager.modules['SkillsModule']; + if (skillsMod && skillsMod.ensureSkillsDir) skillsMod.ensureSkillsDir(); + this.refreshSkillsList(); + modal.style.display = 'flex'; + } + closeSkillsManager() { + document.getElementById('skills-manager').style.display = 'none'; + this.inputField.focus(); + } + openConfigManager() { + document.getElementById('config-manager').style.display = 'flex'; + this.renderConfigList(); + } + closeConfigManager() { + document.getElementById('config-manager').style.display = 'none'; + this.inputField.focus(); + } + renderConfigList() { + const list = document.getElementById('config-list'); + if (!list) return; + const prefix = 'webx11_config_'; + const entries = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) }); + } + } + if (entries.length === 0) { + list.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No configuration saved.</div>'; + return; + } + list.innerHTML = entries.map(e => { + const displayVal = e.key.toLowerCase().includes('password') || e.key.toLowerCase().includes('token') || e.key.toLowerCase().includes('secret') + ? '****' : e.value; + return `<div class="module-item"> + <div><strong>${e.key}</strong> <span style="color:var(--text-muted)">${displayVal}</span></div> + <button class="btn btn-danger" onclick="webx11.deleteConfigFromUI('${e.key.replace(/'/g, "\\'")}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div>`; + }).join(''); + } + addConfigFromUI() { + const key = document.getElementById('config-key-input').value.trim(); + const value = document.getElementById('config-value-input').value.trim(); + if (!key || !value) { + this.print('Enter both key and value', 'warning'); + return; + } + localStorage.setItem('webx11_config_' + key, value); + document.getElementById('config-key-input').value = ''; + document.getElementById('config-value-input').value = ''; + this.print(`Config saved: ${key} = ${value}`, 'success'); + this.renderConfigList(); + } + deleteConfigFromUI(key) { + localStorage.removeItem('webx11_config_' + key); + this.print(`Config '${key}' deleted`, 'success'); + this.renderConfigList(); + } + exportConfigFromUI() { + const mod = this.moduleManager.modules['ConfigManagerModule']; + if (mod && mod.commands && mod.commands['config-export']) { + mod.commands['config-export'](); + } + } + resetConfigFromUI() { + const mod = this.moduleManager.modules['ConfigManagerModule']; + if (mod && mod.commands && mod.commands['config-reset']) { + mod.commands['config-reset'](); + this.renderConfigList(); + } + } + refreshSkillsList() { + const list = document.getElementById('skills-list'); + list.innerHTML = ''; + const fs = this.fs; + fs.mkdirRecursive('/skills'); + const index = fs.fsReadJSON('/skills/index.json') || {}; + const names = Object.keys(index); + if (names.length === 0) { + list.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No skills yet. Create one below!</div>'; + return; + } + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + const div = document.createElement('div'); + div.className = 'module-item'; + div.innerHTML = ` + <div> + <strong style="color:var(--success-color)">${name}</strong> + <div style="font-size:0.85em; color:#aaa">${s.description || ''}</div> + <div style="font-size:0.75em; color:#666">${s.category || 'general'}${tags ? ' · ' + tags : ''}</div> + </div> + <div style="display:flex;gap:4px"> + <button class="btn" onclick="webx11.viewSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">👁</button> + <button class="btn btn-danger" onclick="webx11.deleteSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div> + `; + list.appendChild(div); + }); + } + viewSkillUI(name) { + const fs = this.fs; + const content = fs.fsRead(`/skills/${name}.md`); + if (!content) { this.print(`Skill '${name}' not found`, 'error'); return; } + document.getElementById('skill-preview-title').textContent = name; + document.getElementById('skill-preview-content').textContent = content; + document.getElementById('skill-preview').style.display = 'block'; + } + closeSkillPreview() { + document.getElementById('skill-preview').style.display = 'none'; + } + deleteSkillUI(name) { + if (!confirm(`Delete skill "${name}"?`)) return; + const fs = this.fs; + fs.mkdirRecursive('/skills'); + fs.fsWrite(`/skills/${name}.md`, ''); + const index = fs.fsReadJSON('/skills/index.json') || {}; + delete index[name]; + fs.fsWriteJSON('/skills/index.json', index); + this.print(`Skill '${name}' deleted`, 'success'); + this.refreshSkillsList(); + } + createSkillFromUI() { + const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase(); + const description = document.getElementById('skill-desc-input').value.trim() || 'No description'; + const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean); + const contentInput = document.getElementById('skill-content-input').value.trim(); + if (!name) { alert('Skill name is required'); return; } + const fs = this.fs; + fs.mkdirRecursive('/skills'); + if (fs.fsRead(`/skills/${name}.md`)) { + alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`); + return; + } + let content = contentInput || `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n## Steps\n1. (to be filled)\n`; + if (!content.startsWith('---')) { + content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n${content}`; + } + fs.fsWrite(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, category: 'general', tags }; + fs.fsWriteJSON('/skills/index.json', index); + this.print(`Skill '${name}' created`, 'success'); + document.getElementById('skill-name-input').value = ''; + document.getElementById('skill-desc-input').value = ''; + document.getElementById('skill-tags-input').value = ''; + document.getElementById('skill-content-input').value = ''; + this.refreshSkillsList(); + } + updateOllamaStatus() { + const statusDiv = document.getElementById('ollama-status'); + const statusText = document.getElementById('ollama-status-text'); + if (this.ollamaManager.connected) { + statusDiv.className = 'ollama-status connected'; + statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`; + } else { + statusDiv.className = 'ollama-status disconnected'; + statusText.textContent = 'Disconnected'; + } + } + renderOllamaModels() { + const container = document.getElementById('ollama-models'); + const select = document.getElementById('ollama-current-model'); + container.innerHTML = ''; + select.innerHTML = '<option value="">Select a model</option>'; + const models = this.ollamaManager.getModels(); + if (models.length === 0) { + container.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Connect to see models</div>'; + return; + } + models.forEach(model => { + const div = document.createElement('div'); + div.className = 'model-item'; + div.innerHTML = `<span>${model.name}</span><span style="font-size:0.8em; color:#666">${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}</span>`; + div.onclick = () => { + this.ollamaManager.setModel(model.name); + select.value = model.name; + }; + container.appendChild(div); + const option = document.createElement('option'); + option.value = model.name; + option.textContent = model.name; + if (model.name === this.ollamaManager.currentModel) { option.selected = true; } + select.appendChild(option); + }); + } + async connectOllama() { + const endpoint = document.getElementById('ollama-endpoint').value.trim(); + this.ollamaManager.setEndpoint(endpoint); + const success = await this.ollamaManager.connect(); + this.updateOllamaStatus(); + if (success) { this.renderOllamaModels(); } + } + setOllamaModel() { + const select = document.getElementById('ollama-current-model'); + this.ollamaManager.setModel(select.value); + } + openStartupModules() { + document.getElementById('startup-modules').style.display = 'flex'; + this.renderStartupModules(); + } + closeStartupModules() { + document.getElementById('startup-modules').style.display = 'none'; + } + renderStartupModules() { + const body = document.getElementById('startup-modules-body'); + const selected = (() => { + try { + const s = localStorage.getItem('webx11_startup_modules'); + return s ? JSON.parse(s) : null; + } catch (e) { return null; } + })(); + const isSel = (name) => !selected || selected.includes(name); + body.innerHTML = ''; + + const foundation = ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js']; + const builtin = [ + 'module-system.js','module-fs.js','module-modmgr.js','module-ollama.js', + 'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js', + 'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js', + 'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js', + 'module-fetcher.js','module-skills.js','module-chatstudio.js' + ]; + const extFiles = [ + 'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','skill-pack-core.js' + ]; + + const addSection = (title) => { + const h = document.createElement('div'); + h.className = 'mod-section-header'; + h.textContent = title; + body.appendChild(h); + }; + const addCard = (name, badge, isDisabled) => { + const card = document.createElement('div'); + card.className = 'mod-card' + (isDisabled ? ' disabled' : ''); + const label = document.createElement('label'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = isDisabled || isSel(name); + cb.disabled = isDisabled; + cb.dataset.name = name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'mod-name'; + nameSpan.textContent = name; + const badgeSpan = document.createElement('span'); + badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : ''); + badgeSpan.textContent = badge; + label.appendChild(cb); + label.appendChild(nameSpan); + card.appendChild(label); + card.appendChild(badgeSpan); + body.appendChild(card); + }; + + addSection('Foundation (always loaded)'); + foundation.forEach(n => addCard(n, 'foundation', true)); + + addSection('Built-in modules'); + builtin.forEach(n => addCard(n, 'built-in', false)); + + addSection('Extensions'); + extFiles.forEach(n => addCard(n, 'ext', false)); + } + saveStartupModules() { + const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)'); + const selected = []; + cbs.forEach(cb => { + if (cb.checked) selected.push(cb.dataset.name); + }); + try { + localStorage.setItem('webx11_startup_modules', JSON.stringify(selected)); + } catch (e) { + this.print('Failed to save startup modules', 'error'); + return; + } + this.print('Startup modules saved, reloading...', 'success'); + setTimeout(() => location.reload(), 500); + } + openCodeEditor(name, url, code) { + const modal = document.getElementById('code-editor'); + const title = document.getElementById('code-editor-title'); + const info = document.getElementById('code-editor-info'); + const ta = document.getElementById('code-editor-textarea'); + if (!modal || !ta) return; + this._editingModule = { name: name, url: url }; + title.textContent = '📝 Edit: ' + name; + info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : ''); + ta.value = code; + modal.style.display = 'flex'; + } + closeCodeEditor() { + document.getElementById('code-editor').style.display = 'none'; + this._editingModule = null; + } + async loadLocalModule(path) { + if (!path) { + this.print('Usage: load-local <path>', 'warning'); + this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info'); + return; + } + let code = this.fs.readFile(path); + if (code === null) { + try { + const resp = await fetch(path); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + code = await resp.text(); + } catch (e) { + this.print('load-local: file not found in VFS or at URL: ' + path, 'error'); + return; + } + } + try { + const el = document.createElement('script'); + el.textContent = code; + el.onerror = () => this.print('load-local: failed to execute ' + path, 'error'); + document.body.appendChild(el); + this.print('Module loaded from ' + path, 'success'); + } catch (e) { + this.print('load-local: error: ' + e.message, 'error'); + } + } + async saveCodeEditor() { + const ta = document.getElementById('code-editor-textarea'); + const mod = this._editingModule; + if (!mod || !ta) return; + const code = ta.value; + this.fs.writeFile('_mods/' + mod.name + '.js', code); + if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) { + this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder'); + } + this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({ + originalUrl: mod.url || '', + modifiedAt: new Date().toISOString(), editCount: 1 + })); + try { + await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js'); + this.print('✅ Module loaded live from _mods/' + mod.name + '.js', 'success'); + } catch (e) { + this.print('⚠️ Auto-test failed: ' + e.message + ' — edits saved but module not loaded', 'error'); + } + this.closeCodeEditor(); + } + _restoreFontSize() { + try { + const saved = localStorage.getItem('webx11_font_size'); + if (saved) { + const s = parseInt(saved); + if (s >= 8 && s <= 32) document.documentElement.style.fontSize = s + 'px'; + } + } catch (e) {} + } + log(level, source, message) { + const d = new Date(); + const ts = d.toISOString().split('T')[1].split('.')[0]; + const entry = `[${ts}][${level}][${source}] ${message}`; + console.log(entry); + // Write to VFS log + try { + this.fs.mkdirRecursive('/logs'); + let current = this.fs.readFile('/logs/current.log') || ''; + current += entry + '\n'; + const lines = current.split('\n').filter(l => l); + if (lines.length > 200) { + this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n')); + this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n')); + } else { + this.fs.writeFile('/logs/current.log', current); + } + } catch (e) {} + } + setAiBusy(busy) { + this._aiBusy = !!busy; + if (this._aiBusyInterval) { + clearInterval(this._aiBusyInterval); + this._aiBusyInterval = null; + } + const cancelBtn = document.getElementById('ai-cancel-btn'); + if (busy) { + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + this._aiBusyStart = Date.now(); + const updateTimer = () => { + let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000); + let sec = elapsed % 60; + let min = Math.floor(elapsed / 60); + this.statusBar.textContent = '🤖 AI Thinking... ⏱ ' + (min > 0 ? min + 'm ' : '') + sec + 's'; + this.statusBar.className = 'loading'; + }; + updateTimer(); + this._aiBusyInterval = setInterval(updateTimer, 1000); + } else { + if (cancelBtn) cancelBtn.style.display = 'none'; + this.statusBar.textContent = 'Ready'; + this.statusBar.className = ''; + this._aiBusyStart = 0; + } + } + printMemoryStats() { + const pm = typeof performance !== 'undefined' && performance.memory; + let heapInfo = ''; + if (pm) { + const used = (pm.usedJSHeapSize / 1048576).toFixed(1); + const total = (pm.totalJSHeapSize / 1048576).toFixed(1); + const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1); + heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`; + } else { + try { + const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1); + heapInfo = `localStorage: ${lsSize}KB`; + } catch (e) { + heapInfo = 'Memory API not available on this browser'; + } + } + const output = [ + `📊 Memory Stats`, + ` ${heapInfo}`, + ` Terminal lines in DOM: ${this.outputContainer.children.length}`, + ` History buffer: ${this._historyBuffer.length} entries`, + ` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`, + ` Commands in history: ${this.history.length}` + ].join('\n'); + this.print(output, 'info'); + } + playNotificationSound() { + if (localStorage.getItem('webx11_config_ai_sound') === 'off') return; + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 880; + osc.type = 'sine'; + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.3); + } catch (e) {} + } + toggleMicVisibility() { + const voiceMod = this.moduleManager.modules['VoiceModule']; + if (!voiceMod) { + this.print('Voice module not loaded', 'warning'); + return; + } + if (voiceMod.listening) { + voiceMod.stopListening(); + } else { + voiceMod.handle([]); + } + } + setFontSize(size) { + const s = parseInt(size); + if (isNaN(s) || s < 8 || s > 32) { + this.print('Usage: font-size <8-32>', 'warning'); + return; + } + document.documentElement.style.fontSize = s + 'px'; + try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {} + this.print('Font size set to ' + s + 'px', 'success'); + } + } diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js new file mode 100644 index 0000000..a505699 --- /dev/null +++ b/modules/core/virtual-fs.js @@ -0,0 +1,320 @@ +class VirtualFileSystem { + constructor() { + this.root = { type: 'dir', content: {} }; + this.currentPath = []; + this.loadFromStorage(); + } + loadFromStorage() { + try { + const saved = localStorage.getItem('webx11_filesystem'); + if (saved) { + const data = JSON.parse(saved); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + } + } catch (e) { console.error('FS load error:', e); } + } + saveToStorage() { + try { + localStorage.setItem('webx11_filesystem', JSON.stringify({ + root: this.root, currentPath: this.currentPath + })); + } catch (e) { console.error('FS save error:', e); } + } + getCurrentDir() { + let dir = this.root; + for (let part of this.currentPath) { + if (dir.content[part] && dir.content[part].type === 'dir') { + dir = dir.content[part]; + } else { return null; } + } + return dir; + } + _toAbsoluteSegments(path) { + if (!path || path === '.' || path === '') return [...this.currentPath]; + if (path === '/') return []; + const parts = path.split('/').filter(p => p && p !== '.'); + let base = path.startsWith('/') ? [] : [...this.currentPath]; + const result = [...base]; + for (const p of parts) { + if (p === '..') { + if (result.length > 0) result.pop(); + else return null; + } else { + result.push(p); + } + } + return result; + } + _resolvePath(path) { + const segs = this._toAbsoluteSegments(path); + if (segs === null) return null; + if (segs.length === 0) return { dir: this.root, name: null }; + let dir = this.root; + for (let i = 0; i < segs.length - 1; i++) { + if (!dir.content[segs[i]] || dir.content[segs[i]].type !== 'dir') return null; + dir = dir.content[segs[i]]; + } + return { dir, name: segs[segs.length - 1] }; + } + _resolveDir(path) { + const segs = this._toAbsoluteSegments(path); + if (segs === null) return null; + if (segs.length === 0) return this.root; + let dir = this.root; + for (const seg of segs) { + if (!dir.content[seg] || dir.content[seg].type !== 'dir') return null; + dir = dir.content[seg]; + } + return dir; + } + ls(path) { + let dir; + if (path) { + const resolved = this._resolvePath(path); + if (!resolved) return []; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return []; + } else { + dir = resolved.dir; + } + } else { + dir = this.getCurrentDir(); + } + if (!dir) return []; + return Object.keys(dir.content).map(name => ({ + name, type: dir.content[name].type, + size: dir.content[name].content ? dir.content[name].content.length : 0 + })); + } + writeFile(path, content) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const name = resolved.name; + if (name === '.' || name === '..') return false; + if (resolved.dir.content[name] && resolved.dir.content[name].type === 'dir') return false; + resolved.dir.content[name] = { type: 'file', content: content, modified: new Date() }; + this.saveToStorage(); + return true; + } + readFile(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return null; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'file') return node.content; + return null; + } + deleteFile(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'file') { + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } + return false; + } + mkdir(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const name = resolved.name; + if (name === '.' || name === '..') return false; + if (resolved.dir.content[name]) return false; + resolved.dir.content[name] = { type: 'dir', content: {} }; + this.saveToStorage(); + return true; + } + mkdirRecursive(path) { + const segs = this._toAbsoluteSegments(path); + if (!segs || segs.length === 0) return true; + let dir = this.root; + for (const seg of segs) { + if (!dir.content[seg]) { + dir.content[seg] = { type: 'dir', content: {} }; + } else if (dir.content[seg].type !== 'dir') { + return false; + } + dir = dir.content[seg]; + } + this.saveToStorage(); + return true; + } + readDir(path) { + const resolved = this._resolvePath(path); + if (!resolved) return null; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + return (node && node.type === 'dir') ? node : null; + } + return resolved.dir; + } + copyFile(srcPath, dstPath) { + const src = this._resolvePath(srcPath); + if (!src || !src.name) return false; + const srcNode = src.dir.content[src.name]; + if (!srcNode || srcNode.type !== 'file') return false; + const dst = this._resolvePath(dstPath); + if (!dst || !dst.name) return false; + const copy = JSON.parse(JSON.stringify(srcNode)); + dst.dir.content[dst.name] = copy; + this.saveToStorage(); + return true; + } + copyDir(srcPath, dstPath) { + const src = this._resolvePath(srcPath); + if (!src || !src.name) return false; + const srcNode = src.dir.content[src.name]; + if (!srcNode || srcNode.type !== 'dir') return false; + const dst = this._resolvePath(dstPath); + if (!dst || !dst.name) return false; + if (dst.dir.content[dst.name]) return false; + const copy = JSON.parse(JSON.stringify(srcNode)); + dst.dir.content[dst.name] = copy; + this.saveToStorage(); + return true; + } + moveFile(srcPath, dstPath) { + if (this.copyFile(srcPath, dstPath)) { + const src = this._resolvePath(srcPath); + if (src && src.name) { + delete src.dir.content[src.name]; + this.saveToStorage(); + return true; + } + } + return false; + } + moveDir(srcPath, dstPath) { + if (this.copyDir(srcPath, dstPath)) { + const src = this._resolvePath(srcPath); + if (src && src.name) { + delete src.dir.content[src.name]; + this.saveToStorage(); + return true; + } + } + return false; + } + // Old-compat aliases for agent tools and SkillsModule + fsRead(path) { + const content = this.readFile(path); + return content !== null ? content : null; + } + fsWrite(path, content) { + return this.writeFile(path, content); + } + fsReadJSON(path) { + const c = this.fsRead(path); + if (c === null) return null; + try { return JSON.parse(c); } catch (e) { return null; } + } + fsWriteJSON(path, obj) { + return this.fsWrite(path, JSON.stringify(obj)); + } + findFiles(pattern) { + const results = []; + const search = (node, path) => { + if (node.type === 'dir' && node.content) { + for (const k of Object.keys(node.content)) { + const sub = node.content[k]; + const subPath = path ? path + '/' + k : k; + if (sub.type === 'dir') search(sub, subPath); + else if (sub.type === 'file' && k.toLowerCase().includes(pattern)) results.push(subPath); + } + } + }; + search(this.root, ''); + return results.sort(); + } + deleteDirRecursive(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (!node || node.type !== 'dir') return false; + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } + // Full state serialization for session save/restore + serializeState() { + return JSON.stringify({ + root: this.root, + currentPath: this.currentPath, + version: 1 + }); + } + restoreState(json) { + try { + const data = JSON.parse(json); + if (!data || !data.root || !data.root.type) return false; + this.root = data.root; + this.currentPath = data.currentPath || []; + this.saveToStorage(); + return true; + } catch (e) { + console.error('FS restore error:', e); + return false; + } + } + // Restore VFS from an exported dump file (replaces entire root) + restoreFromExport(content) { + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (e) { return false; } + } + if (!content || !content.type) return false; + this.root = content; + this.currentPath = []; + this.saveToStorage(); + return true; + } + _snapshotDirExists() { + return !!this.readDir('/_snapshots'); + } + checkpoint(name) { + if (!name) name = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const data = { + version: 1, + createdAt, + root: JSON.parse(JSON.stringify(this.root)), + currentPath: [...this.currentPath] + }; + if (!this.readDir('/_snapshots')) { + this.mkdirRecursive('/_snapshots'); + } + this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data); + this.saveToStorage(); + // Auto-manage: keep max 10 checkpoints, delete oldest + const all = this.listCheckpoints(); + if (all.length > 10) { + const sorted = all.slice().sort(); + const toDelete = sorted.slice(0, all.length - 10); + for (const old of toDelete) this.deleteCheckpoint(old); + } + return true; + } + rollback(name) { + const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); + if (!raw || !raw.root) return false; + this.root = raw.root; + this.currentPath = raw.currentPath || []; + this.saveToStorage(); + return true; + } + listCheckpoints() { + const dir = this.readDir('/_snapshots'); + if (!dir || !dir.content) return []; + const prefix = 'checkpoint-'; + const suffix = '.json'; + return Object.keys(dir.content) + .filter(k => k.startsWith(prefix) && k.endsWith(suffix)) + .map(k => k.slice(prefix.length, k.length - suffix.length)) + .sort(); + } + deleteCheckpoint(name) { + return this.deleteFile('/_snapshots/checkpoint-' + name + '.json'); + } +} diff --git a/modules/ext/alias.js b/modules/ext/alias.js new file mode 100644 index 0000000..4ffab72 --- /dev/null +++ b/modules/ext/alias.js @@ -0,0 +1,108 @@ +class AliasModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.key = 'webx11_aliases'; + this.defaults = { 'll': 'ls', 'h': 'help', 'cls': 'clear' }; + this.aliases = this.load(); + } + + getHelpInfo() { + return { + 'alias': 'List, set, or show aliases (usage: alias [name=value])', + 'unalias': 'Remove a user-defined alias (usage: unalias <name>)', + }; + } + + load() { + try { + const data = localStorage.getItem(this.key); + return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults }; + } catch (e) { return { ...this.defaults }; } + } + save() { + const custom = {}; + Object.keys(this.aliases).forEach(k => { + if (!this.defaults[k]) custom[k] = this.aliases[k]; + }); + localStorage.setItem(this.key, JSON.stringify(custom)); + } + registerCommands() { + const self = this; + // Register management commands + this.commands['alias'] = (args) => self.handleAlias(args); + this.commands['unalias'] = (args) => self.handleUnalias(args); + // Register each alias as a delegating command + Object.keys(this.aliases).forEach(name => { + if (name === 'alias' || name === 'unalias') return; + const target = this.aliases[name]; + this.commands[name] = (args) => { + this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : '')); + }; + }); + } + handleAlias(args) { + if (!args[0]) { + // List all aliases + this.terminal.print('=== Aliases ===', 'info'); + const keys = Object.keys(this.aliases); + if (keys.length === 0) { + this.terminal.print(' No aliases defined', 'warning'); + } else { + keys.forEach(name => { + const mark = this.defaults[name] ? ' (built-in)' : ''; + this.terminal.print(` ${name}=${this.aliases[name]}${mark}`); + }); + } + this.terminal.print('Usage: alias <name>=<value>', 'info'); + return; + } + // Parse name=value + const input = args.join(' '); + const eqIdx = input.indexOf('='); + if (eqIdx === -1) { + // Show specific alias value + const name = args[0]; + if (this.aliases[name]) { + this.terminal.print(`${name}=${this.aliases[name]}`, 'info'); + } else { + this.terminal.print(`Alias '${name}' not found`, 'error'); + } + return; + } + const name = input.substring(0, eqIdx).trim(); + const value = input.substring(eqIdx + 1).trim(); + if (!name) return this.terminal.print('Invalid alias name', 'error'); + if (!value) return this.terminal.print('Invalid alias value', 'error'); + if (name === 'alias' || name === 'unalias') { + return this.terminal.print('Cannot alias reserved command name', 'error'); + } + this.aliases[name] = value; + this.save(); + // Register the new alias as a command immediately + if (!this.defaults[name]) { + const target = value; + this.commands[name] = (args) => { + this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : '')); + }; + } + this.terminal.print(`Alias set: ${name}=${value}`, 'success'); + } + handleUnalias(args) { + if (!args[0]) return this.terminal.print('Usage: unalias <name>', 'warning'); + const name = args[0]; + if (this.defaults[name]) { + return this.terminal.print(`Cannot unalias built-in alias '${name}'`, 'warning'); + } + if (!this.aliases[name]) { + return this.terminal.print(`Alias '${name}' not found`, 'error'); + } + delete this.aliases[name]; + delete this.commands[name]; + this.save(); + this.terminal.print(`Alias removed: ${name}`, 'success'); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new AliasModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/ext/alias.js'); +} diff --git a/modules/ext/calc.js b/modules/ext/calc.js new file mode 100644 index 0000000..e28079e --- /dev/null +++ b/modules/ext/calc.js @@ -0,0 +1,77 @@ +class CalcModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.units = { + 'km': { to: { 'miles': 0.621371, 'meters': 1000, 'feet': 3280.84 } }, + 'miles': { to: { 'km': 1.60934, 'meters': 1609.34, 'feet': 5280 } }, + 'meters': { to: { 'km': 0.001, 'miles': 0.000621371, 'feet': 3.28084 } }, + 'feet': { to: { 'km': 0.0003048, 'miles': 0.000189394, 'meters': 0.3048 } }, + 'kg': { to: { 'pounds': 2.20462, 'grams': 1000, 'ounces': 35.274 } }, + 'pounds': { to: { 'kg': 0.453592, 'grams': 453.592, 'ounces': 16 } }, + 'grams': { to: { 'kg': 0.001, 'pounds': 0.00220462, 'ounces': 0.035274 } }, + 'ounces': { to: { 'kg': 0.0283495, 'pounds': 0.0625, 'grams': 28.3495 } }, + 'celsius': { to: { 'fahrenheit': 'c2f', 'kelvin': 'c2k' } }, + 'fahrenheit': { to: { 'celsius': 'f2c', 'kelvin': 'f2k' } }, + 'kelvin': { to: { 'celsius': 'k2c', 'fahrenheit': 'k2f' } }, + 'inch': { to: { 'cm': 2.54, 'mm': 25.4, 'feet': 0.0833333 } }, + 'cm': { to: { 'inch': 0.393701, 'mm': 10, 'm': 0.01 } }, + 'mm': { to: { 'cm': 0.1, 'inch': 0.0393701, 'm': 0.001 } }, + 'liters': { to: { 'gallons': 0.264172, 'ml': 1000, 'cups': 4.22675 } }, + 'gallons': { to: { 'liters': 3.78541, 'ml': 3785.41, 'cups': 16 } } + }; + } + getHelpInfo() { + return { 'calc': 'calc <expression> or calc <value> <from-unit> to <to-unit> — Evaluate expressions or convert between units' }; + } + registerCommands() { + this.commands['calc'] = (args) => this.calc(args); + } + calc(args) { + if (!args[0]) return this.terminal.print('Usage: calc <expression> or calc <value> <from-unit> to <to-unit>', 'warning'); + const input = args.join(' '); + const convertMatch = input.match(/^([+-]?\d*\.?\d+)\s*(\w+)\s+(?:to|in|->)\s+(\w+)$/i); + if (convertMatch) { + return this.convert(parseFloat(convertMatch[1]), convertMatch[2].toLowerCase(), convertMatch[3].toLowerCase()); + } + this.evaluate(input); + } + evaluate(expr) { + const sanitized = expr.replace(/[^0-9+\-*/.()%eE\s]/g, ''); + if (!sanitized) return this.terminal.print('Invalid expression', 'error'); + try { + const result = new Function('return (' + sanitized + ')')(); + if (typeof result === 'number' && !isNaN(result)) { + this.terminal.print(`${sanitized} = ${result}`, 'success'); + } else { + this.terminal.print('Invalid result', 'error'); + } + } catch (e) { + this.terminal.print(`Error: ${e.message}`, 'error'); + } + } + convert(value, from, to) { + if (!this.units[from]) return this.terminal.print(`Unknown unit: ${from}`, 'error'); + if (!this.units[from].to[to]) { + const avail = Object.keys(this.units[from].to).join(', '); + return this.terminal.print(`Cannot convert ${from} to ${to}. Available: ${avail}`, 'warning'); + } + const factor = this.units[from].to[to]; + if (typeof factor === 'string') { + let result; + if (factor === 'c2f') result = value * 9/5 + 32; + else if (factor === 'c2k') result = value + 273.15; + else if (factor === 'f2c') result = (value - 32) * 5/9; + else if (factor === 'f2k') result = (value - 32) * 5/9 + 273.15; + else if (factor === 'k2c') result = value - 273.15; + else if (factor === 'k2f') result = (value - 273.15) * 9/5 + 32; + else return this.terminal.print('Conversion not implemented', 'error'); + this.terminal.print(`${value} ${from} = ${result.toFixed(4)} ${to}`, 'success'); + } else { + this.terminal.print(`${value} ${from} = ${(value * factor).toFixed(4)} ${to}`, 'success'); + } + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new CalcModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/calc.js'); +} diff --git a/modules/ext/cron.js b/modules/ext/cron.js new file mode 100644 index 0000000..66cd18e --- /dev/null +++ b/modules/ext/cron.js @@ -0,0 +1,110 @@ +class CronModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.jobs = {}; + this.counter = 0; + this.MIN_INTERVAL = 5; + this.STORAGE_KEY = 'webx11_cron_jobs'; + this._restoreJobs(); + } + requirements() { + return { met: true, missing: [] }; + } + getHelpInfo() { + return { + 'cron-every': 'cron-every <seconds> <command> — Schedule a command to run every N seconds', + 'cron-list': 'cron-list — List all active cron jobs', + 'cron-stop': 'cron-stop <id> — Stop a specific cron job', + 'cron-clear': 'cron-clear — Stop all cron jobs' + }; + } + registerCommands() { + this.commands['cron-every'] = (args) => this.every(args); + this.commands['cron-list'] = () => this.list(); + this.commands['cron-stop'] = (args) => this.stop(args); + this.commands['cron-clear'] = () => this.clear(); + } + _restoreJobs() { + try { + const saved = localStorage.getItem(this.STORAGE_KEY); + if (!saved) return; + const data = JSON.parse(saved); + if (!Array.isArray(data) || data.length === 0) return; + let maxId = 0; + data.forEach(j => { + if (j.id > maxId) maxId = j.id; + if (this.counter < j.id) this.counter = j.id; + this.jobs[j.id] = { + cmd: j.cmd, + interval: j.interval, + runs: j.runs || 0, + timer: setInterval(() => { + if (!this.jobs[j.id]) return; + this.jobs[j.id].runs++; + this.terminal.print(`[cron #${j.id}] Running: ${j.cmd}`, 'info'); + this.terminal.executeCommand(j.cmd); + }, j.interval) + }; + }); + this.counter = maxId; + if (data.length > 0) this.terminal.print(`↻ Restored ${data.length} cron job(s) from storage`, 'info'); + } catch (e) { /* silently ignore corrupt data */ } + } + _saveJobs() { + try { + const data = Object.entries(this.jobs).map(([id, j]) => ({ + id: parseInt(id), + cmd: j.cmd, + interval: j.interval, + runs: j.runs + })); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data)); + } catch (e) { /* quota exceeded */ } + } + every(args) { + if (args.length < 2) return this.terminal.print('Usage: cron-every <seconds> <command>', 'warning'); + const secs = parseInt(args[0]); + if (isNaN(secs) || secs < this.MIN_INTERVAL) return this.terminal.print(`Interval must be >= ${this.MIN_INTERVAL} seconds`, 'warning'); + const cmd = args.slice(1).join(' '); + this.counter++; + const id = this.counter; + this.jobs[id] = { + cmd, interval: secs * 1000, runs: 0, + timer: setInterval(() => { + this.jobs[id].runs++; + this.terminal.print(`[cron #${id}] Running: ${cmd}`, 'info'); + this.terminal.executeCommand(cmd); + }, secs * 1000) + }; + this._saveJobs(); + this.terminal.print(`Cron job #${id} started: "${cmd}" every ${secs}s`, 'success'); + } + list() { + const ids = Object.keys(this.jobs); + if (ids.length === 0) return this.terminal.print('No active cron jobs', 'info'); + this.terminal.print('=== Cron Jobs ===', 'info'); + ids.forEach(id => { + const j = this.jobs[id]; + this.terminal.print(` #${id}: "${j.cmd}" every ${j.interval / 1000}s (${j.runs} runs)`); + }); + } + stop(args) { + if (!args[0]) return this.terminal.print('Usage: cron-stop <id>', 'warning'); + const id = parseInt(args[0]); + if (!this.jobs[id]) return this.terminal.print(`Cron job #${id} not found`, 'error'); + clearInterval(this.jobs[id].timer); + delete this.jobs[id]; + this._saveJobs(); + this.terminal.print(`Cron job #${id} stopped`, 'success'); + } + clear() { + Object.keys(this.jobs).forEach(id => { clearInterval(this.jobs[id].timer); }); + this.jobs = {}; + localStorage.removeItem(this.STORAGE_KEY); + this.terminal.print('All cron jobs stopped', 'success'); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new CronModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/cron.js'); +} diff --git a/modules/ext/diff.js b/modules/ext/diff.js new file mode 100644 index 0000000..8b79b6b --- /dev/null +++ b/modules/ext/diff.js @@ -0,0 +1,287 @@ +class DiffModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'diff': 'Show differences between two VFS files or compare module source (usage: diff <file1> <file2> or diff --module <name>)', + }; + } + + registerCommands() { + this.commands['diff'] = (args) => this.diff(args); + } + async diff(args) { + if (!args.length) return this.terminal.print('Usage: diff <file1> <file2> or diff --module <ModuleName>', 'warning'); + + if (args[0] === '--module') { + if (!args[1]) return this.terminal.print('Usage: diff --module <ModuleName>', 'warning'); + return this._diffModule(args[1]); + } + + return this._diffFiles(args[0], args[1]); + } + _diffFiles(path1, path2) { + let content1, content2; + try { + content1 = this.terminal.fs.readFile(path1); + content2 = this.terminal.fs.readFile(path2); + } catch (e) { + return this.terminal.print(`Error reading files: ${e.message}`, 'error'); + } + if (content1 === null || content1 === undefined) { + return this.terminal.print(`File not found: ${path1}`, 'error'); + } + if (content2 === null || content2 === undefined) { + return this.terminal.print(`File not found: ${path2}`, 'error'); + } + const lines1 = String(content1).split('\n'); + const lines2 = String(content2).split('\n'); + + // Strip trailing empty line if file ends with newline + if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop(); + if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop(); + + if (lines1.length === 0 && lines2.length === 0) { + return this.terminal.print('Both files are empty — no differences', 'info'); + } + + // Compute LCS-based diff + const result = this._computeDiff(lines1, lines2); + + // Render output + this.terminal.print(`--- ${path1}`, 'info'); + this.terminal.print(`+++ ${path2}`, 'info'); + + if (result.length === 0) { + return this.terminal.print('Files are identical', 'success'); + } + + const maxLineNum = Math.max(lines1.length, lines2.length); + const lineNumWidth = String(maxLineNum).length; + + result.forEach(part => { + if (part.type === 'same') { + // Print up to 3 unchanged lines for context, then collapse + if (part.lines.length <= 3) { + part.lines.forEach((line, i) => { + const ln = String(part.aStart + i + 1).padStart(lineNumWidth); + this.terminal.print(` ${ln} ${line}`, 'dim'); + }); + } else { + // Show first line + this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim'); + this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim'); + // Show last line + const lastIdx = part.lines.length - 1; + const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth); + this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim'); + } + } else if (part.type === 'removed') { + part.lines.forEach((line, i) => { + const ln = String(part.aStart + i + 1).padStart(lineNumWidth); + this.terminal.print(`-${ln} ${line}`, 'error'); + }); + } else if (part.type === 'added') { + part.lines.forEach((line, i) => { + const ln = String(part.bStart + i + 1).padStart(lineNumWidth); + this.terminal.print(`+${ln} ${line}`, 'success'); + }); + } + }); + } + _computeDiff(a, b) { + // Build LCS table + const m = a.length; + const n = b.length; + const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to build diff parts + const parts = []; + let i = m, j = n; + let removed = [], added = [], same = []; + const aStart = 0, bStart = 0; + + const flush = () => { + if (same.length > 0) { + parts.push({ + type: 'same', + aStart: aStart + (parts.length > 0 ? parts.reduce((sum, p) => { + if (p.type === 'same') return sum + p.lines.length; + if (p.type === 'removed') return sum + p.lines.length; + return sum; + }, 0) : 0), + lines: same.splice(0) + }); + } + if (removed.length > 0 || added.length > 0) { + if (removed.length > 0) { + const aIdx = parts.reduce((sum, p) => { + if (p.type === 'same') return sum + p.lines.length; + if (p.type === 'removed') return sum + p.lines.length; + return sum; + }, 0); + parts.push({ type: 'removed', aStart: aIdx, lines: removed.splice(0) }); + } + if (added.length > 0) { + const bIdx = parts.reduce((sum, p) => { + if (p.type === 'same') return sum; + if (p.type === 'added') return sum + p.lines.length; + return sum; + }, 0); + parts.push({ type: 'added', bStart: bIdx, lines: added.splice(0) }); + } + } + }; + + // Trace back through DP table + const trace = []; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + trace.push({ type: 'same', lineA: a[i - 1], lineB: b[j - 1] }); + i--; j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + trace.push({ type: 'added', lineB: b[j - 1] }); + j--; + } else { + trace.push({ type: 'removed', lineA: a[i - 1] }); + i--; + } + } + + // Reverse trace to get forward order + trace.reverse(); + + // Build parts from trace + let aIdx = 0, bIdx = 0; + for (const entry of trace) { + if (entry.type === 'same') { + same.push(entry.lineA); + aIdx++; + bIdx++; + } else if (entry.type === 'removed') { + flush(); + removed.push(entry.lineA); + aIdx++; + } else if (entry.type === 'added') { + flush(); + added.push(entry.lineB); + bIdx++; + } + } + flush(); + + return parts; + } + async _diffModule(modName) { + // Find module in registry + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + if (!entry) { + return this.terminal.print(`Module "${modName}" not found in registry`, 'error'); + } + + const url = entry.url; + if (!url) { + return this.terminal.print(`No source URL for module "${modName}"`, 'warning'); + } + + // Fetch original source + let originalSource; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + originalSource = await resp.text(); + } catch (e) { + return this.terminal.print(`Failed to fetch original source: ${e.message}`, 'error'); + } + + // Read local edited version + const localPath = `_mods/${modName}.js`; + let localSource; + let hasLocalEdit = false; + try { + localSource = this.terminal.fs.readFile(localPath); + if (localSource === null || localSource === undefined) { + hasLocalEdit = false; + } else { + hasLocalEdit = true; + } + } catch (e) { + hasLocalEdit = false; + } + + if (!hasLocalEdit) { + // Try to read the module's currently loaded source + const loadedModule = this.terminal.moduleManager.modules[modName]; + if (!loadedModule) { + return this.terminal.print(`Module "${modName}" is not loaded and has no local edits`, 'info'); + } + return this.terminal.print(`Module "${modName}" has no local edits — source matches original`, 'success'); + } + + // Compare original source vs local edit + const lines1 = String(originalSource).split('\n'); + const lines2 = String(localSource).split('\n'); + + // Strip trailing empty line if file ends with newline + if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop(); + if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop(); + + this.terminal.print(`--- ${url}`, 'info'); + this.terminal.print(`+++ ${localPath}`, 'info'); + + if (lines1.length === lines2.length && lines1.every((l, i) => l === lines2[i])) { + return this.terminal.print('No differences — local edit matches original', 'success'); + } + + const result = this._computeDiff(lines1, lines2); + const maxLineNum = Math.max(lines1.length, lines2.length); + const lineNumWidth = String(maxLineNum).length; + + result.forEach(part => { + if (part.type === 'same') { + if (part.lines.length <= 3) { + part.lines.forEach((line, i) => { + const ln = String(part.aStart + i + 1).padStart(lineNumWidth); + this.terminal.print(` ${ln} ${line}`, 'dim'); + }); + } else { + this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim'); + this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim'); + const lastIdx = part.lines.length - 1; + const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth); + this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim'); + } + } else if (part.type === 'removed') { + part.lines.forEach((line, i) => { + const ln = String(part.aStart + i + 1).padStart(lineNumWidth); + this.terminal.print(`-${ln} ${line}`, 'error'); + }); + } else if (part.type === 'added') { + part.lines.forEach((line, i) => { + const ln = String(part.bStart + i + 1).padStart(lineNumWidth); + this.terminal.print(`+${ln} ${line}`, 'success'); + }); + } + }); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new DiffModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/diff.js'); +} diff --git a/modules/ext/editor.js b/modules/ext/editor.js new file mode 100644 index 0000000..571a535 --- /dev/null +++ b/modules/ext/editor.js @@ -0,0 +1,70 @@ +class EditorModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._vfsPath = null; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'edit': 'Open the code editor for a VFS file (usage: edit <path>)', + }; + } + + registerCommands() { + this.commands['edit'] = (args) => this.edit(args); + } + edit(args) { + if (!args[0]) return this.terminal.print('Usage: edit <path>', 'warning'); + const path = args.join(' '); + const modal = document.getElementById('code-editor'); + const title = document.getElementById('code-editor-title'); + const textarea = document.getElementById('code-editor-textarea'); + if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error'); + // Read existing file content or use empty for new files + const content = this.terminal.fs.readFile(path); + const fileContent = (content !== null && content !== false) ? content : ''; + // Set up modal + this._vfsPath = path; + title.textContent = '📝 Editing: ' + path; + textarea.value = fileContent; + modal.style.display = 'flex'; + textarea.focus(); + // Override save button to write back to VFS + const saveBtn = modal.querySelector('.modal-footer .btn:last-child'); + const cancelBtn = modal.querySelector('.modal-footer .btn-danger'); + if (saveBtn) { + saveBtn._origOnclick = saveBtn.onclick; + saveBtn.onclick = async () => { + const code = textarea.value; + const ok = this.terminal.fs.writeFile(this._vfsPath, code); + if (ok) { + this.terminal.print('✅ Saved to ' + this._vfsPath, 'success'); + } else { + this.terminal.print('❌ Failed to save ' + this._vfsPath, 'error'); + } + modal.style.display = 'none'; + this._vfsPath = null; + // Restore original onclick handler + if (saveBtn._origOnclick) saveBtn.onclick = saveBtn._origOnclick; + delete saveBtn._origOnclick; + }; + } + if (cancelBtn) { + cancelBtn._origOnclick = cancelBtn.onclick; + cancelBtn.onclick = () => { + modal.style.display = 'none'; + this._vfsPath = null; + if (cancelBtn._origOnclick) cancelBtn.onclick = cancelBtn._origOnclick; + delete cancelBtn._origOnclick; + }; + } + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new EditorModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/editor.js'); +} diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js new file mode 100644 index 0000000..5532cee --- /dev/null +++ b/modules/ext/filemgr.js @@ -0,0 +1,755 @@ +class FileManagerModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.currentPath = []; + this._overlay = null; + this._selectedFile = null; + this._searchFilter = ''; + this._rawMode = false; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'files': 'Open the file manager overlay with VFS directory listing and preview', + 'preview': 'Preview a specific file (usage: preview <path>)', + }; + } + + registerCommands() { + this.commands['files'] = () => this._openOverlay(); + this.commands['preview'] = (args) => this._previewCommand(args); + } + + _previewCommand(args) { + if (!args[0]) return this.terminal.print('Usage: preview <path>', 'warning'); + const path = args.join(' '); + this._openOverlay(); + this._navigateToPathFromParts(path); + const content = this.terminal.fs.readFile(path); + if (content === null || content === false) { + this._renderPreviewError('File not found: ' + path); + return; + } + this._selectedFile = path; + this._renderPreview(path, content); + this._highlightSelected(); + } + + _navigateToPathFromParts(path) { + const parts = path.split('/').filter(p => p); + if (parts.length <= 1) { + this.currentPath = []; + return; + } + this.currentPath = parts.slice(0, -1); + } + + _detectType(filePath) { + const ext = filePath.split('.').pop().toLowerCase(); + if (!ext || ext === filePath) return 'other'; + const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + const videoExts = ['mp4', 'webm']; + const audioExts = ['mp3', 'wav', 'ogg']; + const codeExts = ['js', 'py', 'html', 'css', 'yaml', 'yml', 'toml']; + if (ext === 'md') return 'md'; + if (imageExts.includes(ext)) return 'image'; + if (videoExts.includes(ext)) return 'video'; + if (audioExts.includes(ext)) return 'audio'; + if (codeExts.includes(ext)) return 'code'; + if (ext === 'json') return 'json'; + if (ext === 'csv') return 'csv'; + if (ext === 'pdf') return 'pdf'; + if (ext === 'svg') return 'svg'; + if (ext === 'txt') return 'text'; + return 'other'; + } + + _renderMarkdown(text) { + if (!text) return ''; + try { + let html = text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + // code blocks first + html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>'); + // inline code + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); + // headers + html = html.replace(/^### (.+)$/gm, '<h3 style="color:var(--accent-color);margin:8px 0 4px">$1</h3>'); + html = html.replace(/^## (.+)$/gm, '<h2 style="color:var(--accent-color);margin:10px 0 4px">$1</h2>'); + html = html.replace(/^# (.+)$/gm, '<h1 style="color:var(--accent-color);margin:12px 0 6px">$1</h1>'); + // bold + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + // italic + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); + // links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--info-color)">$1</a>'); + // horizontal rules + html = html.replace(/^---$/gm, '<hr style="border-color:var(--border-color);margin:8px 0">'); + // unordered lists + html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li style="margin-left:16px;list-style:disc">$1</li>'); + // paragraphs - double newlines + html = html.replace(/\n\n/g, '</p><p style="margin:6px 0">'); + html = '<p style="margin:6px 0">' + html + '</p>'; + return html; + } catch (e) { + return '<pre style="white-space:pre-wrap">' + text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</pre>'; + } + } + + _getPreviewHtml(path, content) { + if (content === null || content === false) { + return '<div style="color:var(--error-color);padding:20px">File not found: ' + this._escapeHtml(path) + '</div>'; + } + + const type = this._detectType(path); + const ext = path.split('.').pop().toLowerCase(); + const fileName = path.split('/').pop() || path; + + // Handle binary detection for media types + const isLikelyBinary = typeof content === 'string' && content.length > 0 && !content.startsWith('\n') && !content.startsWith('\r') && !content.startsWith(' ') && !content.startsWith('<') && !content.startsWith('{') && !content.startsWith('[') && content.charCodeAt(0) < 32 && content.charCodeAt(0) !== 9 && content.charCodeAt(0) !== 10 && content.charCodeAt(0) !== 13; + + const isTextContent = typeof content === 'string' && (content.startsWith('{') || content.startsWith('[') || content.startsWith('<') || content.startsWith('#') || /^[\w\s\n\r\t.,;:!?()\-=+*@#$%^&"'\/\\|`~]/.test(content.substring(0, 100))); + + if (type === 'md' && isTextContent) { + return '<div class="markdown-preview" style="padding:12px;font-family:var(--font-primary);line-height:1.5">' + this._renderMarkdown(content) + '</div>'; + } + + if (type === 'text') { + const truncated = content.substring(0, 2000); + const extra = content.length > 2000 ? '<div style="color:var(--text-muted);margin-top:8px">(truncated to 2000 chars)</div>' : ''; + return '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(truncated) + extra + '</pre>'; + } + + if (type === 'json') { + try { + const parsed = JSON.parse(content); + const formatted = JSON.stringify(parsed, null, 2); + const html = this._syntaxHighlightJson(formatted); + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto;line-height:1.4">' + html + '</pre>'; + } catch (e) { + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>'; + } + } + + if (type === 'code') { + const lines = content.split('\n'); + const totalLines = lines.length; + const maxLines = 150; + const displayLines = lines.slice(0, maxLines); + let html = '<table style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0"><tbody>'; + displayLines.forEach((line, i) => { + const lineNum = i + 1; + const escaped = this._escapeHtml(line); + html += '<tr><td style="color:var(--text-muted);padding:0 8px 0 4px;text-align:right;user-select:none;border-right:1px solid var(--border-color);vertical-align:top;width:40px">' + lineNum + '</td>'; + html += '<td style="padding:0 8px;white-space:pre;width:100%">' + (escaped || ' ') + '</td></tr>'; + }); + html += '</tbody></table>'; + if (totalLines > maxLines) { + html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + totalLines + ' lines)</div>'; + } + return html; + } + + if (type === 'image') { + if (isLikelyBinary && !isTextContent) { + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Image — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="' + this._escapeHtml("var a=document.createElement('a');a.href='data:image/" + ext + ";base64," + btoa(content) + "';a.download='" + fileName + "';a.click()") + '">download to view</span></div>'; + } + const base64 = btoa(unescape(encodeURIComponent(content))); + return '<div style="padding:12px;text-align:center"><img src="data:image/' + ext + ';base64,' + base64 + '" style="max-width:80%;max-height:60vh;border-radius:4px;border:1px solid var(--border-color)" alt="' + this._escapeHtml(fileName) + '"></div>'; + } + + if (type === 'svg') { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(content, 'text/html'); + const scripts = doc.querySelectorAll('script'); + scripts.forEach(s => s.remove()); + // Remove event handlers from svg elements + const allEls = doc.querySelectorAll('*'); + allEls.forEach(el => { + for (let i = el.attributes.length - 1; i >= 0; i--) { + const attr = el.attributes[i].name; + if (attr.startsWith('on')) el.removeAttribute(attr); + } + }); + const cleanSvg = doc.body.innerHTML; + return '<div style="padding:12px;text-align:center;max-width:100%;overflow:auto">' + cleanSvg + '</div>'; + } catch (e) { + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>'; + } + } + + if (type === 'video') { + if (isLikelyBinary && !isTextContent) { + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Video — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:video/' + ext + ';base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to view</span></div>'; + } + const base64 = btoa(unescape(encodeURIComponent(content))); + return '<div style="padding:12px;text-align:center"><video controls style="max-width:80%;max-height:60vh;border-radius:4px"><source src="data:video/' + ext + ';base64,' + base64 + '" type="video/' + ext + '"></video></div>'; + } + + if (type === 'audio') { + if (isLikelyBinary && !isTextContent) { + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">🎵 Audio — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:audio/' + ext + ';base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to play</span></div>'; + } + const base64 = btoa(unescape(encodeURIComponent(content))); + return '<div style="padding:12px;text-align:center"><audio controls style="max-width:80%"><source src="data:audio/' + ext + ';base64,' + base64 + '" type="audio/' + ext + '"></audio></div>'; + } + + if (type === 'pdf') { + try { + const base64 = btoa(unescape(encodeURIComponent(content))); + return '<div style="padding:12px;text-align:center;height:70vh"><embed src="data:application/pdf;base64,' + base64 + '" type="application/pdf" style="width:100%;height:100%;border:1px solid var(--border-color);border-radius:4px"></div>'; + } catch (e) { + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📄 PDF — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:application/pdf;base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to view</span></div>'; + } + } + + if (type === 'csv') { + try { + const lines = content.split('\n').filter(l => l.trim()); + const maxRows = 50; + const rows = lines.slice(0, maxRows).map(l => l.split(',').map(c => c.trim().replace(/^"|"$/g, ''))); + let html = '<table class="csv-table" style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0;background:var(--bg-secondary)">'; + if (rows.length > 0) { + html += '<thead><tr>'; + rows[0].forEach(cell => { + html += '<th style="border:1px solid var(--border-color);padding:6px 8px;text-align:left;background:var(--bg-tertiary);color:var(--accent-color);font-weight:bold">' + this._escapeHtml(cell) + '</th>'; + }); + html += '</tr></thead><tbody>'; + rows.slice(1).forEach(row => { + html += '<tr>'; + row.forEach(cell => { + html += '<td style="border:1px solid var(--border-color);padding:4px 8px">' + this._escapeHtml(cell) + '</td>'; + }); + html += '</tr>'; + }); + html += '</tbody></table>'; + if (lines.length > maxRows) { + html += '<div style="color:var(--text-muted);padding:8px">(showing ' + maxRows + ' of ' + lines.length + ' rows)</div>'; + } + } + return html; + } catch (e) { + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>'; + } + } + + // 'other' — raw text fallback + if (typeof content === 'string' && isTextContent) { + const lines = content.split('\n'); + const maxLines = 80; + const displayLines = lines.slice(0, maxLines); + const truncated = lines.length > maxLines; + let html = '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">'; + displayLines.forEach((line, i) => { + html += this._escapeHtml(line) + '\n'; + }); + html += '</pre>'; + if (truncated) { + html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + lines.length + ' lines)</div>'; + } + return html; + } + + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📁 Preview not available for this file type</div>'; + } + + _syntaxHighlightJson(jsonStr) { + return jsonStr.replace(/("(?:\\.|[^"\\])*")\s*:/g, '<span style="color:var(--info-color)">$1</span>:') + .replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span style="color:var(--success-color)">$1</span>') + .replace(/:\s*(\d+\.?\d*)/g, ': <span style="color:var(--warning-color)">$1</span>') + .replace(/:\s*(true|false|null)/g, ': <span style="color:var(--error-color)">$1</span>'); + } + + _escapeHtml(str) { + if (typeof str !== 'string') return ''; + return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + _formatSize(size) { + if (!size && size !== 0) return ''; + if (size < 1024) return size + ' B'; + if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'; + return (size / (1024 * 1024)).toFixed(1) + ' MB'; + } + + _createOverlay() { + if (this._overlay) return; + + const overlay = document.createElement('div'); + overlay.id = 'filemgr-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:1800;display:flex;flex-direction:column;background:var(--bg-color);color:var(--text-color);font-family:var(--font-primary);font-size:14px;'; + + // Inject responsive styles for the overlay + const styleId = 'filemgr-styles'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + #filemgr-overlay.responsive .filemgr-main { + flex-direction: column !important; + } + #filemgr-overlay.responsive #filemgr-sidebar { + width: 100% !important; + max-height: 40vh; + } + #filemgr-overlay.responsive #filemgr-preview { + width: 100% !important; + } + #filemgr-overlay .filemgr-main { + display: flex; + flex: 1; + min-height: 0; + } + @media (max-width: 600px) { + #filemgr-overlay .filemgr-main { + flex-direction: column !important; + } + #filemgr-overlay #filemgr-sidebar { + width: 100% !important; + max-height: 40vh; + border-right: none !important; + border-bottom: 1px solid var(--border-color); + } + #filemgr-overlay #filemgr-preview { + width: 100% !important; + min-height: 40vh; + } + #filemgr-action-bar { + flex-wrap: wrap; + gap: 6px; + } + #filemgr-action-bar button { + min-width: 44px; + min-height: 44px; + font-size: 0.9em; + } + } + `; + document.head.appendChild(style); + } + + // Toolbar + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;'; + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:1em;min-height:44px;touch-action:manipulation;'; + closeBtn.onclick = () => this._closeOverlay(); + toolbar.appendChild(closeBtn); + + const titleSpan = document.createElement('span'); + titleSpan.textContent = '📁 File Manager'; + titleSpan.style.cssText = 'font-weight:bold;margin-right:8px;'; + toolbar.appendChild(titleSpan); + + // Breadcrumbs + const breadcrumbs = document.createElement('div'); + breadcrumbs.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:0.9em;'; + breadcrumbs.id = 'filemgr-breadcrumbs'; + + // Root breadcrumb + const rootSeg = document.createElement('span'); + rootSeg.textContent = '/'; + rootSeg.style.cssText = 'color:var(--accent-color);cursor:pointer;padding:2px 4px;border-radius:3px;'; + rootSeg.onmouseenter = () => { rootSeg.style.background = 'var(--bg-tertiary)'; }; + rootSeg.onmouseleave = () => { rootSeg.style.background = 'transparent'; }; + rootSeg.onclick = () => this._navigateTo([]); + breadcrumbs.appendChild(rootSeg); + + toolbar.appendChild(breadcrumbs); + + // Search input + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = '🔍 Filter files...'; + searchInput.style.cssText = 'flex:1;min-width:120px;max-width:300px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-family:var(--font-primary);font-size:0.9em;outline:none;'; + searchInput.oninput = (e) => { + this._searchFilter = e.target.value.toLowerCase(); + this._refreshFileList(); + }; + toolbar.appendChild(searchInput); + + overlay.appendChild(toolbar); + + // Main area — wraps sidebar + preview in a responsive flex container + const mainArea = document.createElement('div'); + mainArea.id = 'filemgr-main-container'; + mainArea.className = 'filemgr-main'; + mainArea.style.cssText = 'overflow:hidden;'; + + // Sidebar + const sidebar = document.createElement('div'); + sidebar.id = 'filemgr-sidebar'; + sidebar.style.cssText = 'width:30%;min-width:200px;overflow-y:auto;background:var(--bg-secondary);border-right:1px solid var(--border-color);'; + + mainArea.appendChild(sidebar); + + // Preview pane + const preview = document.createElement('div'); + preview.id = 'filemgr-preview'; + preview.style.cssText = 'flex:1;overflow-y:auto;padding:0;'; + + mainArea.appendChild(preview); + + overlay.appendChild(mainArea); + + // Action bar + const actionBar = document.createElement('div'); + actionBar.id = 'filemgr-action-bar'; + actionBar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-top:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;'; + + const statusSpan = document.createElement('span'); + statusSpan.id = 'filemgr-status'; + statusSpan.style.cssText = 'flex:1;color:var(--text-muted);font-size:0.85em;'; + statusSpan.textContent = 'No file selected'; + actionBar.appendChild(statusSpan); + + const deleteBtn = document.createElement('button'); + deleteBtn.textContent = '🗑 Delete'; + deleteBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--error-color);color:var(--error-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; + deleteBtn.onclick = () => this._actionDelete(); + actionBar.appendChild(deleteBtn); + + const downloadBtn = document.createElement('button'); + downloadBtn.textContent = '⬇ Download'; + downloadBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; + downloadBtn.onclick = () => this._actionDownload(); + actionBar.appendChild(downloadBtn); + + const editBtn = document.createElement('button'); + editBtn.textContent = '✏ Edit'; + editBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--accent-color);color:var(--accent-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; + editBtn.onclick = () => this._actionEdit(); + actionBar.appendChild(editBtn); + + const previewToggleBtn = document.createElement('button'); + previewToggleBtn.id = 'filemgr-toggle-raw'; + previewToggleBtn.textContent = '📄 Raw'; + previewToggleBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-muted);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; + previewToggleBtn.onclick = () => this._togglePreviewMode(); + actionBar.appendChild(previewToggleBtn); + + overlay.appendChild(actionBar); + + this._overlay = overlay; + return overlay; + } + + _openOverlay() { + if (this._overlay) { + this._overlay.style.display = 'flex'; + this._refreshFileList(); + return; + } + const overlay = this._createOverlay(); + document.body.appendChild(overlay); + this._navigateTo(this.currentPath); + } + + _closeOverlay() { + if (this._overlay) { + this._overlay.style.display = 'none'; + } + } + + _clearOverlay() { + if (this._overlay && this._overlay.parentNode) { + this._overlay.parentNode.removeChild(this._overlay); + } + this._overlay = null; + this._selectedFile = null; + this._searchFilter = ''; + this._rawMode = false; + } + + _navigateTo(pathParts) { + this.currentPath = pathParts || []; + this._selectedFile = null; + this._searchFilter = ''; + const searchInput = this._overlay?.querySelector('input[type="text"]'); + if (searchInput) searchInput.value = ''; + this._updateBreadcrumbs(); + this._refreshFileList(); + this._clearPreview(); + this._updateStatus('No file selected'); + } + + _updateBreadcrumbs() { + const breadcrumbs = document.getElementById('filemgr-breadcrumbs'); + if (!breadcrumbs) return; + + // Keep root segment, remove rest + while (breadcrumbs.children.length > 1) { + breadcrumbs.removeChild(breadcrumbs.lastChild); + } + + let accumulated = ''; + this.currentPath.forEach((part, i) => { + const sep = document.createElement('span'); + sep.textContent = ' › '; + sep.style.cssText = 'color:var(--text-muted);margin:0 2px;'; + breadcrumbs.appendChild(sep); + + const seg = document.createElement('span'); + seg.textContent = part; + seg.style.cssText = 'cursor:pointer;padding:2px 4px;border-radius:3px;color:var(--text-color);'; + if (i === this.currentPath.length - 1) { + seg.style.color = 'var(--accent-color)'; + seg.style.fontWeight = 'bold'; + } + seg.onmouseenter = () => { seg.style.background = 'var(--bg-tertiary)'; }; + seg.onmouseleave = () => { seg.style.background = 'transparent'; }; + const pathUpTo = this.currentPath.slice(0, i + 1); + seg.onclick = () => this._navigateTo(pathUpTo); + breadcrumbs.appendChild(seg); + }); + } + + _refreshFileList() { + const sidebar = document.getElementById('filemgr-sidebar'); + if (!sidebar) return; + + // Read directory + const pathStr = '/' + this.currentPath.join('/'); + const entries = this.terminal.fs.ls(pathStr) || []; + + // Separate dirs and files + const dirs = entries.filter(e => e.type === 'dir'); + const files = entries.filter(e => e.type !== 'dir'); + + // Sort alphabetically + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + + // Apply search filter + let filteredDirs = dirs; + let filteredFiles = files; + if (this._searchFilter) { + const q = this._searchFilter; + filteredDirs = dirs.filter(e => e.name.toLowerCase().includes(q)); + filteredFiles = files.filter(e => e.name.toLowerCase().includes(q)); + } + + sidebar.innerHTML = ''; + + if (filteredDirs.length === 0 && filteredFiles.length === 0) { + const emptyMsg = document.createElement('div'); + emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);'; + emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)'; + sidebar.appendChild(emptyMsg); + this._updateStatus('0 files'); + return; + } + + // Add directory items + filteredDirs.forEach(entry => { + const item = document.createElement('div'); + const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; + item.dataset.path = fullPath; + item.dataset.type = 'dir'; + item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;'; + item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; + item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; + item.onclick = () => { + const newPath = [...this.currentPath, entry.name]; + this._navigateTo(newPath); + }; + + const icon = document.createElement('span'); + icon.textContent = '📁'; + item.appendChild(icon); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = entry.name + '/'; + nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + item.appendChild(nameSpan); + + sidebar.appendChild(item); + }); + + // Add file items + filteredFiles.forEach(entry => { + const item = document.createElement('div'); + const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; + const isSelected = this._selectedFile === fullPath; + item.dataset.path = fullPath; + item.dataset.type = 'file'; + item.dataset.selected = isSelected ? 'true' : 'false'; + const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent'; + item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';'; + if (isSelected) { + item.style.borderLeft = '3px solid var(--accent-color)'; + } + item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; + item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; + item.onclick = () => { + this._selectedFile = fullPath; + this._highlightSelected(); + const content = this.terminal.fs.readFile(fullPath); + this._renderPreview(fullPath, content); + }; + + const icon = document.createElement('span'); + icon.textContent = '📄'; + item.appendChild(icon); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = entry.name; + nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + item.appendChild(nameSpan); + + const sizeSpan = document.createElement('span'); + sizeSpan.textContent = this._formatSize(entry.size); + sizeSpan.style.cssText = 'color:var(--text-muted);font-size:0.8em;white-space:nowrap;margin-left:4px;'; + item.appendChild(sizeSpan); + + sidebar.appendChild(item); + }); + + const totalCount = entries.length; + this._updateStatus(totalCount + ' item' + (totalCount !== 1 ? 's' : '')); + } + + _highlightSelected() { + const sidebar = document.getElementById('filemgr-sidebar'); + if (!sidebar) return; + const items = sidebar.querySelectorAll('[data-type="file"]'); + items.forEach(item => { + const selected = item.dataset.path === this._selectedFile; + item.dataset.selected = selected ? 'true' : 'false'; + item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent'; + item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent'; + }); + } + + _renderPreview(path, content) { + const preview = document.getElementById('filemgr-preview'); + if (!preview) return; + + // Re-read content on each preview call + if (content === undefined) { + content = this.terminal.fs.readFile(path); + } + + // For raw mode, show raw text regardless of type + if (this._rawMode && typeof content === 'string') { + preview.innerHTML = '<div style="padding:12px"><div style="color:var(--text-muted);font-size:0.85em;margin-bottom:8px">RAW: ' + this._escapeHtml(path) + '</div><pre style="white-space:pre-wrap;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 10000)) + '</pre></div>'; + this._updateStatus('Preview: ' + path); + return; + } + + // Check file size for text files + const type = this._detectType(path); + const textTypes = ['md', 'text', 'json', 'code', 'csv', 'other']; + if (textTypes.includes(type) && typeof content === 'string' && content.length > 51200) { + content = content.substring(0, 51200); + preview.innerHTML = this._getPreviewHtml(path, content); + const truncatedMsg = document.createElement('div'); + truncatedMsg.style.cssText = 'color:var(--warning-color);padding:8px 12px;border-top:1px solid var(--border-color);font-size:0.85em;'; + truncatedMsg.textContent = '(truncated — file exceeds 50KB)'; + preview.appendChild(truncatedMsg); + this._updateStatus('Preview: ' + path); + return; + } + + preview.innerHTML = this._getPreviewHtml(path, content); + this._updateStatus('Preview: ' + path); + } + + _renderPreviewError(msg) { + const preview = document.getElementById('filemgr-preview'); + if (!preview) return; + preview.innerHTML = '<div style="color:var(--error-color);padding:20px;font-size:1em">' + this._escapeHtml(msg) + '</div>'; + this._updateStatus('Error'); + } + + _clearPreview() { + const preview = document.getElementById('filemgr-preview'); + if (preview) preview.innerHTML = ''; + } + + _updateStatus(msg) { + const status = document.getElementById('filemgr-status'); + if (status) status.textContent = msg; + } + + _actionDelete() { + if (!this._selectedFile) { + this._updateStatus('No file selected to delete'); + return; + } + if (!confirm('Delete ' + this._selectedFile + '?')) return; + const ok = this.terminal.fs.deleteFile(this._selectedFile); + if (ok) { + this._selectedFile = null; + this._refreshFileList(); + this._clearPreview(); + this._updateStatus('✅ Deleted'); + } else { + this._updateStatus('❌ Failed to delete'); + } + } + + _actionDownload() { + if (!this._selectedFile) { + this._updateStatus('No file selected to download'); + return; + } + const content = this.terminal.fs.readFile(this._selectedFile); + if (content === null || content === false) { + this._updateStatus('❌ File not found'); + return; + } + const fileName = this._selectedFile.split('/').pop() || 'file'; + const blob = new Blob([content], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this._updateStatus('⬇ Downloaded: ' + fileName); + } + + _actionEdit() { + if (!this._selectedFile) { + this._updateStatus('No file selected to edit'); + return; + } + if (typeof webx11 !== 'undefined' && webx11 && webx11.executeCommand) { + webx11.executeCommand('edit ' + this._selectedFile); + this._updateStatus('✏ Opened in editor: ' + this._selectedFile); + } else { + this._updateStatus('❌ Editor not available'); + } + } + + _togglePreviewMode() { + this._rawMode = !this._rawMode; + const btn = document.getElementById('filemgr-toggle-raw'); + if (btn) { + btn.textContent = this._rawMode ? '📄 Rendered' : '📄 Raw'; + btn.style.borderColor = this._rawMode ? 'var(--accent-color)' : 'var(--border-color)'; + btn.style.color = this._rawMode ? 'var(--accent-color)' : 'var(--text-muted)'; + } + if (this._selectedFile) { + const content = this.terminal.fs.readFile(this._selectedFile); + this._renderPreview(this._selectedFile, content); + } + } +} + +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/ext/filemgr.js'); +} diff --git a/modules/ext/finder.js b/modules/ext/finder.js new file mode 100644 index 0000000..1ca8cf7 --- /dev/null +++ b/modules/ext/finder.js @@ -0,0 +1,47 @@ +class FinderModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'find': 'Recursively search VFS files/directories by name (usage: find <query>)', + }; + } + + registerCommands() { + this.commands['find'] = (args) => this.find(args); + } + find(args) { + if (!args[0]) return this.terminal.print('Usage: find <query> — recursively searches VFS files/dirs matching query', 'warning'); + const query = args.join(' ').toLowerCase(); + const results = []; + this._recurse(this.terminal.fs.root, [], query, results); + if (results.length === 0) return this.terminal.print(`No matches for "${query}"`, 'warning'); + this.terminal.print(`=== Found ${results.length} match(es) for "${query}" ===`, 'info'); + results.forEach(r => { + const icon = r.type === 'dir' ? '📁' : '📄'; + this.terminal.print(` ${icon} ${r.path}`); + }); + } + _recurse(node, pathParts, query, results) { + if (!node || !node.content) return; + for (const [name, child] of Object.entries(node.content)) { + const fullPath = '/' + [...pathParts, name].join('/'); + if (name.toLowerCase().includes(query)) { + results.push({ name, path: fullPath, type: child.type || (child.content ? 'dir' : 'file') }); + } + if (child.content && typeof child.content === 'object') { + this._recurse(child, [...pathParts, name], query, results); + } + } + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new FinderModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/finder.js'); +} diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js new file mode 100644 index 0000000..012bae7 --- /dev/null +++ b/modules/ext/mindmap.js @@ -0,0 +1,217 @@ +class MindmapModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.root = null; + this.colors = ['red', 'green', 'yellow', 'blue', 'cyan', 'magenta', 'white']; + this._colorIdx = 0; + } + getHelpInfo() { + return { 'mindmap': 'mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...] — Create and manage interactive mindmaps' }; + } + _nextColor() { + const c = this.colors[this._colorIdx % this.colors.length]; + this._colorIdx++; + return c; + } + _genId() { + return Math.random().toString(36).slice(2, 6); + } + _findNode(id, node) { + if (node.id === id) return node; + for (const child of (node.children || [])) { + const found = this._findNode(id, child); + if (found) return found; + } + return null; + } + _treeLines(node, prefix, isLast) { + const lines = []; + const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; + const tagStr = (node.tags && node.tags.length) ? ' [' + node.tags.join(', ') + ']' : ''; + const colorTag = node.color ? '[' + node.color + ']' : ''; + lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr); + if (node.children) { + const sorted = node.children; + sorted.forEach((child, i) => { + const subPrefix = prefix + (isLast ? ' ' : '\u2502 '); + this._treeLines(child, subPrefix, i === sorted.length - 1).forEach(l => lines.push(l)); + }); + } + return lines; + } + registerCommands() { + const self = this; + this.commands['mindmap'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'warning'); + const sub = args[0]; + const rest = args.slice(1); + switch (sub) { + case 'new': return this._new(rest); + case 'add': return this._add(rest); + case 'show': return this._show(rest); + case 'export': return this._export(rest); + case 'save': return this._save(rest); + case 'load': return this._load(rest); + case 'list': return this._list(); + case 'delete': return this._delete(rest); + case 'rename': return this._rename(rest); + case 'color': return this._color(rest); + case 'tag': return this._tag(rest); + default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: new, add, show, export, save, load, list, delete, rename, color, tag', 'warning'); + } + }; + } + _parseTags(args) { + const tagIdx = args.indexOf('-t'); + let tags = []; + if (tagIdx >= 0) { + tags = args[tagIdx + 1] ? args[tagIdx + 1].split(',').map(t => t.trim()).filter(Boolean) : []; + args.splice(tagIdx, 2); + } + return { args, tags }; + } + _new(args) { + const { args: clean, tags } = this._parseTags([...args]); + if (!clean.length) return this.terminal.print('Usage: mindmap new <topic> [-t tag1,tag2]', 'warning'); + if (this.root) return this.terminal.print('A mindmap already exists. Use mindmap add to extend it.', 'warning'); + this.root = { + id: this._genId(), + text: clean.join(' '), + color: this._nextColor(), + tags: tags, + children: [] + }; + this.terminal.print('Created root: [' + this.root.color + '] ' + this.root.text + ' (' + this.root.id + ')', 'success'); + } + _add(args) { + const { args: clean, tags } = this._parseTags([...args]); + if (clean.length < 2) return this.terminal.print('Usage: mindmap add <parent-id> <text> [-t tag1,tag2]', 'warning'); + if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning'); + const parentId = clean[0]; + const text = clean.slice(1).join(' '); + const parent = this._findNode(parentId, this.root); + if (!parent) return this.terminal.print('Node not found: ' + parentId, 'error'); + const node = { + id: this._genId(), + text: text, + color: this._nextColor(), + tags: tags, + children: [] + }; + parent.children.push(node); + this.terminal.print('Added [' + node.color + '] ' + text + ' (' + node.id + ')', 'success'); + } + _show(args) { + if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning'); + let node = this.root; + if (args.length && args[0]) { + const found = this._findNode(args[0], this.root); + if (!found) return this.terminal.print('Node not found: ' + args[0], 'error'); + node = found; + } + const lines = this._treeLines(node, '', true); + lines.forEach(l => this.terminal.print(l)); + this.terminal.print('(' + node.text + ' — ' + this._countNodes(node) + ' nodes total)', 'info'); + } + _countNodes(node) { + let c = 1; + for (const child of (node.children || [])) c += this._countNodes(child); + return c; + } + _export(args) { + if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning'); + let node = this.root; + if (args.length && args[0]) { + const found = this._findNode(args[0], this.root); + if (!found) return this.terminal.print('Node not found: ' + args[0], 'error'); + node = found; + } + const md = this._toMarkdown(node, 0); + this.terminal.print(md); + this.terminal.print('(Markdown copied to clipboard)', 'info'); + if (navigator.clipboard) { + navigator.clipboard.writeText(md); + } + } + _toMarkdown(node, depth) { + const prefix = ' '.repeat(depth) + '- '; + const tagStr = (node.tags && node.tags.length) ? ' *[' + node.tags.join(', ') + ']*' : ''; + let out = prefix + '**' + node.text + '**' + tagStr + '\n'; + if (node.children) { + node.children.forEach(c => { out += this._toMarkdown(c, depth + 1); }); + } + return out; + } + _save(args) { + if (!args[0]) return this.terminal.print('Usage: mindmap save <name>', 'warning'); + if (!this.root) return this.terminal.print('No mindmap to save.', 'warning'); + try { + localStorage.setItem('webx11_mindmap_' + args[0], JSON.stringify(this.root)); + this.terminal.print('Mindmap saved as: ' + args[0], 'success'); + } catch (e) { + this.terminal.print('Failed to save: ' + e.message, 'error'); + } + } + _load(args) { + if (!args[0]) return this.terminal.print('Usage: mindmap load <name>', 'warning'); + try { + const data = localStorage.getItem('webx11_mindmap_' + args[0]); + if (!data) return this.terminal.print('No saved mindmap: ' + args[0], 'error'); + this.root = JSON.parse(data); + this.terminal.print('Loaded mindmap: ' + args[0] + ' (' + this._countNodes(this.root) + ' nodes)', 'success'); + } catch (e) { + this.terminal.print('Failed to load: ' + e.message, 'error'); + } + } + _list() { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith('webx11_mindmap_')) keys.push(k.replace('webx11_mindmap_', '')); + } + if (!keys.length) return this.terminal.print('No saved mindmaps.', 'info'); + this.terminal.print('=== Saved Mindmaps ===', 'info'); + keys.forEach(k => this.terminal.print(' ' + k)); + } + _delete(args) { + if (!args[0]) return this.terminal.print('Usage: mindmap delete <name>', 'warning'); + localStorage.removeItem('webx11_mindmap_' + args[0]); + this.terminal.print('Deleted mindmap: ' + args[0], 'success'); + } + _rename(args) { + if (args.length < 2) return this.terminal.print('Usage: mindmap rename <id> <text>', 'warning'); + if (!this.root) return this.terminal.print('No mindmap.', 'warning'); + const id = args[0]; + const text = args.slice(1).join(' '); + const node = this._findNode(id, this.root); + if (!node) return this.terminal.print('Node not found: ' + id, 'error'); + node.text = text; + this.terminal.print('Renamed ' + id + ' to: ' + text, 'success'); + } + _color(args) { + if (args.length < 2) return this.terminal.print('Usage: mindmap color <id> <color>', 'warning'); + if (!this.root) return this.terminal.print('No mindmap.', 'warning'); + const id = args[0]; + const color = args[1].toLowerCase(); + if (!this.colors.includes(color)) return this.terminal.print('Available colors: ' + this.colors.join(', '), 'warning'); + const node = this._findNode(id, this.root); + if (!node) return this.terminal.print('Node not found: ' + id, 'error'); + node.color = color; + this.terminal.print('Set color for ' + id + ' to ' + color, 'success'); + } + _tag(args) { + if (args.length < 2) return this.terminal.print('Usage: mindmap tag <id> <tag>', 'warning'); + if (!this.root) return this.terminal.print('No mindmap.', 'warning'); + const id = args[0]; + const tag = args.slice(1).join(' '); + const node = this._findNode(id, this.root); + if (!node) return this.terminal.print('Node not found: ' + id, 'error'); + if (!node.tags) node.tags = []; + node.tags.push(tag); + this.terminal.print('Added tag "' + tag + '" to ' + id, 'success'); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new MindmapModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/mindmap.js'); +} diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js new file mode 100644 index 0000000..95da385 --- /dev/null +++ b/modules/ext/skill-pack-core.js @@ -0,0 +1,285 @@ +class SkillPackCoreModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._seeded = false; + this._skills = this._buildSkills(); + } + + requirements() { + try { + const fs = this.terminal && this.terminal.fs; + if (fs && fs.readFile('/skills/_seeded')) { + return { met: true, missing: [] }; + } + return { met: false, missing: ['Skills directory not initialized. Will be created on first use.'] }; + } catch (e) { + return { met: true, missing: [] }; + } + } + + getHelpInfo() { + return { + 'skill-pack-core': 'Seed and manage built-in core skills (navigation, memory, search, modules, backup, mindmap, etc.)', + }; + } + + _buildSkills() { + const s = (name, desc, tags, cat, steps) => ({ + name, description: desc, tags, category: cat, + content: `---\nname: ${name}\ndescription: ${desc}\ntags: [${tags.join(', ')}]\ncategory: ${cat}\n---\n## Steps\n${steps}` + }); + + return [ + s('navigating-the-terminal', 'How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem', ['terminal', 'navigation', 'help', 'filesystem'], 'system', +`1. **Get an overview of available commands:** + - Call get_help() to get a full command list sorted by category + - Call get_status() to see filesystem state, loaded modules, Ollama status + - For a specific command, use help <command> or check the help map + +2. **Explore the filesystem:** + - Use list_files(path?) to browse directories (omit path for current dir) + - Use read_file(path) to view file contents + - Use get_current_dir to see your working directory + - Use run_command("ls") or run_command("tree") for visual output + +3. **Understand system state:** + - run_command("mem") — memory usage + - run_command("status") — network, files, modules, Ollama + - run_command("modules") — module manager GUI + - get_module_status() — loaded modules, URLs, local edits + +4. **When user asks a vague question:** + - First call get_status() and get_help() to understand the current state + - Then ask clarifying questions based on what you found`), + + s('memory-and-profile', 'How to use the user profile system, session memory, and pattern tracking for persistent context', ['memory', 'profile', 'session', 'persistence'], 'system', +`1. **User Profile (persists across sessions):** + - user_get(key) — read any profile field (e.g. "preferredModel", "fontSize") + - user_set(key, value) — write to profile, auto-persists to localStorage + - user_list — show all stored profile keys + - user_forget(key) — delete a profile key + - Dot notation supported: user_set knownFacts.name "Alex" + +2. **Session Memory (VFS-based daily logs):** + - session_save(summary) — save a summary of what was accomplished + - session_load(date) — read a specific day's log (format: YYYY-MM-DD) + - session_recent(count) — show last N session summaries + - session_stats — aggregate totals across all sessions + - Recent sessions auto-load into context on first message + +3. **Conversation Patterns:** + - Pattern tracking runs automatically — frequent topics surface as hints + - my-set, my-get, my-list store additional personal data + - AI conversation context persists within a session (use ai-clear to reset) + +4. **When to use:** + - At start: call session_recent(3) to see what was worked on before + - After identifying user preferences: call user_set to store them + - After complex tasks: call session_save with a brief summary`), + + s('web-search-and-fetch', 'How to search the web and fetch URL content using the WebX11 proxy API', ['search', 'fetch', 'web', 'proxy', 'api'], 'research', +`1. **Search the web:** + - Call web_search(query) — uses /api/search which queries SearXNG + DDG fallback + - Results include: title, url, content snippet, source engine + - Always available (same-origin, no CORS issues) + - 5-minute server-side cache for repeated queries + +2. **Fetch URL content:** + - Call web_fetch(url) — uses /api/extract which strips HTML, extracts clean text + - Returns: title, content (up to 4000 chars), word_count, extraction method + - SSRF protection blocks internal network addresses + - 15-minute server-side cache + +3. **Search flags (via terminal search command):** + - search <query> --summarize — adds AI-generated overview via Ollama + - search <query> --fetch — fetches content snippets for top 3 results + +4. **When to use:** + - User asks about current events, facts, research → call web_search + - User says "read this article" or provides a URL → call web_fetch + - For in-depth analysis: search first, then fetch top results`), + + s('module-management', 'How to view, edit, test, reload modules and manage startup configuration', ['modules', 'editing', 'code', 'self-improvement'], 'dev', +`1. **View module source:** + - view_module(name) — fetches original source or reads from _mods/ if edited + - Supports all loaded modules: SystemModule, FileSystemModule, SearXNGModule, etc. + +2. **Edit a module:** + - edit_module(name) — copies source to _mods/<name>.js and opens code editor + - Editor shows the full source in a textarea + - Click "Save & Test" to save and auto-load the edited version + +3. **Test and reload:** + - test_module(name) — loads edited module from _mods/ for live testing + - reload_module(name) — discards edits and reloads from original URL + - list_modified_modules — shows all modules with pending edits + +4. **Check for updates:** + - check_updates — HEAD-requests all loaded module URLs to verify reachability + +5. **Manage startup:** + - startup-modules — opens the Startup Modules UI to select which modules load + - load-local <path> — load a module from VFS or URL without editing + +6. **When to use:** + - User reports a bug in a module → view_module, edit_module, test_module + - User asks "can you add X feature?" → same workflow + - Always save edits before testing. If test fails, the original module stays loaded.`), + + s('system-maintenance', 'How to reset, troubleshoot, and maintain the WebX11 terminal', ['system', 'maintenance', 'reset', 'troubleshoot', 'config'], 'system', +`1. **Factory reset (wipes everything):** + - factory-reset --confirm — clears all webx11_ localStorage keys, resets VFS, reloads + - Use only when the system is in an unrecoverable state + +2. **Soft VFS reset:** + - reset-vfs --confirm — clears only the filesystem, preserves settings/profile/modules + +3. **Config management:** + - config-list — show all config keys and values + - config-set <key> <value> — set a config value + - config-delete <key> — remove a config key + - config-export — dump all config as JSON + - config-reset — restore defaults (use before factory-reset if you want to save data) + +4. **Health checks (run automatically after complex tasks):** + - Check module loading status: loaded vs total + - Check for pending module edits + - Check memory usage (high if >100MB) + - Check skill count (0 skills after 2+ tool calls → suggest create_skill) + +5. **Font size and display:** + - font-size <8-32> — set terminal font size, persists across sessions + - setfont — alias for font-size + +6. **When to use:** + - System is slow or modules fail to load → check check_updates, memory + - User says "reset everything" → factory-reset --confirm + - User says "clear my files but keep settings" → reset-vfs --confirm`), + + s('backup-and-restore', 'How to backup, restore, and export WebX11 data', ['backup', 'restore', 'export', 'import', 'persistence'], 'system', +`1. **Backup VFS:** + - vfs-export — downloads the entire virtual filesystem as a dated JSON file + - backup — creates a full backup including VFS, modules, config (saves to _backups/) + +2. **Export/Import personal data:** + - my-export — prints all my-set data as formatted JSON + - my-import <json> — bulk-import personal data from JSON string + +3. **Export/Import modules:** + - export-mods — exports module registry as JSON + - import-mods <json> — imports modules from JSON backup + +4. **Restore:** + - restore <json> — restores terminal state from a backup JSON + - Session logs are saved to /memory/ automatically on page unload + +5. **When to use:** + - Before factory-reset: call my-export, vfs-export, export-mods to save data + - When switching browsers or devices: export all, then import + - User says "backup my data" or "save my files"`), + + s('mindmap-workflow', 'How to create, view, edit, and manage brainstorming mindmaps', ['mindmap', 'brainstorming', 'planning', 'organization'], 'productivity', +`1. **Create a new mindmap:** + - mindmap new "<topic>" -t tag1,tag2 — creates a root node + - Tags are optional (e.g. planning, research, dev) + - Root gets auto-assigned a color from 7-color palette + +2. **Add child nodes:** + - mindmap add <parent-id> "<idea>" -t tag — add a child + - Get the parent-id from mindmap show + - IDs are 4-char random strings (e.g. a3f8, x7k2) + +3. **View:** + - mindmap show — full tree with Unicode box-drawing + - mindmap show <id> — subtree starting at that node + +4. **Edit nodes:** + - mindmap rename <id> "<new text>" — change text + - mindmap color <id> <color> — set color (red/green/yellow/blue/cyan/magenta/white) + - mindmap tag <id> <tag> — add a tag + +5. **Persistence:** + - mindmap save <name> — save to localStorage + - mindmap load <name> — restore + - mindmap list — show saved mindmaps + - mindmap delete <name> — remove saved + +6. **Export:** + - mindmap export [id] — Markdown bullet list output, auto-copies to clipboard + +7. **When to use:** + - Map out project structure, brainstorm features, organize ideas + - Break down complex tasks into subtrees + - User says "brainstorm", "diagram", "organize thoughts", "plan"`), + + s('skills-system-guide', 'How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory', ['skills', 'system', 'meta'], 'system', +`1. **Always load skills at task start:** + - At the START of every task, call load_skills(query) to check for stored procedures + - Example: load_skills("search the web") returns matching skill content + - Example: load_skills("edit module") returns the module editing workflow + +2. **Create skills after complex tasks:** + - After any task with 3+ tool calls, call create_skill to save the approach + - Required fields: name (kebab-case), description, content (markdown with YAML frontmatter) + - Optional: tags (comma-separated), category (e.g. research, system, dev) + - Skills persist in /skills/ as .md files with /skills/index.json for fast lookup + +3. **Patch skills when they're outdated:** + - If a stored procedure has wrong steps, call patch_skill(name, newContent) + - This replaces the entire skill content and updates the index + - Do NOT create a duplicate — always patch the existing skill + +4. **Auto-created error skills:** + - If the same tool fails 3+ times, a workaround skill is created automatically + - Named tool-<name>-workaround (e.g. tool-write_file-workaround) + +5. **Audit and maintenance:** + - list_skills(category?) — browse available skills, optionally filter + - Skills are audited every 20 messages for staleness (empty content, >30 days old) + +6. **YAML frontmatter format:** + - Each skill file must start with: ---\\nname: <name>\\ndescription: <text>\\ntags: [...]\\ncategory: <cat>\\n---\\n + - Steps follow under ## Steps heading + - Use numbered lists for sequential steps`), + ]; + } + registerCommands() { + this.commands['skill-pack-core'] = () => { + const count = this._skills.length; + this.terminal.print(`Skill Pack: Core — ${count} skills available`, 'info'); + this.terminal.print('Skills will auto-seed on load', 'info'); + }; + } + init() { + this.seed(); + } + async seed() { + console.log('[SkillPackCore] seed() called, _seeded=' + this._seeded); + if (this._seeded) return; + const fs = this.terminal.fs; + if (!fs) { console.warn('[SkillPackCore] this.terminal.fs not available'); return; } + fs.mkdirRecursive('/skills'); + let index = fs.fsReadJSON('/skills/index.json') || {}; + console.log('[SkillPackCore] existing index keys:', Object.keys(index)); + let seeded = 0; + for (const skill of this._skills) { + if (!index[skill.name]) { + fs.fsWrite(`/skills/${skill.name}.md`, skill.content); + index[skill.name] = { + name: skill.name, + description: skill.description, + tags: skill.tags, + category: skill.category, + created: new Date().toISOString(), + updated: new Date().toISOString() + }; + seeded++; + } + } + if (seeded > 0) { + fs.fsWriteJSON('/skills/index.json', index); + } + this._seeded = true; + } +} diff --git a/modules/ext/theme.js b/modules/ext/theme.js new file mode 100644 index 0000000..9a1dab5 --- /dev/null +++ b/modules/ext/theme.js @@ -0,0 +1,172 @@ +class ThemeModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.key = 'webx11_themes'; + this._previewTimer = null; + this.defaults = { + 'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' }, + 'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' }, + 'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' }, + 'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' }, + 'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' }, + 'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' }, + 'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' }, + 'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' }, + 'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' } + }; + this.themes = this.load(); + } + requirements() { + return { met: true, missing: [] }; + } + getHelpInfo() { + return { 'theme': 'theme <set|list|new|delete|export|preview|keep> [...] — Manage and preview visual themes' }; + } + load() { + try { + const data = localStorage.getItem(this.key); + return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults }; + } catch (e) { return { ...this.defaults }; } + } + save() { + const custom = {}; + Object.keys(this.themes).forEach(k => { + if (!this.defaults[k]) custom[k] = this.themes[k]; + }); + localStorage.setItem(this.key, JSON.stringify(custom)); + } + registerCommands() { + this.commands['theme'] = (args) => this.handle(args); + } + handle(args) { + if (!args[0]) return this.terminal.print('Usage: theme set <name>, theme list, theme new, theme delete <name>, theme export <name>, theme preview <name>, theme keep', 'warning'); + const sub = args[0].toLowerCase(); + if (sub === 'set') this.setTheme(args[1]); + else if (sub === 'list') this.listThemes(); + else if (sub === 'new') this.newTheme(args.slice(1)); + else if (sub === 'delete') this.deleteTheme(args[1]); + else if (sub === 'export') this.exportTheme(args[1]); + else if (sub === 'preview') this.previewTheme(args[1]); + else if (sub === 'keep') this.keepPreview(); + else this.terminal.print(`Unknown: ${sub}`, 'error'); + } + _applyVars(name) { + if (!this.themes[name]) return false; + const vars = this.themes[name]; + const root = document.documentElement; + Object.entries(vars).forEach(([key, val]) => root.style.setProperty(key, val)); + return true; + } + setTheme(name) { + if (!name) return this.terminal.print('Usage: theme set <name>', 'warning'); + if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error'); + // Cancel any active preview + if (this._previewTimer) { + clearTimeout(this._previewTimer); + this._previewTimer = null; + } + this._applyVars(name); + localStorage.setItem('webx11_active_theme', name); + this.terminal.print(`Theme set to: ${name}`, 'success'); + } + previewTheme(name) { + if (!name) return this.terminal.print('Usage: theme preview <name>', 'warning'); + if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error'); + // Cancel any existing preview + if (this._previewTimer) { + clearTimeout(this._previewTimer); + this._previewTimer = null; + } + const previous = localStorage.getItem('webx11_active_theme') || 'dark'; + this._applyVars(name); + this.terminal.print(`🎨 Previewing: ${name} — will revert in 5s`, 'info'); + this.terminal.print(' Type "theme keep" to keep this theme permanently', 'info'); + this._previewTimer = setTimeout(() => { + this._previewTimer = null; + this._applyVars(previous); + this.terminal.print(`⏱ Preview ended — reverted to: ${previous}`, 'info'); + }, 5000); + } + keepPreview() { + if (!this._previewTimer) return this.terminal.print('No active preview to keep', 'warning'); + clearTimeout(this._previewTimer); + this._previewTimer = null; + const root = document.documentElement; + const cssVars = ['--bg-color', '--bg-secondary', '--bg-tertiary', '--text-color', '--text-muted', '--accent-color', '--border-color']; + const currentVars = {}; + cssVars.forEach(v => { currentVars[v] = getComputedStyle(root).getPropertyValue(v).trim() || '#000'; }); + let matchName = null; + for (const [name, vars] of Object.entries(this.themes)) { + const match = cssVars.every(v => vars[v] === currentVars[v]); + if (match) { matchName = name; break; } + } + if (matchName) { + localStorage.setItem('webx11_active_theme', matchName); + this.terminal.print(`✅ Preview kept — theme saved as: ${matchName}`, 'success'); + } else { + // Custom vars — save current state as active + const active = localStorage.getItem('webx11_active_theme') || 'dark'; + this.terminal.print(`✅ Preview kept (${active} theme)`, 'success'); + } + } + listThemes() { + const active = localStorage.getItem('webx11_active_theme') || 'dark'; + this.terminal.print('=== Themes ===', 'info'); + Object.keys(this.themes).forEach(name => { + const mark = name === active ? ' ✅' : ''; + this.terminal.print(` ${name}${mark}`); + }); + this.terminal.print('Use: theme set <name> to apply', 'info'); + } + newTheme(args) { + if (args.length < 1) return this.terminal.print('Usage: theme new <name> --bg #000 --text #0f0', 'warning'); + const name = args[0].toLowerCase(); + if (this.themes[name]) return this.terminal.print(`Theme '${name}' already exists`, 'warning'); + const vars = {}; + const root = document.documentElement; + const cssVars = ['--bg-color', '--bg-secondary', '--bg-tertiary', '--text-color', '--text-muted', '--accent-color', '--border-color', '--error-color', '--warning-color', '--info-color', '--success-color']; + for (let i = 1; i < args.length; i++) { + if (args[i].startsWith('--')) { + const val = args[i + 1]; + if (val && !val.startsWith('--')) { + vars[args[i]] = val; + i++; + } + } + } + if (Object.keys(vars).length === 0) { + const preview = {}; + cssVars.forEach(v => { preview[v] = getComputedStyle(root).getPropertyValue(v).trim() || '#000'; }); + this.themes[name] = preview; + } else { + this.themes[name] = vars; + } + this.save(); + this.terminal.print(`Theme '${name}' created with ${Object.keys(this.themes[name]).length} variables`, 'success'); + } + deleteTheme(name) { + if (!name) return this.terminal.print('Usage: theme delete <name>', 'warning'); + if (this.defaults[name]) return this.terminal.print('Cannot delete built-in theme', 'warning'); + if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error'); + delete this.themes[name]; + this.save(); + const active = localStorage.getItem('webx11_active_theme'); + if (active === name) { localStorage.removeItem('webx11_active_theme'); } + this.terminal.print(`Theme '${name}' deleted`, 'success'); + } + exportTheme(name) { + if (!name) return this.terminal.print('Usage: theme export <name>', 'warning'); + if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error'); + const data = JSON.stringify(this.themes[name], null, 2); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `theme-${name}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print(`Theme '${name}' exported`, 'success'); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new ThemeModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/theme.js'); +} diff --git a/modules/ext/voice.js b/modules/ext/voice.js new file mode 100644 index 0000000..0fd96cd --- /dev/null +++ b/modules/ext/voice.js @@ -0,0 +1,102 @@ +class VoiceModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.recognition = null; + this.listening = false; + this._lastError = ''; + } + requirements() { + if (typeof SpeechRecognition !== 'undefined' || typeof webkitSpeechRecognition !== 'undefined') { + return { met: true, missing: [] }; + } + return { met: false, missing: ['SpeechRecognition not available in this browser'] }; + } + getHelpInfo() { + return { 'voice': 'voice [stop] — Start voice recognition, or type "voice stop" to cancel' }; + } + registerCommands() { + this.commands['voice'] = (args) => this.handle(args); + } + handle(args) { + const sub = args[0] && args[0].toLowerCase(); + if (sub === 'stop') return this.stopListening(); + if (this.listening) return this.terminal.print('Already listening. Say something or type "voice stop"', 'warning'); + this.startListening(); + } + _updateButton(listening, error) { + const btn = document.getElementById('voice-toggle-btn'); + if (!btn) return; + if (listening) { + btn.style.borderColor = 'var(--success-color)'; + btn.style.color = 'var(--success-color)'; + btn.style.animation = 'pulse 1.5s ease-in-out infinite'; + btn.textContent = '🔴 Listening'; + } else { + btn.style.borderColor = ''; + btn.style.color = ''; + btn.style.animation = ''; + if (error) { + btn.style.borderColor = 'var(--error-color)'; + btn.style.color = 'var(--error-color)'; + } else { + btn.style.borderColor = ''; + btn.style.color = ''; + } + btn.textContent = '🎤 Mic'; + } + } + startListening() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + this._updateButton(false, true); + return this.terminal.print('Speech recognition not supported in this browser', 'error'); + } + this.recognition = new SpeechRecognition(); + this.recognition.lang = 'en-US'; + this.recognition.interimResults = false; + this.recognition.maxAlternatives = 1; + this.recognition.continuous = false; + this.listening = true; + this._updateButton(true); + this.terminal.print('🎤 Listening... (say a command, or tap mic button to cancel)', 'info'); + this.recognition.onresult = (event) => { + const transcript = event.results[0][0].transcript; + this.terminal.print(`🎤 Recognized: "${transcript}"`, 'success'); + const input = this.terminal.inputField; + input.value = transcript; + input.focus(); + this.listening = false; + this._updateButton(false); + }; + this.recognition.onerror = (event) => { + this.terminal.print(`🎤 Error: ${event.error}`, 'error'); + this.listening = false; + this._updateButton(false, true); + }; + this.recognition.onend = () => { + if (this.listening) { + this.listening = false; + this._updateButton(false); + this.terminal.print('🎤 Voice input ended', 'info'); + } + }; + try { this.recognition.start(); } + catch (e) { + this.terminal.print(`🎤 Failed to start: ${e.message}`, 'error'); + this.listening = false; + this._updateButton(false, true); + } + } + stopListening() { + if (this.recognition && this.listening) { + this.recognition.stop(); + this.listening = false; + this._updateButton(false); + this.terminal.print('🎤 Listening stopped', 'info'); + } + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new VoiceModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/voice.js'); +} diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js new file mode 100644 index 0000000..63dfb05 --- /dev/null +++ b/modules/ext/webdav.js @@ -0,0 +1,532 @@ +class WebDAVModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.config = null; // { url, username, password } + this._browsingPath = '/'; + this._STORAGE_KEY = 'webx11_webdav_config'; + // Auto-discover space name from page URL + this._spaceName = this._detectSpaceName(); + this._autoConfigured = false; + this._loadConfig(); + } + + _detectSpaceName() { + try { + const path = window.location.pathname; + // /spaces/chatstudio/newos4/ → chatstudio + const parts = path.split('/').filter(p => p); + const spacesIdx = parts.indexOf('spaces'); + if (spacesIdx >= 0 && spacesIdx + 1 < parts.length) { + return parts[spacesIdx + 1]; + } + } catch (e) {} + return ''; + } + + requirements() { + const cfg = localStorage.getItem('webx11_webdav_config'); + if (!cfg) return { met: false, missing: ['WebDAV endpoint not configured. Use webdav-config to set it up.'] }; + try { + const parsed = JSON.parse(cfg); + if (!parsed.url) return { met: false, missing: ['WebDAV URL not set'] }; + return { met: true, missing: [] }; + } catch (e) { + return { met: false, missing: ['WebDAV config corrupted'] }; + } + } + + getHelpInfo() { + return { + 'webdav-config': 'webdav-config [url user pass] — Configure WebDAV. No args = auto-detect from current page URL', + 'webdav-status': 'Show current WebDAV configuration status', + 'webdav-ls': 'List files/directories on the WebDAV remote', + 'webdav-push': 'Upload a VFS file to WebDAV', + 'webdav-pull': 'Download a WebDAV file to VFS', + 'webdav-backup': 'Backup entire VFS to WebDAV as a dated JSON snapshot', + 'webdav-restore': 'Restore VFS from a WebDAV backup file', + 'webdav-backup-list': 'List available backup files on WebDAV', + 'webdav-explorer': 'webdav-explorer [path] — Interactive remote file browser', + }; + } + + _loadConfig() { + try { + const saved = localStorage.getItem(this._STORAGE_KEY); + if (saved) { + this.config = JSON.parse(saved); + // Try to get password from SecretVault + if (window.webx11_secretVault && this.config && this.config.username) { + const encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + this.config.username); + if (encryptedPass !== null && encryptedPass !== undefined) { + this.config.password = encryptedPass; + } + } + } + } catch (e) { /* ignore */ } + } + + _saveConfig() { + try { + if (this.config) { + const toStore = { + url: this.config.url, + username: this.config.username + }; + // Try SecretVault for encrypted password storage + if (window.webx11_secretVault && this.config.password) { + window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password); + // Also store space token + if (this._spaceName) { + window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password); + } + } + // Also store in localStorage for backward compat (SecretVault decrypts later) + // Store only url and username hint in plaintext lS, encrypted password handled above + localStorage.setItem(this._STORAGE_KEY, JSON.stringify(toStore)); + } else { + localStorage.removeItem(this._STORAGE_KEY); + } + } catch (e) { console.error('WebDAV config save error:', e); } + } + + _getAuthHeader() { + if (!this.config || !this.config.password) return {}; + const token = btoa(this.config.username + ':' + this.config.password); + return { 'Authorization': 'Basic ' + token }; + } + + async _webdavRequest(method, path, options = {}) { + if (!this.config || !this.config.url) { + throw new Error('WebDAV not configured. Use webdav-config first.'); + } + const baseUrl = this.config.url.replace(/\/+$/, ''); + const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + const url = baseUrl + urlPath; + + const headers = { + ...this._getAuthHeader(), + ...(options.headers || {}) + }; + + const fetchOpts = { + method: method, + headers: headers, + body: options.body || undefined + }; + + const response = await fetch(url, fetchOpts); + return response; + } + + async _listDir(path) { + // Try PROPFIND first (works with WebDAV servers), fallback to nginx autoindex + try { + const response = await this._webdavRequest('PROPFIND', path, { + headers: { 'Depth': '1' } + }); + if (response.ok) { + const text = await response.text(); + return this._parsePropfind(text); + } + } catch (e) { /* PROPFIND failed, try autoindex */ } + // Fallback: use nginx __list__ endpoint + return this._listDirViaAutoIndex(path); + } + + async _listDirViaAutoIndex(path) { + const baseUrl = this.config.url.replace(/\/+$/, ''); + const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + // Use __list__ for directory listing + const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, ''); + try { + const resp = await fetch(listUrl, { method: 'GET' }); + if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status); + const html = await resp.text(); + return this._parseAutoindex(html); + } catch (e) { + throw new Error('Cannot list directory: ' + e.message); + } + } + + _parseAutoindex(html) { + const entries = []; + // Parse nginx autoindex HTML format + const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; + let match; + while ((match = linkRegex.exec(html)) !== null) { + const href = match[1]; + const name = match[2].replace(/\/$/, ''); + const sizeStr = match[4]; + if (name === '..' || name === '../') continue; + if (name === '__list__') continue; + const isDir = href.endsWith('/'); + let size = 0; + if (!isDir && sizeStr !== '-') { + size = parseInt(sizeStr) || 0; + } + entries.push({ + name: name, + type: isDir ? 'dir' : 'file', + size: size, + href: href + }); + } + // Fallback: if regex failed, try simpler parse + if (entries.length === 0) { + const simpleRegex = /<a href="([^"]+)">/g; + const names = []; + while ((match = simpleRegex.exec(html)) !== null) { + const href = match[1]; + if (href === '../' || href === '/' || href === './') continue; + if (href.startsWith('?')) continue; + names.push(href); + } + for (const href of names) { + const isDir = href.endsWith('/'); + entries.push({ + name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), + type: isDir ? 'dir' : 'file', + size: 0, + href: href + }); + } + } + return entries; + } + + _parsePropfind(xml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'text/xml'); + const responses = doc.querySelectorAll('D\\:response, response'); + const entries = []; + responses.forEach((resp) => { + const href = (resp.querySelector('D\\:href, href') || {}).textContent || ''; + const isDir = !!((resp.querySelector('D\\:collection, collection') || {}).textContent); + const getContentLength = (resp.querySelector('D\\:getcontentlength, getcontentlength, lp1\\:getcontentlength') || {}).textContent; + const size = getContentLength ? parseInt(getContentLength) : 0; + const displayName = href.split('/').filter(Boolean).pop() || '/'; + if (displayName) { + entries.push({ + name: decodeURIComponent(displayName), + type: isDir ? 'dir' : 'file', + size: size, + href: href + }); + } + }); + // Remove the root entry (same as first child's dir) + return entries.filter((e, i) => i > 0 || e.type !== 'dir' || entries.length <= 1); + } + + async _ensureDir(remotePath) { + // Try MKCOL — ignore 405 (already exists) and 409 (parent missing) + const response = await this._webdavRequest('MKCOL', remotePath); + if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true; + if (response.status === 409) { + // Parent missing — try creating parent recursively + const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); + if (parts.length > 1) { + const parent = parts.slice(0, -1).join('/'); + await this._ensureDir(parent); + // Retry MKCOL + const retry = await this._webdavRequest('MKCOL', remotePath); + if (retry.ok || retry.status === 405) return true; + } + // Try creating path component by component as a fallback + let current = ''; + for (const part of parts) { + current += '/' + part; + try { + const attempt = await this._webdavRequest('MKCOL', current); + if (!attempt.ok && attempt.status !== 405) { + // Ignore — it likely exists already or we can't create it + } + } catch (e) { /* ignore */ } + } + return true; + } + return false; + } + + registerCommands() { + const self = this; + + // --- webdav-config: set WebDAV endpoint and credentials --- + this.commands['webdav-config'] = async (args) => { + // No args: auto-detect space name and try to configure from token + if (!args || args.length === 0) { + const spaceName = self._spaceName; + if (!spaceName) { + self.terminal.print('Could not detect space name from URL.', 'warning'); + self.terminal.print('Usage: webdav-config <url> [username] [password]', 'info'); + return; + } + + // Auto-configure URL from space name + const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/'; + let token = null; + + // Check SecretVault first + if (window.webx11_secretVault) { + token = window.webx11_secretVault.decrypt('webdav_token_' + spaceName); + } + + // Fallback to localStorage + if (!token) { + token = localStorage.getItem('webx11_spaces_' + spaceName); + } + + if (token) { + self.config = { + url: autoUrl, + username: spaceName, + password: token + }; + self._saveConfig(); + self._autoConfigured = true; + self.terminal.print('Auto-configured WebDAV for space: ' + spaceName, 'success'); + try { + const resp = await self._webdavRequest('OPTIONS', '/'); + self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); + self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); + } catch (e) { + self.terminal.print('Connection test failed: ' + e.message, 'warning'); + self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); + } + } else { + self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning'); + self.terminal.print('Get your token from /spaces/' + spaceName + '/.token or the dashboard.', 'info'); + self.terminal.print('Auto-setting URL: ' + autoUrl, 'info'); + self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'info'); + // At minimum set the URL + self.config = { + url: autoUrl, + username: spaceName, + password: '' + }; + self._saveConfig(); + self._autoConfigured = true; + } + return; + } + + // Legacy 1-3 arg behavior + self.config = { + url: args[0], + username: args[1] || (self.config ? self.config.username : ''), + password: args[2] || (self.config ? self.config.password : '') + }; + self._saveConfig(); + self.terminal.print('Testing WebDAV connection...', 'info'); + try { + const resp = await self._webdavRequest('OPTIONS', '/'); + self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); + self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); + } catch (e) { + self.terminal.print('Connection test failed: ' + e.message, 'warning'); + self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); + } + }; + + // --- webdav-status: show current WebDAV config --- + this.commands['webdav-status'] = () => { + if (!self.config || !self.config.url) { + self.terminal.print('WebDAV not configured. Use webdav-config <url> [username] [password]', 'warning'); + return; + } + self.terminal.print('WebDAV Status:', 'text-muted'); + self.terminal.print(' URL: ' + self.config.url); + self.terminal.print(' User: ' + (self.config.username || '(none)')); + self.terminal.print(' Password: ' + (self.config.password ? '**** (' + self.config.password.length + ' chars)' : '(none)')); + self.terminal.print(' Space detected: ' + (self._spaceName || '(none)')); + if (self._autoConfigured) { + self.terminal.print(' Auto-configured: yes', 'success'); + } + }; + + // --- webdav-ls: list remote directory --- + this.commands['webdav-ls'] = async (args) => { + const remotePath = args[0] || '/'; + try { + const entries = await self._listDir(remotePath); + if (entries.length === 0) { + self.terminal.print('(empty)', 'info'); + return; + } + entries.forEach(e => { + const icon = e.type === 'dir' ? '📁' : '📄'; + const sizeStr = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/'; + self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + (e.type === 'file' ? sizeStr : '')); + }); + self.terminal.print(entries.length + ' item(s)', 'text-muted'); + } catch (e) { + self.terminal.print('Failed to list: ' + e.message, 'error'); + } + }; + + // --- webdav-push: upload a VFS file to WebDAV --- + this.commands['webdav-push'] = async (args) => { + if (!args[0]) { + self.terminal.print('Usage: webdav-push <vfs-path> [remote-path]', 'warning'); + return; + } + const vfsPath = args[0]; + const remotePath = args[1] || vfsPath; + const content = self.terminal.fs.readFile(vfsPath); + if (content === null) { + self.terminal.print('File not found in VFS: ' + vfsPath, 'error'); + return; + } + try { + // Ensure remote dir exists + const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); + if (parts.length > 1) { + const parent = parts.slice(0, -1).join('/'); + await self._ensureDir(parent); + } + const resp = await self._webdavRequest('PUT', remotePath, { + body: content, + headers: { 'Content-Type': 'text/plain' } + }); + if (resp.ok || resp.status === 201 || resp.status === 204) { + self.terminal.print('Pushed: ' + vfsPath + ' → ' + remotePath, 'success'); + } else { + self.terminal.print('Push failed: HTTP ' + resp.status, 'error'); + } + } catch (e) { + self.terminal.print('Push error: ' + e.message, 'error'); + } + }; + + // --- webdav-pull: download a WebDAV file to VFS --- + this.commands['webdav-pull'] = async (args) => { + if (!args[0]) { + self.terminal.print('Usage: webdav-pull <remote-path> [vfs-path]', 'warning'); + return; + } + const remotePath = args[0]; + const vfsPath = args[1] || remotePath.split('/').pop(); + try { + const resp = await self._webdavRequest('GET', remotePath); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const content = await resp.text(); + self.terminal.fs.writeFile(vfsPath, content); + self.terminal.print('Pulled: ' + remotePath + ' → ' + vfsPath + ' (' + content.length + ' bytes)', 'success'); + } catch (e) { + self.terminal.print('Pull error: ' + e.message, 'error'); + } + }; + + // --- webdav-backup: backup entire VFS to WebDAV --- + this.commands['webdav-backup'] = async (args) => { + const name = (args[0] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_'); + const now = new Date(); + const dateStr = now.getFullYear() + '-' + + String(now.getMonth() + 1).padStart(2, '0') + '-' + + String(now.getDate()).padStart(2, '0') + '_' + + String(now.getHours()).padStart(2, '0') + + String(now.getMinutes()).padStart(2, '0') + + String(now.getSeconds()).padStart(2, '0'); + const filename = name + '-' + dateStr + '.json'; + const remotePath = 'backups/' + filename; + + try { + await self._ensureDir('backups'); + const serialized = JSON.stringify(self.terminal.fs.root, null, 2); + const resp = await self._webdavRequest('PUT', remotePath, { + body: serialized, + headers: { 'Content-Type': 'application/json' } + }); + if (resp.ok || resp.status === 201 || resp.status === 204) { + self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success'); + } else { + self.terminal.print('Backup failed: HTTP ' + resp.status, 'error'); + } + } catch (e) { + self.terminal.print('Backup error: ' + e.message, 'error'); + } + }; + + // --- webdav-restore: restore VFS from a WebDAV backup --- + this.commands['webdav-restore'] = async (args) => { + if (!args[0]) { + self.terminal.print('Usage: webdav-restore <backup-filename>', 'warning'); + self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info'); + return; + } + let remotePath = args[0]; + if (!remotePath.startsWith('backups/') && !remotePath.startsWith('/backups/')) { + remotePath = 'backups/' + remotePath; + } + try { + const resp = await self._webdavRequest('GET', remotePath); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const content = await resp.text(); + const data = JSON.parse(content); + if (!data || !data.type) { + self.terminal.print('Invalid backup file (not a valid VFS export)', 'error'); + return; + } + if (self.terminal.fs.restoreFromExport(data)) { + self.terminal.updatePrompt(); + self.terminal.print('VFS restored from: ' + remotePath, 'success'); + self.terminal.executeCommand('ls'); + } else { + self.terminal.print('Failed to restore VFS from backup', 'error'); + } + } catch (e) { + self.terminal.print('Restore error: ' + e.message, 'error'); + } + }; + + // --- webdav-backup-list: list available backups --- + this.commands['webdav-backup-list'] = async () => { + try { + const entries = await self._listDir('backups'); + if (entries.length === 0) { + self.terminal.print('No backups found', 'info'); + return; + } + self.terminal.print('Available backups:', 'text-muted'); + entries.forEach(e => { + if (e.type === 'file') { + self.terminal.print(' ' + e.name + ' (' + self._formatSize(e.size) + ')'); + } + }); + } catch (e) { + self.terminal.print('Failed to list backups: ' + e.message, 'info'); + self.terminal.print(' (backups directory may not exist yet — run webdav-backup first)', 'info'); + } + }; + + // --- webdav-explorer: interactive file browser via WebDAV --- + this.commands['webdav-explorer'] = async (args) => { + const startPath = args[0] || '/'; + try { + const entries = await self._listDir(startPath); + self.terminal.print('📁 WebDAV: ' + startPath, 'info'); + if (entries.length === 0) { + self.terminal.print('(empty)', 'info'); + return; + } + entries.forEach(e => { + const icon = e.type === 'dir' ? '📁' : '📄'; + const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/'; + self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size); + }); + self.terminal.print('---'); + self.terminal.print('↳ Navigate: webdav-explorer <dir> | Pull: webdav-pull <file>', 'text-muted'); + self.terminal.print(entries.length + ' item(s)', 'text-muted'); + } catch (e) { + self.terminal.print('WebDAV error: ' + e.message, 'error'); + } + }; + } + + _formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } +} diff --git a/modules/ext/wpm.js b/modules/ext/wpm.js new file mode 100644 index 0000000..9c8d820 --- /dev/null +++ b/modules/ext/wpm.js @@ -0,0 +1,149 @@ +class WpmModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.indexUrl = 'https://webx11.duckdns.org/spaces/chatstudio/newos4/packages.json'; + this.index = null; + this.installed = this.loadInstalled(); + } + getHelpInfo() { + return { 'wpm': 'wpm <install|list|search|update|remove|info> [...] — Web Package Manager for installing and managing modules' }; + } + loadInstalled() { + try { + const data = localStorage.getItem('webx11_wpm_installed'); + return data ? JSON.parse(data) : {}; + } catch (e) { return {}; } + } + saveInstalled() { + localStorage.setItem('webx11_wpm_installed', JSON.stringify(this.installed)); + } + registerCommands() { + this.commands['wpm'] = (args) => this.handle(args); + } + handle(args) { + if (!args[0]) return this.terminal.print('Usage: wpm install <pkg>, wpm list, wpm search <kw>, wpm update, wpm remove <pkg>, wpm info <pkg>', 'warning'); + const sub = args[0].toLowerCase(); + if (sub === 'install') this.install(args[1]); + else if (sub === 'list') this.list(); + else if (sub === 'search') this.search(args.slice(1).join(' ')); + else if (sub === 'update') this.update(); + else if (sub === 'remove') this.remove(args[1]); + else if (sub === 'info') this.info(args[1]); + else this.terminal.print(`Unknown: ${sub}`, 'error'); + } + async loadIndex() { + if (this.index) return this.index; + try { + this.terminal.print('Fetching package index...', 'info'); + const res = await fetch(this.indexUrl); + if (!res.ok) throw new Error('HTTP ' + res.status); + this.index = await res.json(); + return this.index; + } catch (e) { + this.terminal.print(`Failed to load index: ${e.message}`, 'error'); + return null; + } + } + async install(name) { + if (!name) return this.terminal.print('Usage: wpm install <package>', 'warning'); + const idx = await this.loadIndex(); + if (!idx) return; + const pkg = idx.packages[name]; + if (!pkg) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error'); + if (this.installed[name]) return this.terminal.print(`Package "${name}" already installed. Use wpm update to reinstall.`, 'warning'); + this.terminal.print(`Installing ${pkg.name} v${pkg.version}...`, 'info'); + this.terminal.print(` ${pkg.desc}`, 'info'); + const success = await this.terminal.moduleManager.loadModuleFromURL(pkg.url); + if (success) { + this.installed[name] = { version: pkg.version, installed: new Date().toISOString() }; + this.saveInstalled(); + this.terminal.print(`"${name}" installed successfully!`, 'success'); + } + } + async list() { + const idx = await this.loadIndex(); + if (!idx) return; + const names = Object.keys(this.installed); + this.terminal.print('=== Installed Packages ===', 'info'); + if (names.length === 0) { + this.terminal.print(' None. Use: wpm install <pkg>', 'info'); + } else { + names.forEach(name => { + const inst = this.installed[name]; + this.terminal.print(` ${name} v${inst.version} (${inst.installed.split('T')[0]})`); + }); + } + this.terminal.print('\n=== Available Packages ===', 'info'); + Object.keys(idx.packages).sort().forEach(name => { + const pkg = idx.packages[name]; + const installed = this.installed[name] ? ' ✅' : ''; + this.terminal.print(` ${name} - ${pkg.desc}${installed}`); + }); + } + async search(keyword) { + if (!keyword) return this.terminal.print('Usage: wpm search <keyword>', 'warning'); + const idx = await this.loadIndex(); + if (!idx) return; + const kw = keyword.toLowerCase(); + const results = []; + Object.entries(idx.packages).forEach(([name, pkg]) => { + if (name.includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) { + results.push({ name, ...pkg }); + } + }); + if (results.length === 0) return this.terminal.print(`No packages matching "${keyword}"`, 'info'); + this.terminal.print(`=== Search: "${keyword}" (${results.length} results) ===`, 'info'); + results.forEach(r => { + const installed = this.installed[r.name] ? ' [installed]' : ''; + this.terminal.print(` ${r.name} [${r.category}] - ${r.desc}${installed}`); + }); + this.terminal.print('Use: wpm install <name> to install', 'info'); + } + async update() { + this.index = null; + await this.loadIndex(); + if (!this.index) return; + this.terminal.print('Index refreshed.', 'success'); + const outdated = []; + Object.keys(this.installed).forEach(name => { + const pkg = this.index.packages[name]; + if (pkg && pkg.version !== this.installed[name].version) { + outdated.push(name); + } + }); + if (outdated.length > 0) { + this.terminal.print(`Outdated packages: ${outdated.join(', ')}. Reinstall with "wpm install <name>"`, 'warning'); + } + } + async remove(name) { + if (!name) return this.terminal.print('Usage: wpm remove <package>', 'warning'); + if (!this.installed[name]) return this.terminal.print(`Package "${name}" is not installed via wpm`, 'warning'); + const idx = await this.loadIndex(); + const className = idx && idx.packages[name] ? idx.packages[name].name.replace(/\s+/g, '') + 'Module' : null; + if (className && this.terminal.moduleManager.unregisterModule(className)) { + this.terminal.print(`Module ${className} unregistered`, 'info'); + } + delete this.installed[name]; + this.saveInstalled(); + this.terminal.print(`Package "${name}" removed. If module persists, use: removemod <ClassName>`, 'success'); + } + async info(name) { + if (!name) return this.terminal.print('Usage: wpm info <package>', 'warning'); + const idx = await this.loadIndex(); + if (!idx) return; + const pkg = idx.packages[name]; + if (!pkg) return this.terminal.print(`Package "${name}" not found`, 'error'); + this.terminal.print(`=== ${pkg.name} v${pkg.version} ===`, 'info'); + this.terminal.print(`Description: ${pkg.desc}`); + this.terminal.print(`Category: ${pkg.category}`); + this.terminal.print(`Commands: ${pkg.commands.join(', ')}`); + this.terminal.print(`URL: ${pkg.url}`); + const installed = this.installed[name]; + if (installed) this.terminal.print(`Installed: ${installed.installed}`, 'success'); + else this.terminal.print('Not installed. Use: wpm install ' + name, 'info'); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new WpmModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wpm.js'); +} diff --git a/packages.json b/packages.json new file mode 100644 index 0000000..759dc0a --- /dev/null +++ b/packages.json @@ -0,0 +1,112 @@ +{ + "name": "WebX11 Package Index", + "version": "1.0.0", + "updated": "2026-05-24T00:00:00Z", + "base_url": "https://webx11.duckdns.org/spaces/newos4/modules/ext/", + "packages": { + "voice": { + "name": "Voice", + "version": "1.0.0", + "desc": "Speech-to-text input using Web Speech API", + "url": "modules/ext/voice.js", + "commands": ["voice"], + "category": "input" + }, + "calc": { + "name": "Calculator", + "version": "1.0.0", + "desc": "Math evaluator and unit converter (km to miles, kg to pounds, etc.)", + "url": "modules/ext/calc.js", + "commands": ["calc"], + "category": "utility" + }, + "cron": { + "name": "Cron Scheduler", + "version": "1.0.0", + "desc": "Schedule commands to run at intervals (min 5s)", + "url": "modules/ext/cron.js", + "commands": ["cron-every", "cron-list", "cron-stop", "cron-clear"], + "category": "automation" + }, + "theme": { + "name": "Theme Switcher", + "version": "1.0.0", + "desc": "CSS theme manager — dark, light, amber, cyber + custom", + "url": "modules/ext/theme.js", + "commands": ["theme"], + "category": "ui" + }, + "wpm": { + "name": "Package Manager", + "version": "1.0.0", + "desc": "Install modules from the WebX11 package index (like npm for the terminal)", + "url": "modules/ext/wpm.js", + "commands": ["wpm"], + "category": "system" + }, + "webdav": { + "name": "WebDAV Sync", + "version": "1.0.0", + "desc": "Cloud sync for VFS — backup, push, pull, and restore via WebDAV", + "url": "modules/ext/webdav.js", + "commands": ["webdav-config", "webdav-status", "webdav-ls", "webdav-push", "webdav-pull", "webdav-backup", "webdav-restore", "webdav-backup-list"], + "category": "system" + }, + "mindmap": { + "name": "Mindmap", + "version": "1.0.0", + "desc": "Brainstorming mindmap tool — new, add, show, export, save, load, color, tag", + "url": "modules/ext/mindmap.js", + "commands": ["mindmap"], + "category": "productivity" + }, + "skill-pack-core": { + "name": "Skill Pack: Core", + "version": "1.0.0", + "desc": "Comprehensive skill set: terminal navigation, memory/profile, web search, module editing, system maintenance, backup, mindmap workflow, skills system guide", + "url": "modules/ext/skill-pack-core.js", + "commands": ["skill-pack-core"], + "category": "system" + }, + "editor": { + "name": "Text Editor", + "version": "1.0.0", + "desc": "Inline text editor for VFS files — edit, save, cancel via modal", + "url": "modules/ext/editor.js", + "commands": ["edit"], + "category": "utility" + }, + "diff": { + "name": "File Diff", + "version": "1.0.0", + "desc": "Compare two files or module versions with unified diff output", + "url": "modules/ext/diff.js", + "commands": ["diff"], + "category": "utility" + }, + "alias": { + "name": "Command Aliases", + "version": "1.0.0", + "desc": "Define and manage command aliases (ll, h, cls defaults) with localStorage persistence", + "url": "modules/ext/alias.js", + "commands": ["alias", "unalias", "ll", "h", "cls"], + "category": "system" + }, + "finder": { + "name": "File Finder", + "version": "1.0.0", + "desc": "Recursive VFS file/directory search with fuzzy matching", + "url": "modules/ext/finder.js", + "commands": ["find"], + "category": "utility" + }, + "filemgr": { + "name": "File Manager", + "version": "1.0.0", + "desc": "Modern file browser with preview for Markdown, images, video, audio, code, PDF, CSV", + "url": "modules/ext/filemgr.js", + "commands": ["files", "preview"], + "category": "utility" + } + } +} diff --git a/packages.json.orig b/packages.json.orig new file mode 100644 index 0000000..9d13824 --- /dev/null +++ b/packages.json.orig @@ -0,0 +1,168 @@ +{ + "name": "WebX11 Package Index", + "version": "1.0.0", + "updated": "2026-05-19T00:00:00Z", + "base_url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/", + "packages": { + "widget": { + "name": "Widget", + "version": "1.0.0", + "desc": "Live dashboard widgets — clock, weather, random quote", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/widget.js", + "commands": ["widget"], + "category": "ui" + }, + "voice": { + "name": "Voice", + "version": "1.0.0", + "desc": "Speech-to-text input using Web Speech API", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/voice.js", + "commands": ["voice"], + "category": "input" + }, + "calc": { + "name": "Calculator", + "version": "1.0.0", + "desc": "Math evaluator and unit converter (km to miles, kg to pounds, etc.)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/calc.js", + "commands": ["calc"], + "category": "utility" + }, + "bookmarks": { + "name": "Bookmarks", + "version": "1.0.0", + "desc": "Bookmark manager with tags, import/export", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/bookmarks.js", + "commands": ["bm-add", "bm-list", "bm-open", "bm-remove", "bm-export", "bm-import"], + "category": "utility" + }, + "snippets": { + "name": "Snippets", + "version": "1.0.0", + "desc": "Code/text snippet library — save, insert, list, delete", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/snippets.js", + "commands": ["snippet"], + "category": "utility" + }, + "notes": { + "name": "Notes", + "version": "1.0.0", + "desc": "Markdown note-taking in the virtual filesystem", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/notes.js", + "commands": ["note"], + "category": "productivity" + }, + "cron": { + "name": "Cron Scheduler", + "version": "1.0.0", + "desc": "Schedule commands to run at intervals (min 5s)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/cron.js", + "commands": ["cron-every", "cron-list", "cron-stop", "cron-clear"], + "category": "automation" + }, + "tour": { + "name": "Tour", + "version": "1.0.0", + "desc": "Interactive guided walkthrough of WebX11 features", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/tour.js", + "commands": ["tour"], + "category": "docs" + }, + "theme": { + "name": "Theme Switcher", + "version": "1.0.0", + "desc": "CSS theme manager — dark, light, amber, cyber + custom", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/theme.js", + "commands": ["theme"], + "category": "ui" + }, + "history-ai": { + "name": "AI History", + "version": "1.0.0", + "desc": "Log and replay Ollama AI conversations", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/history-ai.js", + "commands": ["ai-history", "ai-replay", "ai-export", "ai-save"], + "category": "ai" + }, + "grep": { + "name": "Grep", + "version": "1.0.0", + "desc": "Filter lines matching a regex pattern (pipe-compatible)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/grep.js", + "commands": ["grep"], + "category": "pipe" + }, + "wc": { + "name": "Word Count", + "version": "1.0.0", + "desc": "Count lines, words, and characters (pipe-compatible)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wc.js", + "commands": ["wc"], + "category": "pipe" + }, + "head": { + "name": "Head", + "version": "1.0.0", + "desc": "Show first N lines of output (pipe-compatible)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/head.js", + "commands": ["head"], + "category": "pipe" + }, + "tail": { + "name": "Tail", + "version": "1.0.0", + "desc": "Show last N lines of output (pipe-compatible)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/tail.js", + "commands": ["tail"], + "category": "pipe" + }, + "wpm": { + "name": "Package Manager", + "version": "1.0.0", + "desc": "Install modules from the WebX11 package index (like npm for the terminal)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wpm.js", + "commands": ["wpm"], + "category": "system" + }, + "git4webx": { + "name": "Git Integration", + "version": "1.0.0", + "desc": "Clone GitHub repos into the virtual filesystem", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/git4webx.js", + "commands": ["git-clone", "git-status"], + "category": "dev" + }, + "workspace": { + "name": "Workspaces", + "version": "1.0.0", + "desc": "Switch between isolated workspaces (separate filesystem + modules)", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/workspace.js", + "commands": ["ws"], + "category": "system" + }, + "webdav": { + "name": "WebDAV Sync", + "version": "1.0.0", + "desc": "Cloud sync for VFS — backup, push, pull, and restore via WebDAV", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/webdav.js", + "commands": ["webdav-config", "webdav-status", "webdav-ls", "webdav-push", "webdav-pull", "webdav-backup", "webdav-restore", "webdav-backup-list"], + "category": "system" + }, + "mindmap": { + "name": "Mindmap", + "version": "1.0.0", + "desc": "Brainstorming mindmap tool — new, add, show, export, save, load, color, tag", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/mindmap.js", + "commands": ["mindmap"], + "category": "productivity" + }, + "skill-pack-core": { + "name": "Skill Pack: Core", + "version": "1.0.0", + "desc": "Comprehensive skill set: terminal navigation, memory/profile, web search, module editing, system maintenance, backup, mindmap workflow, skills system guide", + "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/skill-pack-core.js", + "commands": ["skill-pack-core"], + "category": "system" + } + } +} diff --git a/space.yaml b/space.yaml new file mode 100644 index 0000000..9f24e70 --- /dev/null +++ b/space.yaml @@ -0,0 +1,14 @@ +# WebX11 Space Configuration + +space: + name: newos4 + owner: newos4 + created: 2026-05-25T16:40:00.000Z + +settings: + public: true + max_size_mb: 100 + +features: + webdav: true + static_hosting: true diff --git a/summary.txt b/summary.txt new file mode 100644 index 0000000..b89a9a5 --- /dev/null +++ b/summary.txt @@ -0,0 +1,14 @@ +session_summary: project: WebX11 Module System v4.2 +type: Mobile-Optimized Web Terminal + +status: Complete + +key_features: - Virtual File System with localStorage persistence - Dynamic Module Loading from URLs - Ollama AI Integration for local LLM access - Mobile-responsive design with touch optimizations - Command-line interface with history support - Quick action buttons for common tasks + +components: virtual_file_system: +description: In-browser file storage using localStorage commands: [ls, cat, put, rm, upload, download] + +module_manager: description: Dynamic module loading and registry system commands: [modules, loadmod, removemod, listmods] persistence: localStorage + +ollama_manager: +description: Local AI model integration commands: [ollamaconnect, ollamadisconnect, ollamamodels, ollamasetmodel, ai, ask] default_endpoint: http://localhost:11434 terminal_core: description: Main interface controller features: [command_history, network_status, memory_monitor]built_in_modules: - SystemModule - FileSystemModule - ModuleManagerModule - OllamaModuleui_elements: - Header with version and memory status - Terminal output container - Quick action button bar - Command input area - Status bar (network/ready status) - Modals: File Browser, Module Manager, Ollama Managerdecisions: - Single-file HTML implementation for portability - localStorage for data persistence across sessions - Mobile-first responsive design - Modular architecture for extensibilityaction_items: - Deploy HTML file to web server or open locally - Configure Ollama endpoint if using AI features - Load custom modules via URL if needed - Use 'help' command to view all available commandstechnical_notes: - Requires modern browser with localStorage support - Ollama integration needs local Ollama server running - Module loading requires CORS-friendly URLs - All data stored client-side only
\ No newline at end of file |