aboutsummaryrefslogtreecommitdiff
path: root/modules/core/ollama-manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/ollama-manager.js')
-rw-r--r--modules/core/ollama-manager.js842
1 files changed, 842 insertions, 0 deletions
diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js
new file mode 100644
index 0000000..c790e6f
--- /dev/null
+++ b/modules/core/ollama-manager.js
@@ -0,0 +1,842 @@
+// 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;
+ // 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 ─────────────────────────────────────────────────
+
+ _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));
+ }
+
+ _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);
+ 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);
+ 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 {
+ 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);
+ this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
+ }
+ }
+
+ this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers');
+ this.connected = false;
+ return false;
+ }
+
+ // ─── Chat (non-streaming) ────────────────────────────────────────────────
+
+ async chat(prompt, model = null) {
+ if (!this.connected) {
+ this.terminal.print('Ollama not connected. Use ollamaconnect first.', '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) {
+ this.terminal.print(`Ollama error: No provider responded`, 'error');
+ }
+ return null;
+ }
+
+ async _chatWithProvider(provider, prompt, model) {
+ const useModel = model || provider.model || this.currentModel;
+ let fullText = '';
+ try {
+ if (provider.type === 'ollama') {
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ const response = await fetch(`${provider.endpoint}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let isFirstToken = true;
+
+ 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;
+ }
+ } catch (e) {
+ // Skip malformed partial lines
+ }
+ }
+ }
+ return fullText || null;
+ } else {
+ // OpenAI-compatible provider
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ 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: true
+ }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let isFirstToken = true;
+ let buffer = '';
+
+ 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;
+ }
+ } catch (e) {
+ // Skip malformed SSE lines
+ }
+ }
+ }
+ }
+ return fullText || null;
+ }
+ } catch (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
+ }
+ }
+
+ // ─── Chat Stream ─────────────────────────────────────────────────────────
+
+ async chatStream(prompt, onToken, onComplete, model = null) {
+ if (!this.connected) { this.terminal.print('Ollama not connected.', '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;
+ try {
+ if (provider.type === 'ollama') {
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ const response = await fetch(`${provider.endpoint}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let fullResponse = '';
+ let buffer = '';
+ 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);
+ }
+ } catch (e) { continue; }
+ }
+ }
+ if (buffer.trim()) {
+ try {
+ const obj = JSON.parse(buffer);
+ if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); }
+ } catch (e) {}
+ }
+ this.fireResponseHooks(fullResponse);
+ onComplete && onComplete(fullResponse);
+ return fullResponse;
+ } else {
+ // OpenAI-compatible provider
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ 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: true
+ }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let fullResponse = '';
+ let buffer = '';
+
+ 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);
+ }
+ } catch (e) { continue; }
+ }
+ }
+ }
+ 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;
+ }
+ }
+
+ // ─── Chat With Tools ─────────────────────────────────────────────────────
+
+ async chatWithTools(messages, tools, model = null) {
+ if (!this.connected) {
+ this.terminal.print('Ollama not connected. Use ollamaconnect first.', '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;
+ }
+ }
+
+ return lastError || { message: null, tool_calls: null, error: '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 };
+ 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
+ });
+ this._abortController = null;
+ 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})`);
+ return {
+ message: data.message || null,
+ tool_calls: data.message?.tool_calls || null,
+ error: null
+ };
+ } else {
+ // OpenAI-compatible provider
+ const headers = { 'Content-Type': 'application/json' };
+ if (provider.apiKey) {
+ headers['Authorization'] = 'Bearer ' + provider.apiKey;
+ }
+ const body = {
+ model: useModel,
+ messages: messages,
+ stream: false
+ };
+ 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
+ });
+ this._abortController = null;
+ 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})`);
+ const choice = data.choices?.[0];
+ const message = choice?.message || null;
+ const toolCalls = message?.tool_calls || null;
+ return {
+ message: message ? { role: message.role || 'assistant', content: message.content || '' } : 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');
+ 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);
+ 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 || '';
+ 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 };
+ }
+ }
+
+ // 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 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 || '';
+ 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 };
+ }
+ }
+
+ // ─── 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);
+ }
+
+ 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;
+ }
+}