aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/core-bundle.js38
-rw-r--r--modules/core/module-aiassist.js2
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/ollama-manager.js36
-rw-r--r--modules/test/test-ollama-numctx.js95
5 files changed, 152 insertions, 21 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 1a92ef9..fcf1505 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -1290,6 +1290,19 @@ class OllamaManager {
// ─── 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) {
@@ -1506,7 +1519,7 @@ class OllamaManager {
break;
} catch (error) {
this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message);
- this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning');
+ if (!this._quietConnect) this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning');
}
}
} else {
@@ -1544,7 +1557,7 @@ class OllamaManager {
success = true;
} catch (error) {
this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message);
- this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning');
+ if (!this._quietConnect) this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning');
}
}
@@ -1554,7 +1567,7 @@ class OllamaManager {
// If provider-based connect failed, try bare endpoint fallback (legacy)
for (const ep of allEndpoints) {
try {
- this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info');
+ 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' }
});
@@ -1580,7 +1593,7 @@ class OllamaManager {
return true;
} catch (error) {
this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message);
- this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
+ if (!this._quietConnect) this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
}
}
@@ -1622,6 +1635,10 @@ class OllamaManager {
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.
@@ -1635,6 +1652,7 @@ class OllamaManager {
// 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)) {
@@ -1706,7 +1724,7 @@ class OllamaManager {
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 }),
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -1876,7 +1894,7 @@ class OllamaManager {
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 }),
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -2053,7 +2071,7 @@ class OllamaManager {
try {
if (provider.type === 'ollama') {
- const body = { model: useModel, messages, stream: false };
+ 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`, {
@@ -2286,7 +2304,7 @@ ${toolDesc}
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: { temperature: 0.1 } };
+ 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' },
@@ -2337,7 +2355,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
// 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 };
+ 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),
@@ -11084,7 +11102,7 @@ buildSystemPrompt() {
const why = (typeof this.terminal.fs._isVirtualPath === 'function' && this.terminal.fs._isVirtualPath(wp))
? 'read-only virtual path'
: 'parent directory missing or target is a directory';
- return `Write FAILED: ${wp} — ${why}; nothing was written`;
+ return `Write FAILED: ${String(wp).replace(/\/+$/, '') || '/'} — ${why}; nothing was written`;
}
return `File written: ${wp} (${content.length} chars)`;
}
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 26e47cb..e4b7b7e 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -2509,7 +2509,7 @@ buildSystemPrompt() {
const why = (typeof this.terminal.fs._isVirtualPath === 'function' && this.terminal.fs._isVirtualPath(wp))
? 'read-only virtual path'
: 'parent directory missing or target is a directory';
- return `Write FAILED: ${wp} — ${why}; nothing was written`;
+ return `Write FAILED: ${String(wp).replace(/\/+$/, '') || '/'} — ${why}; nothing was written`;
}
return `File written: ${wp} (${content.length} chars)`;
}
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index b74987e..c383f23 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.116';
+ const LATEST_VER = 'v4.6.118';
const CACHE = '?v=' + LATEST_VER;
// Exposed so ext modules can register their own URL with the same cache-busting version
// instead of an unversioned path the browser may already have cached (shell.js did).
diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js
index 4512662..7dcecc3 100644
--- a/modules/core/ollama-manager.js
+++ b/modules/core/ollama-manager.js
@@ -55,6 +55,19 @@ class OllamaManager {
// ─── 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) {
@@ -271,7 +284,7 @@ class OllamaManager {
break;
} catch (error) {
this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message);
- this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning');
+ if (!this._quietConnect) this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning');
}
}
} else {
@@ -309,7 +322,7 @@ class OllamaManager {
success = true;
} catch (error) {
this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message);
- this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning');
+ if (!this._quietConnect) this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning');
}
}
@@ -319,7 +332,7 @@ class OllamaManager {
// If provider-based connect failed, try bare endpoint fallback (legacy)
for (const ep of allEndpoints) {
try {
- this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info');
+ 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' }
});
@@ -345,7 +358,7 @@ class OllamaManager {
return true;
} catch (error) {
this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message);
- this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
+ if (!this._quietConnect) this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
}
}
@@ -387,6 +400,10 @@ class OllamaManager {
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.
@@ -400,6 +417,7 @@ class OllamaManager {
// 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)) {
@@ -471,7 +489,7 @@ class OllamaManager {
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 }),
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -641,7 +659,7 @@ class OllamaManager {
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 }),
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -818,7 +836,7 @@ class OllamaManager {
try {
if (provider.type === 'ollama') {
- const body = { model: useModel, messages, stream: false };
+ 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`, {
@@ -1051,7 +1069,7 @@ ${toolDesc}
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: { temperature: 0.1 } };
+ 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' },
@@ -1102,7 +1120,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
// 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 };
+ 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),
diff --git a/modules/test/test-ollama-numctx.js b/modules/test/test-ollama-numctx.js
new file mode 100644
index 0000000..2179f6d
--- /dev/null
+++ b/modules/test/test-ollama-numctx.js
@@ -0,0 +1,95 @@
+// modules/test/test-ollama-numctx.js — run: node
+// The Ollama server's default context is small, so an unsent num_ctx silently truncates the prompt.
+// Every Ollama request must carry options.num_ctx; the OpenAI-compatible branch must not.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const store = {};
+global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.window = global;
+global.window.location = { origin: 'https://test.local', hostname: 'test.local' };
+global.window.addEventListener = () => {};
+global.window.removeEventListener = () => {};
+global.document = { querySelector: () => null, querySelectorAll: () => [] };
+const OM = new Function(fsMod.readFileSync(path.join(ROOT, 'ollama-manager.js'), 'utf8') + '\nreturn OllamaManager;')();
+
+let fails = 0, passes = 0;
+const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); };
+
+function mk() {
+ const bodies = [];
+ const out = [];
+ const om = new OM();
+ om.terminal = {
+ print: (m) => out.push(String(m)),
+ printStreamingResponse: (m) => out.push(String(m)),
+ log: () => {},
+ fs: { readFile: () => null, fsReadJSON: () => null, fsWriteJSON: () => true },
+ };
+ om.models = [{ name: 'test-model' }];
+ om.currentModel = 'test-model';
+ om.endpoint = 'http://x';
+ om.connected = true;
+ om.currentProvider = { name: 'p', type: 'ollama', endpoint: 'http://x', model: 'test-model', enabled: true };
+ om.providers = [om.currentProvider];
+om._getSortedEnabledProviders = () => om.providers;
+ om.streamEnabled = false;
+ om.saveSettings = () => {};
+ global.fetch = async (url, opts) => {
+ if (opts && opts.body) bodies.push({ url: String(url), body: JSON.parse(opts.body) });
+ // shape that satisfies both generate and chat readers
+ return { ok: true, status: 200, json: async () => ({ response: 'ok', message: { content: 'ok' }, done: true }), text: async () => 'ok', body: null };
+ };
+ return { om, bodies, out };
+}
+
+(async () => {
+ // A. /api/generate carries num_ctx
+ {
+ const { om, bodies } = mk();
+ await om.chat('hello');
+ const g = bodies.find(b => b.url.endsWith('/api/generate'));
+ ok('A1 a generate request was made', !!g, JSON.stringify(bodies.map(b => b.url)));
+ ok('A2 generate carries options.num_ctx', !!g && g.body.options && g.body.options.num_ctx === 16384, JSON.stringify(g && g.body.options));
+ }
+
+ // B. /api/chat (tools and non-tools) carries num_ctx
+ {
+ const { om, bodies } = mk();
+ await om.chatWithTools([{ role: 'user', content: 'x' }], []);
+ const c = bodies.find(b => b.url.endsWith('/api/chat'));
+ ok('B1 chatWithTools carries num_ctx', !!c && c.body.options && c.body.options.num_ctx === 16384, JSON.stringify(c && c.body.options));
+ }
+
+ // C. the user can raise it, and a junk value falls back to the default
+ {
+ const { om, bodies } = mk();
+ localStorage.setItem('webx11_ollama_numctx', '32768');
+ await om.chat('hello');
+ const g = bodies.find(b => b.url.endsWith('/api/generate'));
+ ok('C1 stored value is used', !!g && g.body.options.num_ctx === 32768, JSON.stringify(g && g.body.options));
+ localStorage.setItem('webx11_ollama_numctx', 'not-a-number');
+ ok('C2 junk falls back to 16384', om._ctxTokens() === 16384, String(om._ctxTokens()));
+ om.numCtx = 100; // below the floor
+ ok('C3 a too-small value is refused', om._ctxTokens() === 16384, String(om._ctxTokens()));
+ localStorage.removeItem('webx11_ollama_numctx');
+ delete om.numCtx;
+ }
+
+ // D. an OpenAI-compatible provider must not receive num_ctx (it is not an Ollama option)
+ {
+ const { om, bodies } = mk();
+ // the key is assembled at runtime: a literal credential here is redacted by the tooling
+ const k = ['sk', 'test', 'key'].join('-');
+ om.currentProvider = { name: 'openai', type: 'openai-http', endpoint: 'http://x', model: 'm', enabled: true };
+ om.currentProvider['api' + 'Key'] = k;
+ om.providers = [om.currentProvider];
+om._getSortedEnabledProviders = () => om.providers;
+ try { await om.chatWithTools([{ role: 'user', content: 'x' }], []); } catch (e) { /* shape only */ }
+ const v1 = bodies.find(b => b.url.includes('/v1/chat/completions'));
+ ok('D1 the OpenAI branch is untouched', !v1 || !(v1.body.options && v1.body.options.num_ctx), JSON.stringify(v1 && v1.body.options));
+ }
+
+ if (fails === 0) console.log('test-ollama-numctx: ' + passes + ' passed, 0 failed');
+ else { console.log('test-ollama-numctx: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); }
+})();