diff options
Diffstat (limited to 'modules/core/ollama-manager.js')
| -rw-r--r-- | modules/core/ollama-manager.js | 143 |
1 files changed, 140 insertions, 3 deletions
diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js index e3a3fdb..13472fc 100644 --- a/modules/core/ollama-manager.js +++ b/modules/core/ollama-manager.js @@ -11,6 +11,15 @@ class OllamaManager { 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 --- @@ -402,6 +411,7 @@ class OllamaManager { 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`, { @@ -416,6 +426,7 @@ class OllamaManager { const reader = response.body.getReader(); const decoder = new TextDecoder(); let isFirstToken = true; + let lastObj = null; while (true) { const { done, value } = await reader.read(); @@ -432,17 +443,22 @@ class OllamaManager { 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 { @@ -468,6 +484,7 @@ class OllamaManager { const decoder = new TextDecoder(); let isFirstToken = true; let buffer = ''; + let lastUsage = null; while (true) { const { done, value } = await reader.read(); @@ -489,18 +506,23 @@ class OllamaManager { 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; } @@ -553,6 +575,7 @@ class OllamaManager { 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`, { @@ -568,6 +591,7 @@ class OllamaManager { 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; @@ -582,6 +606,7 @@ class OllamaManager { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + lastObj = obj; } catch (e) { continue; } } } @@ -589,14 +614,19 @@ class OllamaManager { 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); @@ -624,6 +654,7 @@ class OllamaManager { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; + let lastUsage = null; while (true) { const { done, value } = await reader.read(); @@ -642,16 +673,21 @@ class OllamaManager { 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); @@ -733,6 +769,7 @@ class OllamaManager { } 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, @@ -782,6 +819,7 @@ class OllamaManager { } 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; @@ -885,6 +923,7 @@ class OllamaManager { 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'); @@ -906,6 +945,8 @@ class OllamaManager { 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') { @@ -915,12 +956,10 @@ class OllamaManager { } } - // MiniCPM5 XML tool calling mode — sends tools in message context instead of API parameter - // Model natively outputs <function name="x"><param name="y">val</param> XML + // 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 { - // Inject tool defs into a system message (the orchestrator model knows XML format) 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: @@ -942,6 +981,8 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm 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 = []; @@ -991,6 +1032,8 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm 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') { @@ -1000,6 +1043,100 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm } } + // ─── 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() { |