From b08aa5b479395441462effed59ff1ee02d9d8c7f Mon Sep 17 00:00:00 2001 From: Agando Date: Wed, 24 Jun 2026 06:01:05 +0200 Subject: auto: 2026-06-24_06:01 changes --- modules/core/core-bundle.js | 225 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 221 insertions(+), 4 deletions(-) (limited to 'modules/core/core-bundle.js') diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 13330ab..00e8db3 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -1168,6 +1168,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 --- @@ -1559,6 +1568,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`, { @@ -1573,6 +1583,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(); @@ -1589,17 +1600,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 { @@ -1625,6 +1641,7 @@ class OllamaManager { const decoder = new TextDecoder(); let isFirstToken = true; let buffer = ''; + let lastUsage = null; while (true) { const { done, value } = await reader.read(); @@ -1646,18 +1663,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; } @@ -1710,6 +1732,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`, { @@ -1725,6 +1748,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; @@ -1739,6 +1763,7 @@ class OllamaManager { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + lastObj = obj; } catch (e) { continue; } } } @@ -1746,14 +1771,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); @@ -1781,6 +1811,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(); @@ -1799,16 +1830,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); @@ -1890,6 +1926,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, @@ -1939,6 +1976,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; @@ -2042,6 +2080,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'); @@ -2063,6 +2102,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') { @@ -2072,12 +2113,10 @@ class OllamaManager { } } - // MiniCPM5 XML tool calling mode β€” sends tools in message context instead of API parameter - // Model natively outputs val 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: @@ -2099,6 +2138,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 = []; @@ -2148,6 +2189,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') { @@ -2157,6 +2200,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() { @@ -3883,6 +4020,61 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm } return; } + if (cmd === 'usage') { + const om = this.ollamaManager; + if (!om) { this.print('Ollama manager not available', 'error'); return; } + const filters = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--json') { filters._json = true; } + else if (args[i] === '--reset') { + om._usageLog = []; + om._usageBuffer = []; + om._saveUsageLog(); + this.print('Usage log cleared.', 'success'); + return; + } + else if (args[i] === '--model' && args[i+1]) { filters.model = args[++i]; } + else if (args[i] === '--provider' && args[i+1]) { filters.provider = args[++i]; } + else if (args[i] === '--since' && args[i+1]) { filters.since_hours = parseFloat(args[++i]); } + } + const stats = om.getUsageStats(filters); + if (filters._json) { + this.print(JSON.stringify(stats, null, 2), ''); + return; + } + if (stats.total === 0) { + this.print('No usage data recorded yet.', 'info'); + return; + } + const fmt = (n) => n.toLocaleString(); + const timeStr = stats.timeSpan < 60 ? stats.timeSpan + 's' : + stats.timeSpan < 3600 ? Math.round(stats.timeSpan / 60) + 'm' : + Math.round(stats.timeSpan / 3600) + 'h ' + Math.round((stats.timeSpan % 3600) / 60) + 'm'; + this.print('╔══════════════════════════════════════════════════════╗', 'info'); + this.print('β•‘ LLM Usage Stats β•‘', 'info'); + this.print('╠══════════════════════════════════════════════════════╣', 'info'); + this.print(`β•‘ Total requests: ${fmt(stats.total).padStart(30)} β•‘`, 'info'); + this.print(`β•‘ Total tokens: ${fmt(stats.totalPrompt + stats.totalCompletion).padStart(30)} β•‘`, 'info'); + this.print(`β•‘ Input: ${fmt(stats.totalPrompt).padStart(37)} β•‘`, 'info'); + this.print(`β•‘ Output: ${fmt(stats.totalCompletion).padStart(37)} β•‘`, 'info'); + this.print(`β•‘ Avg latency: ${(stats.avgLatency / 1000).toFixed(1) + 's'.padStart(30)} β•‘`, 'info'); + this.print(`β•‘ Time span: ${timeStr.padStart(30)} β•‘`, 'info'); + this.print('╠══════════════════════════════════════════════════════╣', 'info'); + this.print('β•‘ Per model: β•‘', 'info'); + for (const [model, data] of Object.entries(stats.byModel).sort((a, b) => b[1].requests - a[1].requests)) { + const label = (model.length > 30 ? model.slice(0, 27) + '...' : model).padEnd(30); + this.print(`β•‘ ${label} ${fmt(data.requests).padStart(4)} req ${fmt(data.tokens).padStart(8)} tok β•‘`, ''); + } + this.print('╠══════════════════════════════════════════════════════╣', 'info'); + this.print('β•‘ Per provider: β•‘', 'info'); + for (const [prov, data] of Object.entries(stats.byProvider).sort((a, b) => b[1].requests - a[1].requests)) { + const label = (prov.length > 30 ? prov.slice(0, 27) + '...' : prov).padEnd(30); + this.print(`β•‘ ${label} ${fmt(data.requests).padStart(4)} req ${fmt(data.tokens).padStart(8)} tok β•‘`, ''); + } + this.print('β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•', 'info'); + this.print('Flags: --json, --model=, --provider=, --since=, --reset', 'text-muted'); + return; + } if (cmd === 'history') { const entries = this.history; if (!entries.length) { this.print('No command history.', 'info'); return; } @@ -7514,7 +7706,7 @@ buildSystemPrompt() { } catch (e) {} let base = `Current date: ${dateStr} (${dayStr}) ${timeStr}${nvfsNote}\n` + 'VFSβ†’Modulesβ†’AI. nvfs-index for commands. Toolβ†’Observeβ†’Answer. nvfs-resolve. Destructive cmds need confirm.\n' - + 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind: vaultmind_connect(network) β€” connect wallet, vaultmind_status β€” check session, vaultmind_query(ep,msg,agent) β€” Hyperliquid prices/portfolio/chat | wallet_lookup(address) β€” read-only portfolio for any wallet\n' + + 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind: vaultmind_connect(network) β€” connect wallet, vaultmind_status β€” check session, vaultmind_query(ep,msg,agent) β€” Hyperliquid prices/portfolio/chat | wallet_lookup(address) β€” read-only portfolio for any wallet | get_usage_stats(model,provider,since_hours) β€” LLM token/latency stats\n' + 'web_fetch tool: GET for normal pages (returns title+content+word_count), POST for JSON APIs like Hyperliquid /info (pass method="POST" and body as JSON string).\n' + 'Other: floating, webdav, cron, vault, sandbox, agents, wpm, mindmap, memgraph, guardrail, wget, tar, secret-* β€” discoverable via help. ' + 'JS: node executes JS from VFS (alias for js-run), node -e "expr" for inline eval. No real Node.js runtime β€” browser JS only.'; @@ -7927,6 +8119,21 @@ buildSystemPrompt() { required: ['address'] } } + }, { + type: 'function', + function: { + name: 'get_usage_stats', + description: 'Get LLM usage statistics: total requests, tokens, latency, per-model breakdown. Use this to report token consumption or latency to the user.', + parameters: { + type: 'object', + properties: { + model: { type: 'string', description: 'Filter by model name (optional)' }, + provider: { type: 'string', description: 'Filter by provider name (optional)' }, + since_hours: { type: 'number', description: 'Only last N hours (optional)' } + }, + required: [] + } + } }]; } getHelpInfo() { @@ -9511,6 +9718,16 @@ buildSystemPrompt() { return JSON.stringify({ error: 'Wallet lookup error: ' + e.message }); } } + case 'get_usage_stats': { + const om = this.terminal.ollamaManager; + if (!om) return JSON.stringify({ error: 'Ollama manager not available' }); + const filters = {}; + if (a.model) filters.model = a.model; + if (a.provider) filters.provider = a.provider; + if (a.since_hours) filters.since_hours = a.since_hours; + const stats = om.getUsageStats(filters); + return JSON.stringify({ success: true, stats }); + } default: return JSON.stringify({ error: `Unknown tool: ${name}` }); } -- cgit v1.2.3