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 /modules/core | |
| parent | d8bf7fa398d6d928325be9b5c64ca24875bfeef8 (diff) | |
auto: 2026-05-31_06:00 changes
Diffstat (limited to 'modules/core')
| -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 |
3 files changed, 270 insertions, 127 deletions
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'); |