diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/core/module-aiassist.js | 106 | ||||
| -rw-r--r-- | modules/core/ollama-manager.js | 25 | ||||
| -rw-r--r-- | modules/core/terminal.js | 130 |
3 files changed, 257 insertions, 4 deletions
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 70882a9..602ae3e 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -35,7 +35,10 @@ const now = new Date(); const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); - return `Current date: ${dateStr}\nCurrent time: ${timeStr}\n\nYou are WebX11AI, the AI assistant embedded in the WebX11 browser terminal. You have direct access to read/write files, search the web, install packages, edit modules, and run terminal commands — all via tool calls. Use tools proactively. Keep responses concise.\n\n== TOOL REFERENCE == + const optimizationActive = this.terminal?.ollamaManager?.optimizationEnabled !== false; + const CR = String.fromCharCode(10); + const optNote = CR + CR + '== TOOL EXECUTION RULES (STRICT) ==' + CR + 'STRICT RULE 1: NEVER describe what the filesystem looks like from your training data. ALWAYS call list_files() or read_file() first, then report the ACTUAL result.' + CR + 'STRICT RULE 2: If you return text without calling any tool, you have failed. Use tools for every factual question.' + CR + 'STRICT RULE 3: A response like "/bin/, /etc/, /home/" without calling list_files is ILLEGAL. Call the tool.' + CR + 'STRICT RULE 4: When calling a tool, base your answer ENTIRELY on the tool result. If result is empty, say "empty".' + CR + CR; + return `Current date: ${dateStr}\nCurrent time: ${timeStr}\n\nYou are WebX11AI, the AI assistant embedded in the WebX11 browser terminal. You have direct access to read/write files, search the web, install packages, edit modules, and run terminal commands — all via tool calls. Use tools proactively. Keep responses concise.${optNote}== TOOL REFERENCE == You have these tools available. Use them WITHOUT asking permission unless the tool description says (DESTRUCTIVE): FILESYSTEM: @@ -436,7 +439,8 @@ RULES: } // Auto-load skills relevant to the user's query (awaited, not fire-and-forget) // Skip skill loading for trivial queries (< 10 chars, no actionable keywords) - const isTrivial = userMessage.length < 10 && !/\b(search|write|install|edit|create|make|find|delete|remove|change|update|improve|add|fix|build|set|load|save|help)\b/i.test(userMessage); + const optEnabled = this.terminal?.ollamaManager?.optimizationEnabled !== false; + const isTrivial = optEnabled && userMessage.length < 10 && !/\b(search|write|install|edit|create|make|find|delete|remove|change|update|improve|add|fix|build|set|load|save|help)\b/i.test(userMessage); if (!isTrivial) { try { const skillRes = await this.runTool('load_skills', { query: userMessage }); @@ -539,6 +543,42 @@ RULES: } return null; } + _parseToolBlocksFromText(text) { + if (!text) return []; + const toolCalls = []; + // Match ```tool name(args) or ```tool\nname(args) blocks + const blockRegex = /```tool(?:\s*\n)?\s*(\w+)\s*({[^}]*})?\s*```/gi; + let match; + while ((match = blockRegex.exec(text)) !== null) { + const name = match[1]; + let args = {}; + try { + if (match[2]) args = JSON.parse(match[2]); + } catch (e) { + // If JSON parse fails, treat as raw string + args = match[2] ? { path: match[2].replace(/[{}]/g,'').trim() } : {}; + } + toolCalls.push({ + id: 'text_' + Date.now() + '_' + toolCalls.length, + type: 'function', + function: { name, arguments: args } + }); + } + // Also handle single-line format: ```tool get_status{} + const simpleRegex = /```tool\s+(\w+)\s*\{([^}]*)\}\s*```/gi; + while ((match = simpleRegex.exec(text)) !== null) { + // Avoid duplicates — skip if already caught by blockRegex + const name = match[1]; + if (!toolCalls.find(tc => tc.function.name === name)) { + toolCalls.push({ + id: 'text_' + Date.now() + '_' + toolCalls.length, + type: 'function', + function: { name, arguments: {} } + }); + } + } + return toolCalls; + } _trackToolFailure(toolName, error) { try { const key = 'webx11_tool_failures'; @@ -571,7 +611,67 @@ RULES: if (this._cancelled) return { finalAnswer: null, toolTurns }; if (res.error) { this.terminal.print(`⚠️ AI model error (${thinkTime}s): ${res.error}`, 'error'); return { finalAnswer: null, toolTurns }; } if (res.message && !res.tool_calls) { - const text = res.message.content || ''; + let text = res.message.content || ''; + // Fallback: parse ```tool blocks from text when model writes them instead of native tool_calls + let parsedToolCalls = this._parseToolBlocksFromText(text); + if (parsedToolCalls.length > 0) { + this.terminal.print(`🔧 Parsed ${parsedToolCalls.length} tool call(s) from text block — turn ${toolTurns}/${this.MAX_TOOL_TURNS} (${thinkTime}s)`, 'info'); + // Extract any non-tool text as pending assistant content + const cleanText = text.replace(/```tool[\s\S]*?```/g, '').trim(); + if (cleanText) messages.push({ role: 'assistant', content: cleanText }); + messages.push({ role: 'assistant', content: '(calling tool via text block)' }); + let pendingText = cleanText || null; + const destructiveTools = parsedToolCalls.filter(tc => this.isDestructive(tc.function.name)); + const safeTools = parsedToolCalls.filter(tc => !this.isDestructive(tc.function.name)); + const safeResults = await Promise.all(safeTools.map(async (tc) => { + const fn = tc.function; + const t0 = Date.now(); + const toolResult = await this.runTool(fn.name, fn.arguments); + const t1 = Math.round((Date.now() - t0) / 1000); + return { fn, toolResult, t1 }; + })); + for (const { fn, toolResult, t1 } of safeResults) { + this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); + const obs = this._summarizeObservation(toolResult, 150); + const err = this._isToolError(toolResult); + if (err) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${err} (${t1}s)`); + this._trackToolFailure(fn.name, err); + this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); + } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); + this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + if (destructiveTools.length > 0) { + const approved = await this.confirmActionMulti(destructiveTools); + if (approved) { + for (const tc of destructiveTools) { + const fn = tc.function; + const t0 = Date.now(); + this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); + const toolResult = await this.runTool(fn.name, fn.arguments); + const t1 = Math.round((Date.now() - t0) / 1000); + const dObs = this._summarizeObservation(toolResult, 150); + const dErr = this._isToolError(toolResult); + if (dErr) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`); + this._trackToolFailure(fn.name, dErr); + this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); + } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); + this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + } else { + messages.push({ role: 'tool', content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied.` }); + } + } + if (pendingText) messages.push({ role: 'assistant', content: pendingText }); + continue; + } this.terminal.printAIResponse(text || '(no response)'); if (text) this.terminal.ollamaManager.fireResponseHooks(text); this.terminal.print(`💬 AI answered (turn ${toolTurns}/${this.MAX_TOOL_TURNS}, ${thinkTime}s)`, 'info'); diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js index 3f46b13..34ece29 100644 --- a/modules/core/ollama-manager.js +++ b/modules/core/ollama-manager.js @@ -11,6 +11,12 @@ class OllamaManager { this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; this.responseHooks = []; this._abortController = null; + // --- AI Agent Loop Optimization Toggle --- + this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') !== 'false'; + // --- Validator Agent Config (future) --- + this.validatorEnabled = localStorage.getItem('webx11_validator_enabled') === 'true'; + this.validatorModel = localStorage.getItem('webx11_validator_model') || ''; + this.validatorEndpoint = localStorage.getItem('webx11_validator_endpoint') || ''; // Build fallback endpoints this.fallbackEndpoints = []; const origin = typeof window !== 'undefined' ? window.location.origin : ''; @@ -900,6 +906,25 @@ class OllamaManager { saveSettings() { 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_validator_enabled', this.validatorEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_validator_model', this.validatorModel); + localStorage.setItem('webx11_validator_endpoint', this.validatorEndpoint || ''); + } + + setOptimizationEnabled(val) { + this.optimizationEnabled = !!val; + this.terminal?.print(`Agent optimization ${this.optimizationEnabled ? 'enabled' : 'disabled'}. Reload to apply.`, 'info'); + this.saveSettings(); + } + + setValidatorConfig(enabled, model) { + this.validatorEnabled = !!enabled; + if (model !== undefined) { + this.validatorModel = model; + localStorage.setItem('webx11_validator_model', model); + } + this.saveSettings(); } getModels() { return this.models; } diff --git a/modules/core/terminal.js b/modules/core/terminal.js index 1bfc4cd..5e6db6a 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -7,7 +7,7 @@ this.lastRouteTime = 0; this.lastRouteCmd = ''; this.blockedCount = 0; - this.destructiveCmds = new Set(['rm','delete','del','edit','write','save','overwrite','format','kill','stop','shutdown','reboot']); + this.destructiveCmds = new Set(['rm','delete','del','write','save','overwrite','format','kill','stop','shutdown','reboot']); this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']); this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']); this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history']); @@ -1143,6 +1143,7 @@ document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint; this.updateOllamaStatus(); this.renderOllamaModels(); + this.initOllamaUI(); modal.style.display = 'flex'; } closeOllamaManager() { @@ -1392,6 +1393,133 @@ const select = document.getElementById('ollama-current-model'); this.ollamaManager.setModel(select.value); } + toggleOptimization() { + const cb = document.getElementById('ollama-optimization-toggle'); + const label = document.getElementById('ollama-optimization-label'); + const slider = document.getElementById('ollama-optimization-slider'); + const enabled = cb.checked; + this.ollamaManager.setOptimizationEnabled(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)'; + } + this.ollamaManager.saveSettings(); + } + toggleValidator() { + const cb = document.getElementById('ollama-validator-toggle'); + const label = document.getElementById('ollama-validator-label'); + const slider = document.getElementById('ollama-validator-slider'); + const enabled = cb.checked; + this.ollamaManager.validatorEnabled = 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(--info-color)' : 'var(--bg-tertiary)'; + slider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; + } + this.ollamaManager.saveSettings(); + } + saveValidatorConfig() { + const select = document.getElementById('validator-model-select'); + const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; + const model = select ? select.value : ''; + localStorage.setItem('webx11_validator_endpoint', endpoint); + this.ollamaManager.validatorEndpoint = endpoint; + this.ollamaManager.setValidatorConfig(this.ollamaManager.validatorEnabled, model); + this.print('Validator config saved. Model: ' + (model || '(none)') + ' at ' + endpoint, 'success'); + } + async connectValidatorEndpoint() { + const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; + const statusEl = document.getElementById('validator-connection-status'); + const select = document.getElementById('validator-model-select'); + if (statusEl) statusEl.textContent = '⏳ Connecting to ' + endpoint + '...'; + if (select) { select.disabled = true; select.innerHTML = '<option value="">Connecting...</option>'; } + try { + const resp = await fetch(endpoint + '/api/tags'); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const data = await resp.json(); + const models = data.models || []; + if (statusEl) statusEl.textContent = '✅ Connected: ' + models.length + ' model(s)'; + // Populate dropdown + if (select) { + select.innerHTML = '<option value="">Select a validator model</option>'; + models.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = m.name; + select.appendChild(opt); + }); + select.disabled = false; + } + // Save endpoint + localStorage.setItem('webx11_validator_endpoint', endpoint); + this.ollamaManager.validatorEndpoint = endpoint; + this.ollamaManager.saveSettings(); + } catch (e) { + if (statusEl) { + statusEl.innerHTML = '❌ Connection failed: ' + e.message + + '<br><span style="font-size:0.85em;color:var(--text-muted);">Make sure Ollama is running and ' + + '<code>OLLAMA_ORIGINS</code> includes this page\'s origin.</span>'; + } + if (select) { select.disabled = true; select.innerHTML = '<option value="">Connection failed — try again</option>'; } + } + } + initOllamaUI() { + // Initialize optimization toggle from stored state + const optCb = document.getElementById('ollama-optimization-toggle'); + const optLabel = document.getElementById('ollama-optimization-label'); + const optSlider = document.getElementById('ollama-optimization-slider'); + if (optCb && this.ollamaManager) { + const enabled = this.ollamaManager.optimizationEnabled !== false; + optCb.checked = enabled; + if (optLabel) { + optLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + optLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (optSlider) { + optSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + optSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + } + // Initialize validator toggle from stored state + const valCb = document.getElementById('ollama-validator-toggle'); + const valLabel = document.getElementById('ollama-validator-label'); + const valSlider = document.getElementById('ollama-validator-slider'); + if (valCb && this.ollamaManager) { + const enabled = this.ollamaManager.validatorEnabled === true; + valCb.checked = enabled; + if (valLabel) { + valLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + valLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (valSlider) { + valSlider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)'; + valSlider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; + } + } + // Populate validator model dropdown from stored config + const valSelect = document.getElementById('validator-model-select'); + const valEndpoint = document.getElementById('validator-endpoint-input'); + if (valEndpoint && this.ollamaManager) { + valEndpoint.value = this.ollamaManager.validatorEndpoint || 'http://localhost:11434'; + } + if (valSelect && this.ollamaManager) { + const storedModel = this.ollamaManager.validatorModel || ''; + if (storedModel) { + valSelect.innerHTML = '<option value="' + storedModel + '">' + storedModel + '</option><option value="">Disable (use main AI model)</option>'; + valSelect.disabled = false; + } else { + valSelect.innerHTML = '<option value="">Connect to load models</option>'; + valSelect.disabled = true; + } + } + } openStartupModules() { document.getElementById('startup-modules').style.display = 'flex'; this.renderStartupModules(); |