diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-31 06:00:55 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-31 06:00:55 +0200 |
| commit | 7cc82d2d32e44ab1c5b7d042d5522d036d9aa0be (patch) | |
| tree | 0a64e32f8b77043677e62485d7a0ecdf77e5c2d3 | |
| parent | d8bf7fa398d6d928325be9b5c64ca24875bfeef8 (diff) | |
auto: 2026-05-31_06:00 changes
| -rw-r--r-- | CryptoGzipModule-draft.js | 315 | ||||
| -rw-r--r-- | index.html.pre-refactor | 35 | ||||
| -rw-r--r-- | main.html | 23 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 47 | ||||
| -rw-r--r-- | modules/core/ollama-manager.js | 253 | ||||
| -rw-r--r-- | modules/core/terminal.js | 97 | ||||
| -rw-r--r-- | packages.json.orig | 168 |
7 files changed, 290 insertions, 648 deletions
diff --git a/CryptoGzipModule-draft.js b/CryptoGzipModule-draft.js deleted file mode 100644 index 2c1e45f..0000000 --- a/CryptoGzipModule-draft.js +++ /dev/null @@ -1,315 +0,0 @@ -/** - * CryptoGzipModule — Draft for Dev Agent - * - * Commands: - * gzip <path> → creates <path>.gz - * gunzip <path.gz> → restores <path> - * encrypt <path> → prompts passphrase → creates <path>.enc - * decrypt <path.enc> → prompts passphrase → restores <path> - * - * Wires into WebX11 module system. Copy into modules/ext/ or modules/tools/. - */ - -class CryptoGzipModule { - constructor() { - this.commands = {}; - this.terminal = null; - this._passphraseCache = new Map(); // session-only, never persisted - } - - requirements() { - const hasCrypto = !!(window.crypto && crypto.subtle); - const hasCompression = !!(window.CompressionStream); - const missing = []; - if (!hasCrypto) missing.push('Web Crypto API not available (requires secure context)'); - if (!hasCompression) missing.push('CompressionStream not available (requires Chromium-based browser or recent Firefox)'); - return { met: missing.length === 0, missing }; - } - - getHelpInfo() { - return { - 'gzip': 'gzip <path> — Compress a VFS file to .gz', - 'gunzip': 'gunzip <path.gz> — Decompress a .gz file in VFS', - 'encrypt':'encrypt <path> — AES-256-GCM encrypt a VFS file (interactive passphrase)', - 'decrypt':'decrypt <path.enc> — Decrypt a .enc file (interactive passphrase)', - }; - } - - init(terminal) { - this.terminal = terminal; - this.commands = { - 'gzip': (args) => this._cmdGzip(args), - 'gunzip': (args) => this._cmdGunzip(args), - 'encrypt':(args) => this._cmdEncrypt(args), - 'decrypt':(args) => this._cmdDecrypt(args), - }; - } - - // ============================================================ - // VFS WIRE POINTS — Dev Agent: replace with real VFS calls - // ============================================================ - - async _vfsRead(path) { - // WIRE: return await this.terminal.vfs.readFile(path, 'string'); - // OR: return window.webx11_vfs.read(path); - throw new Error('_vfsRead not wired'); - } - - async _vfsWrite(path, content) { - // WIRE: await this.terminal.vfs.writeFile(path, content); - // content is a string (base64 for binary-ish data) - throw new Error('_vfsWrite not wired'); - } - - async _vfsExists(path) { - // WIRE: return this.terminal.vfs.exists(path); - throw new Error('_vfsExists not wired'); - } - - async _vfsDelete(path) { - // WIRE: await this.terminal.vfs.delete(path); - throw new Error('_vfsDelete not wired'); - } - - // ============================================================ - // Prompt Utility - // ============================================================ - - _promptPassphrase(message = 'Enter passphrase: ') { - return new Promise((resolve) => { - // WIRE: adapt to WebX11 terminal prompt - // Option A: if terminal has hidden prompt: - // this.terminal.prompt(message, { hidden: true }).then(resolve); - // Option B: temporary override: - const savedHandler = this.terminal.onCommand; - this.terminal.print(message); - this.terminal.onCommand = (line) => { - this.terminal.onCommand = savedHandler; - resolve(line); - }; - }); - } - - _confirmPrompt(message) { - return new Promise((resolve) => { - this.terminal.print(message + ' (yes/no): '); - const savedHandler = this.terminal.onCommand; - this.terminal.onCommand = (line) => { - this.terminal.onCommand = savedHandler; - resolve(line.trim().toLowerCase() === 'yes'); - }; - }); - } - - // ============================================================ - // GZIP - // ============================================================ - - async _cmdGzip(args) { - const path = args[0]; - if (!path) throw new Error('Usage: gzip <path>'); - - const exists = await this._vfsExists(path); - if (!exists) throw new Error(`File not found: ${path}`); - - const outPath = path + '.gz'; - if (await this._vfsExists(outPath)) { - const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); - if (!ok) return 'Cancelled.'; - } - - const content = await this._vfsRead(path); - const compressed = await this._gzipString(content); - await this._vfsWrite(outPath, compressed); - await this._vfsDelete(path); - - const ratio = (compressed.length / Math.max(content.length, 1)).toFixed(2); - return `gzipped: ${path} → ${outPath} (${content.length} → ${compressed.length} chars, ratio ${ratio})`; - } - - async _cmdGunzip(args) { - const path = args[0]; - if (!path || !path.endsWith('.gz')) throw new Error('Usage: gunzip <path.gz>'); - - const exists = await this._vfsExists(path); - if (!exists) throw new Error(`File not found: ${path}`); - - const outPath = path.replace(/\.gz$/, ''); - if (await this._vfsExists(outPath)) { - const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); - if (!ok) return 'Cancelled.'; - } - - const compressed = await this._vfsRead(path); - const content = await this._gunzipString(compressed); - await this._vfsWrite(outPath, content); - await this._vfsDelete(path); - - return `gunzipped: ${path} → ${outPath} (${compressed.length} chars)`; - } - - async _gzipString(str) { - const encoder = new TextEncoder(); - const bytes = encoder.encode(str); - const cs = new CompressionStream('gzip'); - const writer = cs.writable.getWriter(); - writer.write(bytes); - writer.close(); - const compressed = await new Response(cs.readable).arrayBuffer(); - return this._bytesToBase64(new Uint8Array(compressed)); - } - - async _gunzipString(b64) { - const compressed = this._base64ToBytes(b64); - const ds = new DecompressionStream('gzip'); - const writer = ds.writable.getWriter(); - writer.write(compressed); - writer.close(); - const decompressed = await new Response(ds.readable).arrayBuffer(); - return new TextDecoder().decode(decompressed); - } - - // ============================================================ - // ENCRYPT / DECRYPT (AES-256-GCM) - // ============================================================ - - async _cmdEncrypt(args) { - const path = args[0]; - if (!path) throw new Error('Usage: encrypt <path>'); - - const exists = await this._vfsExists(path); - if (!exists) throw new Error(`File not found: ${path}`); - - const outPath = path + '.enc'; - if (await this._vfsExists(outPath)) { - const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); - if (!ok) return 'Cancelled.'; - } - - const passphrase = await this._promptPassphrase('Enter passphrase: '); - if (!passphrase || passphrase.length < 4) throw new Error('Passphrase too short (min 4 chars)'); - - const plaintext = await this._vfsRead(path); - const envelope = await this._encryptString(plaintext, passphrase); - await this._vfsWrite(outPath, JSON.stringify(envelope, null, 0)); - await this._vfsDelete(path); - - return `encrypted: ${path} → ${outPath}`; - } - - async _cmdDecrypt(args) { - const path = args[0]; - if (!path || !path.endsWith('.enc')) throw new Error('Usage: decrypt <path.enc>'); - - const exists = await this._vfsExists(path); - if (!exists) throw new Error(`File not found: ${path}`); - - const outPath = path.replace(/\.enc$/, ''); - if (await this._vfsExists(outPath)) { - const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); - if (!ok) return 'Cancelled.'; - } - - const passphrase = await this._promptPassphrase('Enter passphrase: '); - if (!passphrase) throw new Error('Passphrase required'); - - const envelopeJson = await this._vfsRead(path); - let envelope; - try { - envelope = JSON.parse(envelopeJson); - } catch (e) { - throw new Error('Invalid .enc file format (expected JSON envelope)'); - } - - const plaintext = await this._decryptEnvelope(envelope, passphrase); - await this._vfsWrite(outPath, plaintext); - await this._vfsDelete(path); - - return `decrypted: ${path} → ${outPath}`; - } - - async _encryptString(plaintext, passphrase) { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const key = await this._deriveKey(passphrase, salt); - - const encoder = new TextEncoder(); - const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - encoder.encode(plaintext) - ); - - return { - v: 1, // format version - alg: 'AES-256-GCM+PBKDF2', - salt: this._bytesToBase64(salt), - iv: this._bytesToBase64(iv), - ct: this._bytesToBase64(new Uint8Array(ciphertext)), - }; - } - - async _decryptEnvelope(envelope, passphrase) { - if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope version'); - - const salt = this._base64ToBytes(envelope.salt); - const iv = this._base64ToBytes(envelope.iv); - const ct = this._base64ToBytes(envelope.ct); - const key = await this._deriveKey(passphrase, salt); - - try { - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - key, - ct - ); - return new TextDecoder().decode(decrypted); - } catch (e) { - throw new Error('Decryption failed — wrong passphrase or corrupted file'); - } - } - - async _deriveKey(passphrase, salt) { - const encoder = new TextEncoder(); - const baseKey = await crypto.subtle.importKey( - 'raw', - encoder.encode(passphrase), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ); - return crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: salt, - iterations: 100000, - hash: 'SHA-256', - }, - baseKey, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt', 'decrypt'] - ); - } - - // ============================================================ - // Base64 Helpers (binary-safe, no deprecated btoa/unescape) - // ============================================================ - - _bytesToBase64(bytes) { - // Works in all modern browsers + WebX11 - const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join(''); - return btoa(bin); - } - - _base64ToBytes(b64) { - const bin = atob(b64); - return Uint8Array.from(bin, (c) => c.charCodeAt(0)); - } -} - -// ================================================================ -// EXPORTS — uncomment the appropriate line for your loader: -// ================================================================ -// window.CryptoGzipModule = CryptoGzipModule; // global registration -// export { CryptoGzipModule }; // ES module diff --git a/index.html.pre-refactor b/index.html.pre-refactor deleted file mode 100644 index c9779c9..0000000 --- a/index.html.pre-refactor +++ /dev/null @@ -1,35 +0,0 @@ - - // 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 @@ -325,7 +325,7 @@ .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); } /* Toggle slider styles */ - #ollama-optimization-slider::before, #ollama-validator-slider::before { + #ollama-optimization-slider::before, #ollama-validator-slider::before, #ollama-stream-slider::before { content: ""; position: absolute; height: 20px; @@ -337,11 +337,13 @@ transition: 0.3s; } #ollama-optimization-toggle:checked + #ollama-optimization-slider::before, - #ollama-validator-toggle:checked + #ollama-validator-slider::before { + #ollama-validator-toggle:checked + #ollama-validator-slider::before, + #ollama-stream-toggle:checked + #ollama-stream-slider::before { transform: translateX(22px); } #ollama-optimization-toggle:checked + #ollama-optimization-slider, - #ollama-validator-toggle:checked + #ollama-validator-slider { + #ollama-validator-toggle:checked + #ollama-validator-slider, + #ollama-stream-toggle:checked + #ollama-stream-slider { border-color: var(--success-color); } .copy-btn { @@ -609,6 +611,7 @@ <div id="status-bar"> <span id="status-text">Ready</span> <span id="network-status">📶 Online</span> + <span id="stream-status" style="font-size:0.75em; margin-left:4px; color:var(--success-color);">▶ Stream</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> @@ -691,6 +694,20 @@ <span style="color:var(--warning-color);">Disable for debugging or if model behaves unexpectedly. Reload after toggle.</span> </div> <hr style="border-color:var(--border-color); margin: 15px 0;"> + <h4 style="color: var(--accent-color); margin-bottom: 10px;">⚡ Response Streaming</h4> + <div style="display:flex; align-items:center; gap:10px; margin-bottom: 6px;"> + <span style="color:var(--text-muted); font-size:0.85em;">Stream Tokens:</span> + <label style="position:relative; display:inline-block; width:48px; height:26px;"> + <input type="checkbox" id="ollama-stream-toggle" onchange="webx11.toggleStream()" style="opacity:0; width:0; height:0;"> + <span id="ollama-stream-slider" style="position:absolute; cursor:pointer; inset:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:26px; transition:0.3s;"></span> + </label> + <span id="ollama-stream-label" style="font-size:0.85em; color:var(--success-color);">Enabled</span> + </div> + <div style="font-size:0.8em; color:var(--text-muted); margin-bottom: 10px;"> + Show AI responses token-by-token. Disable for instant full responses and reduced crash risk.<br> + <span style="color:var(--warning-color);">Disable on slow connections or when browser becomes unresponsive during long outputs.</span> + </div> + <hr style="border-color:var(--border-color); margin: 15px 0;"> <h4 style="color: var(--info-color); margin-bottom: 10px;">🔍 Validator Agent (Future)</h4> <div style="font-size:0.85em; color:var(--text-muted); margin-bottom: 10px;"> A separate lightweight AI that validates the main agent's tool results. Has its own diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index f6fadb5..8f8a60c 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -415,7 +415,15 @@ RULES: } this._cancelled = false; this.terminal.setAiBusy(true); - while (this._chatLock) await new Promise(r => setTimeout(r, 50)); + const lockStart = Date.now(); + while (this._chatLock) { + if (Date.now() - lockStart > 10000) { + this._chatLock = false; + this.terminal.print('⚠️ Chat lock timed out — force released', 'warning'); + break; + } + 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) @@ -545,18 +553,35 @@ RULES: } _trackToolFailure(toolName, error) { try { + if (!this._toolFailuresBuffer) this._toolFailuresBuffer = {}; + if (!this._toolFailuresBuffer[toolName]) this._toolFailuresBuffer[toolName] = { count: 0, last: '', errors: [] }; + const entry = this._toolFailuresBuffer[toolName]; + entry.count++; + entry.last = new Date().toISOString(); + entry.errors.push(error); + if (entry.errors.length > 5) entry.errors.shift(); + if (entry.count >= 3) { + this.terminal.print(`⚠️ Tool '${toolName}' has failed ${entry.count} times. Consider creating a workaround skill.`, 'warning'); + } + if (this._toolFailureFlushTimer) clearTimeout(this._toolFailureFlushTimer); + this._toolFailureFlushTimer = setTimeout(() => this._flushToolFailures(), 2000); + } catch (e) { /* ignore */ } + } + _flushToolFailures() { + try { + if (!this._toolFailuresBuffer || Object.keys(this._toolFailuresBuffer).length === 0) return; const key = 'webx11_tool_failures'; const raw = localStorage.getItem(key) || '{}'; const map = JSON.parse(raw); - if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] }; - map[toolName].count++; - map[toolName].last = new Date().toISOString(); - map[toolName].errors.push(error); - if (map[toolName].errors.length > 5) map[toolName].errors.shift(); - localStorage.setItem(key, JSON.stringify(map)); - if (map[toolName].count >= 3) { - this.terminal.print(`⚠️ Tool '${toolName}' has failed ${map[toolName].count} times. Consider creating a workaround skill.`, 'warning'); + for (const [toolName, entry] of Object.entries(this._toolFailuresBuffer)) { + if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] }; + map[toolName].count += entry.count; + map[toolName].last = entry.last; + map[toolName].errors.push(...entry.errors); + if (map[toolName].errors.length > 10) map[toolName].errors = map[toolName].errors.slice(-10); } + localStorage.setItem(key, JSON.stringify(map)); + this._toolFailuresBuffer = {}; } catch (e) { /* ignore */ } } async agenticLoop(messages) { @@ -781,7 +806,7 @@ RULES: } }, 500); this._confirmTimerInterval = timerInterval; - setTimeout(() => { + this._confirmTimeout = setTimeout(() => { clearInterval(timerInterval); if (this._confirmResolve) { this._confirmResolve(false); @@ -804,6 +829,8 @@ RULES: } _cleanupConfirmBar() { clearInterval(this._confirmTimerInterval); + clearTimeout(this._confirmTimeout); + this._confirmTimeout = null; this._confirmTimerInterval = null; this._confirmResolve = null; this._pendingConfirmation = null; diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js index 420cbc3..ab3ec2c 100644 --- a/modules/core/ollama-manager.js +++ b/modules/core/ollama-manager.js @@ -13,6 +13,8 @@ class OllamaManager { this._abortController = null; // --- AI Agent Loop Optimization Toggle --- this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') !== 'false'; + // --- Streaming Toggle --- + this.streamEnabled = localStorage.getItem('webx11_stream_enabled') !== 'false'; // --- Validator Agent Config (future) --- this.validatorEnabled = localStorage.getItem('webx11_validator_enabled') === 'true'; this.validatorModel = localStorage.getItem('webx11_validator_model') || ''; @@ -359,48 +361,53 @@ class OllamaManager { async _chatWithProvider(provider, prompt, model) { const useModel = model || provider.model || this.currentModel; let fullText = ''; + this._abortController = new AbortController(); + const signal = this._abortController.signal; 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 }), + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled }), 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; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + 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()); + 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; + 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 } - } catch (e) { - // Skip malformed partial lines } } + } else { + const data = await response.json(); + fullText = data.response || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } } 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; @@ -411,43 +418,50 @@ class OllamaManager { body: JSON.stringify({ model: useModel, messages: [{ role: 'user', content: prompt }], - stream: true + stream: this.streamEnabled }), 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; + if (this.streamEnabled) { + 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 } - } catch (e) { - // Skip malformed SSE lines } } } + } else { + const data = await response.json(); + fullText = data.choices?.[0]?.message?.content || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } } return fullText || null; } @@ -457,6 +471,8 @@ class OllamaManager { return fullText; } return null; // signal failure so caller can try next provider + } finally { + this._abortController = null; } } @@ -496,52 +512,58 @@ class OllamaManager { async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) { const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; 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 }), + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled }), 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; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + 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(line); - if (obj.response) { - fullResponse += obj.response; - onToken && onToken(obj.response, fullResponse); - } - } catch (e) { continue; } + const obj = JSON.parse(buffer); + if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + } catch (e) {} + } + } else { + const data = await response.json(); + fullResponse = data.response || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); } - } - 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; @@ -552,38 +574,45 @@ class OllamaManager { body: JSON.stringify({ model: useModel, messages: [{ role: 'user', content: prompt }], - stream: true + stream: this.streamEnabled }), 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; } + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + 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; } + } } } + } else { + const data = await response.json(); + fullResponse = data.choices?.[0]?.message?.content || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } } this.fireResponseHooks(fullResponse); onComplete && onComplete(fullResponse); @@ -593,6 +622,8 @@ class OllamaManager { this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error'); onComplete && onComplete(null); return null; + } finally { + this._abortController = null; } } @@ -654,7 +685,6 @@ class OllamaManager { body: JSON.stringify(body), signal }); - this._abortController = null; const networkTime = Math.round((Date.now() - t0) / 1000); if (!response.ok) { if (response.status === 400) { @@ -696,7 +726,6 @@ class OllamaManager { 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}`); @@ -729,11 +758,13 @@ class OllamaManager { this.terminal.print('⚠️ DeepSeek returned empty — retrying with stream...', 'warning'); body.stream = true; delete body.stop; + this._abortController = new AbortController(); + const retrySignal = this._abortController.signal; const retryResponse = await fetch(`${provider.endpoint}/v1/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body), - signal: this._abortController ? undefined : undefined + signal: retrySignal }); if (retryResponse.ok) { const reader = retryResponse.body.getReader(); @@ -907,6 +938,7 @@ class OllamaManager { localStorage.setItem('webx11_ollama_endpoint', this.endpoint); localStorage.setItem('webx11_ollama_model', this.currentModel); localStorage.setItem('webx11_optimization_enabled', this.optimizationEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_stream_enabled', this.streamEnabled ? 'true' : 'false'); localStorage.setItem('webx11_validator_enabled', this.validatorEnabled ? 'true' : 'false'); localStorage.setItem('webx11_validator_model', this.validatorModel); localStorage.setItem('webx11_validator_endpoint', this.validatorEndpoint || ''); @@ -918,6 +950,13 @@ class OllamaManager { this.saveSettings(); } + setStreamEnabled(val) { + this.streamEnabled = !!val; + this.terminal?.print(`Response streaming ${this.streamEnabled ? 'enabled' : 'disabled'}.`, 'info'); + this.terminal?.updateStreamIndicator(); + this.saveSettings(); + } + setValidatorConfig(enabled, model) { this.validatorEnabled = !!enabled; if (model !== undefined) { diff --git a/modules/core/terminal.js b/modules/core/terminal.js index de2f4a7..d53ab70 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -419,6 +419,7 @@ return fragments.length > 0 ? fragments : null; } printAIResponse(text) { + this._flushStreamChunks(); const fragments = this._renderHTMLBlock(text); if (fragments) { for (const frag of fragments) { @@ -443,6 +444,7 @@ this._trimOutput(); } printStreamingResponse(text, append) { + if (!this._streamChunks) this._streamChunks = []; if (!append) { const safeText = text.replace(/</g, "<").replace(/>/g, ">"); const wrapper = document.createElement('div'); @@ -452,16 +454,30 @@ <div class="ai-content">${safeText}</div> `; this.outputContainer.appendChild(wrapper); + this._streamChunks = []; } 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._streamChunks.push(text); + if (this._streamChunks.length >= 5) { + contentDiv.textContent += this._streamChunks.join(''); + this._streamChunks = []; + if (contentDiv.textContent.length > 50000) { + contentDiv.textContent = contentDiv.textContent.slice(-30000); + } + } } } this.scrollToBottom(); this._trimOutput(); } + _flushStreamChunks() { + const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content'); + if (contentDiv && this._streamChunks && this._streamChunks.length > 0) { + contentDiv.textContent += this._streamChunks.join(''); + this._streamChunks = []; + } + } copyToClipboard(btn) { const contentDiv = btn.nextElementSibling; const text = contentDiv.innerText; @@ -576,8 +592,9 @@ } if (toRender.length > batchSize) { const remaining = toRender.slice(batchSize); - const doBatch = (index) => { - if (index >= remaining.length) { + const MAX_BATCHES = 50; + const doBatch = (index, batchCount) => { + if (index >= remaining.length || batchCount >= MAX_BATCHES) { this.scrollToBottom(); return; } @@ -589,10 +606,10 @@ line.textContent = remaining[i].l || ''; this.outputContainer.appendChild(line); } - doBatch(end); + doBatch(end, (batchCount || 0) + 1); }); }; - doBatch(0); + doBatch(0, 0); } else { this.scrollToBottom(); } @@ -652,12 +669,29 @@ this._autoSaveSession(); this._dirty = false; }, 180000); + window.addEventListener('beforeunload', () => { + if (this._autoSaveTimer) { + clearInterval(this._autoSaveTimer); + this._autoSaveTimer = null; + } + }); } _autoSaveSession() { try { this.fs.mkdirRecursive('_sessions'); - // Clone fsRoot and strip _sessions to prevent recursive nesting - var rootClone = JSON.parse(JSON.stringify(this.fs.root)); + var rootClone; + try { + const json = JSON.stringify(this.fs.root); + if (json.length > 5242880) { // 5MB cap + rootClone = { type: 'directory', name: '/', content: {}, _truncated: true }; + this.print('⚠ Auto-save skipped (VFS too large)', 'warning'); + return; + } + rootClone = JSON.parse(json); + } catch (e) { + rootClone = { type: 'directory', name: '/', content: {}, _error: true }; + return; + } if (rootClone && rootClone.content && rootClone.content['_sessions']) { delete rootClone.content['_sessions']; } @@ -669,8 +703,12 @@ version: 1, terminalLines: this._historyBuffer.slice(-50) }; - this.fs.writeFile('_sessions/_auto.json', JSON.stringify(snapshot, null, 2)); - // Also persist AI context concurrently + const snapshotJson = JSON.stringify(snapshot); + if (snapshotJson.length > 5242880) { + this.print('⚠ Auto-save snapshot too large, skipping', 'warning'); + return; + } + this.fs.writeFile('_sessions/_auto.json', snapshotJson); try { const ctx = localStorage.getItem('webx11_ai_context'); if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx); @@ -1451,6 +1489,28 @@ } this.ollamaManager.saveSettings(); } + toggleStream() { + const cb = document.getElementById('ollama-stream-toggle'); + const label = document.getElementById('ollama-stream-label'); + const slider = document.getElementById('ollama-stream-slider'); + const enabled = cb.checked; + this.ollamaManager.setStreamEnabled(enabled); + if (label) { + label.textContent = enabled ? 'Enabled' : 'Disabled'; + label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (slider) { + slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + } + updateStreamIndicator() { + const el = document.getElementById('stream-status'); + if (!el) return; + const enabled = this.ollamaManager?.streamEnabled !== false; + el.textContent = enabled ? '▶ Stream' : '🔇 No-Stream'; + el.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } saveValidatorConfig() { const select = document.getElementById('validator-model-select'); const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; @@ -1529,6 +1589,23 @@ valSlider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; } } + // Initialize stream toggle from stored state + const strCb = document.getElementById('ollama-stream-toggle'); + const strLabel = document.getElementById('ollama-stream-label'); + const strSlider = document.getElementById('ollama-stream-slider'); + if (strCb && this.ollamaManager) { + const enabled = this.ollamaManager.streamEnabled !== false; + strCb.checked = enabled; + if (strLabel) { + strLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + strLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (strSlider) { + strSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + strSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + } + this.updateStreamIndicator(); // Populate validator model dropdown from stored config const valSelect = document.getElementById('validator-model-select'); const valEndpoint = document.getElementById('validator-endpoint-input'); diff --git a/packages.json.orig b/packages.json.orig deleted file mode 100644 index 9d13824..0000000 --- a/packages.json.orig +++ /dev/null @@ -1,168 +0,0 @@ -{ - "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" - } - } -} |