aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CryptoGzipModule-draft.js315
-rw-r--r--index.html.pre-refactor35
-rw-r--r--main.html23
-rw-r--r--modules/core/module-aiassist.js47
-rw-r--r--modules/core/ollama-manager.js253
-rw-r--r--modules/core/terminal.js97
-rw-r--r--packages.json.orig168
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
diff --git a/main.html b/main.html
index 2468c19..f6a020d 100644
--- a/main.html
+++ b/main.html
@@ -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, "&lt;").replace(/>/g, "&gt;");
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, "&lt;").replace(/>/g, "&gt;");
- 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"
- }
- }
-}