diff options
Diffstat (limited to '.bkp-fix-20261028_165025/ollama-manager.js')
| -rw-r--r-- | .bkp-fix-20261028_165025/ollama-manager.js | 1355 |
1 files changed, 1355 insertions, 0 deletions
diff --git a/.bkp-fix-20261028_165025/ollama-manager.js b/.bkp-fix-20261028_165025/ollama-manager.js new file mode 100644 index 0000000..7dcecc3 --- /dev/null +++ b/.bkp-fix-20261028_165025/ollama-manager.js @@ -0,0 +1,1355 @@ +// Extracted from newOS4 index.html — OllamaManager / LLMRouter +// Refactored to support multiple LLM providers with auto-fallback +// Class name kept as OllamaManager for backward compatibility +class OllamaManager { + constructor(terminal) { + this.terminal = terminal; + // --- Backward-compat properties (reflected from active provider) --- + this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + this.connected = false; + this.models = []; + this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; + this.responseHooks = []; + this._abortController = null; + // --- Usage Tracking --- + this._usageLog = []; + this._usageBuffer = []; + this._usageFlushTimer = null; + this._loadUsageLog(); + // Flush on page unload + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => this._flushUsageLog()); + } + // --- AI Agent Loop Optimization Toggle --- + this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') === 'true'; // v4.5.66: default OFF + // --- Streaming Toggle --- + this.streamEnabled = localStorage.getItem('webx11_stream_enabled') === 'true'; + // --- Validator Agent Config --- + this.validatorEnabled = localStorage.getItem('webx11_validator_enabled') === 'true'; + this.validatorModel = localStorage.getItem('webx11_validator_model') || 'gemma4:31b-cloud'; + this.validatorEndpoint = localStorage.getItem('webx11_validator_endpoint') || ''; + // --- Model Parameters (temperature, top_p, max_tokens, thinking) --- + this.modelParams = this._loadModelParams(); + // Build fallback endpoints + this.fallbackEndpoints = []; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + if (origin) { + this.fallbackEndpoints.push(origin + '/ollama/'); + } + + // --- Multi-provider architecture --- + this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled } + this.currentProvider = null; // currently active provider object + this.fallbackCount = 0; + this._loadProviders(); + + // If no active provider set, pick the highest-priority enabled one + if (!this.currentProvider) { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + } + + // ─── Provider management ───────────────────────────────────────────────── + + // Ollama's server-side default context is small (2k-4k depending on the build). The agent + // sends a system prompt, up to 20 messages and up to 24k chars of tool results, so without an + // explicit num_ctx the server silently truncates the prompt — the model "forgets" what it was + // just told. Send it on every Ollama request; the OpenAI-compatible branch has its own params. + _ctxTokens() { + const n = Number(this.numCtx || (typeof localStorage !== 'undefined' ? localStorage.getItem('webx11_ollama_numctx') : 0)); + return Number.isFinite(n) && n >= 512 ? Math.floor(n) : 16384; + } + + _ollamaOptions(extra) { + return Object.assign({ num_ctx: this._ctxTokens() }, extra || {}); + } + + _loadProviders() { + const stored = this._getSecret('webx11_llm_providers'); + if (stored) { + try { + this.providers = JSON.parse(stored); + // Ensure all entries have the 'enabled' field + this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; }); + } catch (e) { + this.providers = []; + } + } + if (this.providers.length === 0) { + // Default single provider: Ollama + this.providers = [{ + name: 'Ollama', + type: 'ollama', + endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434', + apiKey: '', + model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: 0, + enabled: true + }]; + } + } + + _saveProviders() { + this._setSecret('webx11_llm_providers', JSON.stringify(this.providers)); + } + + _loadModelParams() { + try { + const saved = localStorage.getItem('webx11_model_params'); + if (saved) { + const parsed = JSON.parse(saved); + return { + temperature: parsed.temperature !== undefined ? parsed.temperature : 0.7, + top_p: parsed.top_p !== undefined ? parsed.top_p : 0.9, + max_tokens: parsed.max_tokens !== undefined ? parsed.max_tokens : 4096, + thinking: parsed.thinking || 'off' + }; + } + } catch (e) {} + return { temperature: 0.7, top_p: 0.9, max_tokens: 4096, thinking: 'off' }; + } + + _saveModelParams() { + try { + localStorage.setItem('webx11_model_params', JSON.stringify(this.modelParams)); + } catch (e) {} + } + + _getSecret(key) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') { + return window.webx11_secretVault.decrypt(key); + } + return localStorage.getItem(key); + } + + _setSecret(key, value) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') { + window.webx11_secretVault.encrypt(key, value); + } else { + localStorage.setItem(key, value); + } + } + + _getSortedEnabledProviders() { + return this.providers + .filter(p => p.enabled) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); + } + + _syncCompatProperties(provider) { + if (!provider) return; + this.endpoint = provider.endpoint; + this.currentModel = provider.model || this.currentModel; + // Don't override backward-compat localStorage unless explicitly set + } + + _getProviderByType(type) { + return this.providers.find(p => p.type === type && p.enabled); + } + + addProvider(name, type, endpoint, apiKey, model, priority) { + const existing = this.providers.findIndex(p => p.name === name); + const entry = { + name: name, + type: type || 'ollama', + endpoint: endpoint || 'http://localhost:11434', + apiKey: apiKey || '', + model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: priority !== undefined ? priority : this.providers.length, + enabled: true + }; + if (existing >= 0) { + this.providers[existing] = entry; + } else { + this.providers.push(entry); + } + this._saveProviders(); + // If this is the new highest-priority enabled provider, activate it + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0 && sorted[0].name === entry.name) { + this.currentProvider = entry; + this._syncCompatProperties(entry); + } + return true; + } + + removeProvider(name) { + const idx = this.providers.findIndex(p => p.name === name); + if (idx < 0) return false; + this.providers.splice(idx, 1); + if (this.currentProvider && this.currentProvider.name === name) { + this.currentProvider = null; + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + this._saveProviders(); + return true; + } + + listProviders() { + return this.providers; + } + + setActiveProvider(name) { + const provider = this.providers.find(p => p.name === name); + if (!provider) { + this.terminal.print(`Provider "${name}" not found`, 'error'); + return false; + } + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success'); + return true; + } + + getActiveProvider() { + return this.currentProvider; + } + + getProviderStats() { + const sorted = this._getSortedEnabledProviders(); + const connectedProvider = this.connected + ? (this.currentProvider ? this.currentProvider.name : 'unknown') + : null; + return { + currentProvider: this.currentProvider ? this.currentProvider.name : null, + totalProviders: this.providers.length, + connectedProvider: connectedProvider, + fallbackCount: this.fallbackCount + }; + } + + // ─── Connection ────────────────────────────────────────────────────────── + + async connect() { + const sortedProviders = this._getSortedEnabledProviders(); + if (sortedProviders.length === 0) { + this.terminal.print('No enabled providers configured', 'error'); + this.connected = false; + return false; + } + + // Also build endpoint-based fallback list for backward compat + const allEndpoints = []; + // First try the original endpoint + fallbackEndpoints for plain Ollama connect + if (this._getProviderByType('ollama')) { + allEndpoints.push(this.endpoint); + for (const fb of this.fallbackEndpoints) { + if (!allEndpoints.includes(fb)) allEndpoints.push(fb); + } + } + + // Try each provider in priority order + for (const provider of sortedProviders) { + let success = false; + if (provider.type === 'ollama') { + // Try provider endpoint first, then fallback endpoints + const eps = [provider.endpoint]; + for (const fb of this.fallbackEndpoints) { + if (!eps.includes(fb)) eps.push(fb); + } + for (const ep of eps) { + try { + this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + this.currentProvider = provider; + provider.endpoint = ep; // may have been updated via fallback + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + break; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message); + if (!this._quietConnect) this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + } else { + // OpenAI-compatible provider (OpenRouter, etc.) + try { + this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/models`, { + method: 'GET', headers + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = (data.data || data.models || []).map(m => ({ + name: m.id || m.name, + size: m.size || 0 + })); + this.connected = true; + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message); + if (!this._quietConnect) this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning'); + } + } + + if (success) return true; + } + + // If provider-based connect failed, try bare endpoint fallback (legacy) + for (const ep of allEndpoints) { + try { + if (!this._quietConnect) this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + if (ep !== this.endpoint) { + this.endpoint = ep; + this.saveSettings(); + this.terminal.print(`Endpoint updated to: ${ep}`, 'info'); + } + this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this.saveSettings(); + return true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message); + if (!this._quietConnect) this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + + this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers'); + this.connected = false; + return false; + } + + // v4.5.68: Fetch with timeout — wraps fetch() with AbortController to prevent network hangs + async fetchWithTimeout(url, options = {}, timeoutMs = 30000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(url, { ...options, signal: controller.signal }); + clearTimeout(timer); + return resp; + } catch (e) { + clearTimeout(timer); + if (e.name === 'AbortError') throw new Error(`Request timed out after ${timeoutMs / 1000}s`); + throw e; + } + } + + // ─── Lazy connect ──────────────────────────────────────────────────────── + /** + * Every request path calls this first. Nothing connects at boot, so the user's first message + * used to die with "Use ollamaconnect first" even when the endpoint was healthy + * (reported 2026-10-26/27). One attempt, then the caller prints the real reason. + * Concurrent callers share the same in-flight promise. + */ + async _ensureConnected() { + if (this.connected) return true; + if (this._connecting) { + try { await this._connecting; } catch (e) { /* fall through to the state check */ } + return this.connected; + } + // An automatic connect must not silently rewrite the model the user picked. + const savedModel = (typeof localStorage !== 'undefined') ? localStorage.getItem('webx11_ollama_model') : null; + this.terminal.print('Ollama: connecting on first use…', 'info'); + let timedOut = false; + try { + // One failure should read as one failure: connect() prints a line per endpoint it + // tries, which made an auto-connect failure seven lines of noise. Keep the detail in + // the log, print only the outcome for an automatic attempt. + this._quietConnect = true; + this._connecting = this.connect(); + // connect() uses a bare fetch(), so a blackholed endpoint would hang this promise + // forever and — because the promise is shared — every later message with it. + const deadline = this.connectTimeoutMs || 10000; + const outcome = await Promise.race([ + this._connecting.then(() => 'done', e => { throw e; }), + new Promise(resolve => setTimeout(() => resolve('timeout'), deadline)), + ]); + timedOut = outcome === 'timeout' && !this.connected; + } catch (e) { + // A throwing terminal.log() must not leave _connecting poisoned: log defensively. + try { this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + (e && e.message ? e.message : e)); } catch (_) {} + } finally { + this._quietConnect = false; + this._connecting = null; + } + if (this.connected && savedModel && !(this.models || []).some(m => m.name === savedModel)) { + this.currentModel = savedModel; + if (this.currentProvider) this.currentProvider.model = savedModel; + // connect() may already have overwritten the stored preference; put it back. + try { localStorage.setItem('webx11_ollama_model', savedModel); } catch (_) {} + this.terminal.print('Keeping your saved model "' + savedModel + '" — this endpoint does not list it.', 'warning'); + } + if (!this.connected) { + this.terminal.print(timedOut + ? 'Auto-connect timed out after ' + ((this.connectTimeoutMs || 10000) / 1000) + 's — the endpoint is not answering. Check the Ollama tab or run ollamaconnect.' + : 'Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning'); + } + return this.connected; + } + + // ─── Chat (non-streaming) ──────────────────────────────────────────────── + + async chat(prompt, model = null) { + // Per-call state: never let a reason captured by an earlier failure be replayed here. + this._lastChatError = null; + if (!this.connected && !(await this._ensureConnected())) { + this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error'); + return null; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected. Use ollamamodels to select.', 'error'); + return null; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithProvider(provider, prompt, useModel); + if (result !== null && result !== undefined) { + // Success — if we fell back, increment fallbackCount + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + lastError = result; + } + + if (lastError === null) { + // Name the real cause when one was captured; the generic wording is only a fallback. + const reason = this._lastChatError; + this.terminal.print( + reason ? `Ollama error: ${reason}` : 'Ollama error: No provider responded', + 'error'); + } + return null; + } + + async _chatWithProvider(provider, prompt, model) { + const useModel = model || provider.model || this.currentModel; + let fullText = ''; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const t0 = Date.now(); + try { + if (provider.type === 'ollama') { + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let lastObj = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.response) { + fullText += parsed.response; + this.terminal.printStreamingResponse(parsed.response, !isFirstToken); + isFirstToken = false; + } + lastObj = parsed; // capture last for eval_count + } catch (e) { + // Skip malformed partial lines + } + } + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count); + } else { + const data = await response.json(); + fullText = data.response || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + } + return fullText || null; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: this.streamEnabled + }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let buffer = ''; + let lastUsage = null; + + 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 (parsed.usage) lastUsage = parsed.usage; + } catch (e) { + // Skip malformed SSE lines + } + } + } + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens); + } else { + const data = await response.json(); + fullText = data.choices?.[0]?.message?.content || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); + } + return fullText || null; + } + } catch (error) { + // Remember why this provider failed so chat() can name the real cause + // instead of falling back to the generic "No provider responded". + this._lastChatError = error.message || String(error); + if (fullText && fullText.length > 0) { + this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true); + return fullText; + } + return null; // signal failure so caller can try next provider + } finally { + this._abortController = null; + } + } + + // ─── Chat Stream ───────────────────────────────────────────────────────── + + async chatStream(prompt, onToken, onComplete, model = null) { + if (!this.connected && !(await this._ensureConnected())) { + this.terminal.print('Ollama not connected: the endpoint is unreachable.', 'error'); + onComplete && onComplete(null); return; + } + const useModel = model || this.currentModel; + if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; } + + const providersToTry = this._getFallbackChain(); + let triedAny = false; + + for (const provider of providersToTry) { + triedAny = true; + const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel); + if (result !== null) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + } + + if (!triedAny) { + this.terminal.print('No model selected.', 'error'); + onComplete && onComplete(null); + } else { + this.terminal.print(`Stream error: No provider responded`, 'error'); + onComplete && onComplete(null); + } + return null; + } + + async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const t0 = Date.now(); + try { + if (provider.type === 'ollama') { + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + let fullResponse = ''; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let lastObj = null; + 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); + } + lastObj = obj; + } catch (e) { continue; } + } + } + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer); + if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + lastObj = obj; + } catch (e) {} + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count); + } else { + const data = await response.json(); + fullResponse = data.response || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: this.streamEnabled + }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + let fullResponse = ''; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let lastUsage = null; + + 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); + } + if (parsed.usage) lastUsage = parsed.usage; + } catch (e) { continue; } + } + } + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens); + } else { + const data = await response.json(); + fullResponse = data.choices?.[0]?.message?.content || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } + } catch (error) { + this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error'); + onComplete && onComplete(null); + return null; + } finally { + this._abortController = null; + } + } + + // ─── Chat With Tools ───────────────────────────────────────────────────── + + _isModelType(modelName, type) { + if (!modelName) return false; + const lower = modelName.toLowerCase(); + return lower.includes(type); + } + + async chatWithTools(messages, tools, model = null) { + // Per-call state: never let a reason captured by an earlier failure be replayed here. + this._lastChatError = null; + if (!this.connected && !(await this._ensureConnected())) { + this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error'); + return { message: null, tool_calls: null, error: 'Not connected' }; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected.', 'error'); + return { message: null, tool_calls: null, error: 'No model' }; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel); + if (result && !result.error) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + // Only keep non-cancellation errors for fallback + if (result && result.error && result.error !== 'Cancelled') { + lastError = result; + } + } + + // Name the real cause when one was captured; the generic wording is only a fallback. + const reason = this._lastChatError; + return lastError || { + message: null, tool_calls: null, + error: reason ? `All providers failed: ${reason}` : 'All providers failed' + }; + } + + async _chatWithToolsWithProvider(provider, messages, tools, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + + try { + if (provider.type === 'ollama') { + const body = { model: useModel, messages, stream: false, options: this._ollamaOptions() }; + if (tools && tools.length > 0) body.tools = tools; + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + if (response.status === 400) { + return await this.chatWithToolsFallback(messages, tools, useModel, signal); + } + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + this._logUsage(provider, useModel, networkTime * 1000, data.prompt_eval_count, data.eval_count); + return { + message: data.message || null, + tool_calls: data.message?.tool_calls || null, + error: null + }; + } else { + // OpenAI-compatible provider (OpenRouter, DeepSeek, etc.) + const isDeepSeek = this._isModelType(useModel, 'deepseek'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + // Apply model parameters (user-configured temperature, top_p, max_tokens, thinking) + if (this.modelParams) { + body.temperature = this.modelParams.temperature; + body.top_p = this.modelParams.top_p; + body.max_tokens = this.modelParams.max_tokens; + } + if (isDeepSeek) { + // DeepSeek defaults: user params override hardcoded defaults + if (body.temperature === undefined) body.temperature = 0.3; + if (body.max_tokens === undefined) body.max_tokens = 4096; + body.stop = ['<|end|>']; + // DeepSeek thinking mode + if (this.modelParams && this.modelParams.thinking !== 'off') { + body.thinking = { type: this.modelParams.thinking }; + } + } + if (tools && tools.length > 0) { + body.tools = tools; + } + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal + }); + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + this._logUsage(provider, useModel, networkTime * 1000, data.usage?.prompt_tokens, data.usage?.completion_tokens); + const choice = data.choices?.[0]; + const message = choice?.message || null; + const toolCalls = message?.tool_calls || null; + const content = message?.content || ''; + + // DeepSeek: handle null content with tool calls — send empty string + if (isDeepSeek && content === '' && toolCalls && toolCalls.length > 0) { + const normalised = toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })); + return { + // tool_calls must live INSIDE the assistant message: the caller pushes + // result.message and then a role:'tool' reply, and a tool message whose + // tool_call_id has no matching assistant call is the HTTP 400 this + // branch exists to avoid. + message: { role: 'assistant', content: '', tool_calls: normalised }, + tool_calls: normalised, + error: null + }; + } + + // DeepSeek: empty response with no tools — retry once with stream forcing + if (isDeepSeek && content === '' && (!toolCalls || toolCalls.length === 0)) { + 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: retrySignal + }); + if (retryResponse.ok) { + const reader = retryResponse.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + let retryBuffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + retryBuffer += decoder.decode(value, { stream: true }); + const lines = retryBuffer.split('\n'); + retryBuffer = 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 chunk = parsed.choices?.[0]?.delta?.content || ''; + if (chunk) fullText += chunk; + } catch (e) {} + } + } + } + if (fullText) { + return { + message: { role: 'assistant', content: fullText }, + tool_calls: null, + error: null + }; + } + } + return { + message: { role: 'assistant', content: '' }, + tool_calls: null, + error: 'DeepSeek returned empty response after retry — model may be overloaded or context too long' + }; + } + + return { + message: message ? { + role: message.role || 'assistant', + content: content, + // OpenAI-compatible providers require tool_calls INSIDE the assistant message; + // a bare { role:'tool' } with no preceding tool_calls is rejected with HTTP 400. + ...(Array.isArray(toolCalls) && toolCalls.length ? { tool_calls: toolCalls } : {}), + } : null, + tool_calls: toolCalls ? toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })) : null, + error: null + }; + } + } catch (error) { + if (error.name === 'AbortError') { + this.terminal.print('⏹ AI request cancelled by user', 'warning'); + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + if (error.message === 'Failed to fetch' && provider.type === 'ollama') { + return await this.chatWithToolsFallback(messages, tools, useModel); + } + this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error'); + this._lastChatError = error.message || String(error); + return { message: null, tool_calls: null, error: error.message }; + } finally { + this._abortController = null; + } + } + + async chatWithToolsFallback(messages, tools, model, signal) { + const provider = this.currentProvider; + const useModel = model || (provider ? provider.model : this.currentModel); + const t0 = Date.now(); + if (provider && provider.type !== 'ollama') { + // For OpenAI-compatible providers, try without tools + this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning'); + try { + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', headers, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.choices?.[0]?.message?.content || ''; + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // MiniCPM5 XML tool calling mode + if (useModel && useModel.includes('minicpm5') && tools && tools.length > 0) { + this.terminal.print(`🧩 Using XML tool mode for ${useModel}...`, 'info'); + try { + const toolDesc = tools.map(t => JSON.stringify({type:'function',function:t.function})).join('\n'); + const toolContext = { role: 'system', content: +`You are a tool orchestrator. When you need to call a tool, output XML like: +<function name="function-name"><param name="param-name">param-value</param> + +Available tools: +<tools> +${toolDesc} +</tools> + +Only output XML function calls for tool tasks. If no tool is needed, answer normally.` }; + const msgsWithTools = [toolContext, ...messages]; + const body = { model: useModel, messages: msgsWithTools, stream: false, options: this._ollamaOptions({ temperature: 0.1 }) }; + + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = (data.message?.content || '').trim(); + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + + // Parse XML function calls from the response + const toolCalls = []; + const funcRegex = /<function\s+name="([^"]+)"(.*?)<\/function>/gs; + let match; + while ((match = funcRegex.exec(text)) !== null) { + const funcName = match[1]; + const args = {}; + const paramRegex = /<param\s+name="([^"]+)"(.*?)>(.*?)<\/param>/gs; + let pmatch; + while ((pmatch = paramRegex.exec(match[0])) !== null) { + args[pmatch[1]] = pmatch[3] + .replace('<![CDATA[', '').replace(']]>', '') + .replace(/^\s+|\s+$/g, ''); + } + if (funcName && Object.keys(args).length > 0) { + toolCalls.push({ + id: 'call_' + Math.random().toString(36).slice(2, 10), + type: 'function', + function: { name: funcName, arguments: JSON.stringify(args) } + }); + } + } + if (toolCalls.length > 0) { + this.terminal.print(`✅ XML tool call(s) parsed from ${useModel}`, 'success'); + return { message: { role: 'assistant', content: '' }, tool_calls: toolCalls, error: null }; + } + + return { message: { role: 'assistant', content: text || '(no response)' }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // Original Ollama fallback + this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning'); + try { + const body = { model: useModel, messages, stream: false, options: this._ollamaOptions() }; + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.message?.content || data.response || ''; + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // ─── Usage Tracking ────────────────────────────────────────────────────── + + _logUsage(provider, model, latencyMs, promptTokens, completionTokens) { + const entry = { + ts: Date.now(), + provider: provider?.name || 'unknown', + model: model || 'unknown', + latency: Math.round(latencyMs || 0), + prompt_tokens: promptTokens || 0, + completion_tokens: completionTokens || 0, + total_tokens: (promptTokens || 0) + (completionTokens || 0) + }; + this._usageBuffer.push(entry); + this._debouncedFlush(); + } + + _debouncedFlush() { + if (this._usageFlushTimer) clearTimeout(this._usageFlushTimer); + if (this._usageBuffer.length >= 5) { + this._flushUsageLog(); + } else { + this._usageFlushTimer = setTimeout(() => this._flushUsageLog(), 2000); + } + } + + _flushUsageLog() { + if (this._usageBuffer.length === 0) return; + this._usageLog.push(...this._usageBuffer); + this._usageBuffer = []; + if (this._usageLog.length > 500) { + this._usageLog = this._usageLog.slice(-500); + } + this._saveUsageLog(); + } + + _loadUsageLog() { + try { + const raw = localStorage.getItem('webx11_ollama_usage_log'); + this._usageLog = raw ? JSON.parse(raw) : []; + } catch (e) { + this._usageLog = []; + } + } + + _saveUsageLog() { + try { + localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog)); + } catch (e) { + // localStorage quota exceeded — trim to 250 + this._usageLog = this._usageLog.slice(-250); + try { + localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog)); + } catch (e2) {} + } + } + + getUsageStats(filters = {}) { + let log = this._usageLog; + if (filters.model) { + log = log.filter(e => e.model === filters.model); + } + if (filters.provider) { + log = log.filter(e => e.provider === filters.provider); + } + if (filters.since_hours) { + const cutoff = Date.now() - (filters.since_hours * 3600 * 1000); + log = log.filter(e => e.ts >= cutoff); + } + const total = log.length; + const totalPrompt = log.reduce((s, e) => s + e.prompt_tokens, 0); + const totalCompletion = log.reduce((s, e) => s + e.completion_tokens, 0); + const totalLatency = log.reduce((s, e) => s + e.latency, 0); + const avgLatency = total > 0 ? Math.round(totalLatency / total) : 0; + // Per-model breakdown + const byModel = {}; + for (const e of log) { + if (!byModel[e.model]) byModel[e.model] = { requests: 0, tokens: 0 }; + byModel[e.model].requests++; + byModel[e.model].tokens += e.total_tokens; + } + // Per-provider breakdown + const byProvider = {}; + for (const e of log) { + if (!byProvider[e.provider]) byProvider[e.provider] = { requests: 0, tokens: 0 }; + byProvider[e.provider].requests++; + byProvider[e.provider].tokens += e.total_tokens; + } + // Time span + const timeSpan = total > 1 + ? Math.round((log[log.length - 1].ts - log[0].ts) / 1000) + : 0; + return { total, totalPrompt, totalCompletion, totalLatency, avgLatency, timeSpan, byModel, byProvider }; + } + + // ─── Internal helpers ──────────────────────────────────────────────────── + + _getFallbackChain() { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length === 0) return []; + + // Start with the current provider (if any), then all others sorted by priority + const chain = []; + const seen = new Set(); + + if (this.currentProvider && this.currentProvider.enabled) { + chain.push(this.currentProvider); + seen.add(this.currentProvider.name); + } + + for (const p of sorted) { + if (!seen.has(p.name)) { + chain.push(p); + seen.add(p.name); + } + } + + return chain; + } + + // ─── Backward-compat methods ───────────────────────────────────────────── + + setEndpoint(url) { + this.endpoint = url; + // Also update the current provider's endpoint if it exists + if (this.currentProvider) { + this.currentProvider.endpoint = url; + this._saveProviders(); + } + this.saveSettings(); + } + + setModel(name) { + this.currentModel = name; + // Also update the current provider's model + if (this.currentProvider) { + this.currentProvider.model = name; + this._saveProviders(); + } + this.saveSettings(); + this.terminal.print(`Model set to: ${name}`, 'success'); + } + + saveSettings() { + localStorage.setItem('webx11_ollama_endpoint', this.endpoint); + localStorage.setItem('webx11_ollama_model', this.currentModel); + 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 || ''); + } + + setOptimizationEnabled(val) { + this.optimizationEnabled = !!val; + this.terminal?.print(`Agent optimization ${this.optimizationEnabled ? 'enabled' : 'disabled'}. Reload to apply.`, 'info'); + 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) { + this.validatorModel = model; + localStorage.setItem('webx11_validator_model', model); + } + this.saveSettings(); + } + + async validateToolResult(toolName, args, result, aiAnswer) { + if (!this.validatorEnabled || !this.validatorModel) return true; + const endpoint = this.validatorEndpoint || this.endpoint; + const prompt = 'Tool called: ' + toolName + '(' + JSON.stringify(args) + ')\n' + + 'Tool returned: ' + ((result || '').substring(0, 500)) + '\n' + + 'AI answered: ' + ((aiAnswer || '').substring(0, 500)) + '\n' + + 'QUESTION: Is the AI answer consistent with the tool result?\nAnswer only YES or NO.'; + try { + const res = await fetch(endpoint + '/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.validatorModel, messages: [{ role: 'user', content: prompt }], stream: false }) + }); + const data = await res.json(); + const answer = (data.message?.content || '').trim().toUpperCase(); + return answer.startsWith('YES'); + } catch (e) { + if (this.terminal) this.terminal.print('⚠️ Validator error: ' + e.message, 'warning'); + return true; + } + } + + getModels() { return this.models; } + + addResponseHook(fn) { this.responseHooks.push(fn); } + + removeResponseHook(fn) { this.responseHooks = this.responseHooks.filter(h => h !== fn); } + + fireResponseHooks(text) { if (text) this.responseHooks.forEach(fn => { try { fn(text); } catch (e) {} }); } + + abort() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + return true; + } + return false; + } +} |