aboutsummaryrefslogtreecommitdiff
path: root/modules/core/core-bundle.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/core-bundle.js')
-rw-r--r--modules/core/core-bundle.js225
1 files changed, 221 insertions, 4 deletions
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 <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:
@@ -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=<name>, --provider=<name>, --since=<hours>, --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 <file.js> 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}` });
}