| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 |
- /**
- * @node ollama-chat
- * @name Ollama Chat
- * @category ai
- * @version 1.0.0
- * @description Send a prompt to an Ollama model, optionally with an image for vision models, and return the text or parsed JSON response
- * @icon ollama
- */
- const configSchema = {
- type: 'object',
- properties: {
- baseUrl: {
- type: 'string',
- title: 'Ollama Base URL',
- description: 'Base URL of the Ollama instance. Use https://ollama.com to call the hosted API directly instead of proxying through a local daemon.',
- default: 'http://localhost:11434'
- },
- authType: {
- type: 'string',
- title: 'Authentication',
- description: 'Hosted ollama.com requires a credential. A local daemon usually does not.',
- enum: ['none', 'credential'],
- enumLabels: ['None', 'Stored credential'],
- default: 'none'
- },
- credentialId: {
- type: 'string',
- title: 'Credential',
- description: 'Stored Bearer credential holding the ollama.com API key',
- // Read as a header, so any header-shaped credential works. An IMAP
- // or database credential has no header to send and would only be
- // an option that fails.
- dynamicOptions: {
- source: 'credentials',
- filter: { type: ['basic', 'bearer', 'api_key', 'oauth2'] }
- },
- default: '',
- showWhen: { field: 'authType', value: 'credential' }
- },
- model: {
- type: 'string',
- title: 'Model',
- description: 'Model tag, for example llama3.2 or a vision model tag',
- default: 'llama3.2'
- },
- systemPrompt: {
- type: 'string',
- title: 'System Prompt',
- description: 'Role and output contract for the model. When Response Format is JSON, mention the word JSON here so the model honours it.',
- format: 'textarea',
- default: ''
- },
- userPrompt: {
- type: 'string',
- title: 'User Prompt',
- description: 'Message sent with the request. Supports {{variable}} interpolation.',
- format: 'textarea',
- default: 'Describe the input.'
- },
- responseFormat: {
- type: 'string',
- title: 'Response Format',
- description: 'How to treat the reply. JSON parses the content and fails the attempt when it is not valid JSON.',
- enum: ['text', 'json'],
- enumLabels: ['Plain text', 'JSON object'],
- default: 'text'
- },
- temperature: {
- type: 'number',
- title: 'Temperature',
- description: 'Sampling temperature. Use 0 for the most repeatable output.',
- default: 0
- },
- imageMode: {
- type: 'string',
- title: 'Image Input',
- description: 'Auto picks up binary or base64 image data from the previous node. Use None for text-only models.',
- enum: ['auto', 'none'],
- enumLabels: ['Auto detect', 'None'],
- default: 'auto'
- },
- imageField: {
- valueKind: 'path',
- type: 'string',
- title: 'Image Field Override',
- description: 'Optional dot path to base64 image data, for example data.file.data',
- default: '',
- showWhen: { field: 'imageMode', value: 'auto' }
- },
- passthroughImage: {
- type: 'boolean',
- title: 'Pass Image Through',
- description: 'Include the base64 image in the output so later nodes can store or reuse it',
- default: false
- },
- retryCount: {
- type: 'number',
- title: 'Retries',
- description: 'Extra attempts after a failed or unparseable response',
- default: 0
- },
- retryDelayMs: {
- type: 'number',
- title: 'Retry Delay (ms)',
- description: 'Pause before the first retry. Doubles on each further attempt.',
- default: 2000
- },
- retryMaxDelayMs: {
- type: 'number',
- title: 'Max Retry Delay (ms)',
- description: 'Upper bound for the exponential backoff',
- default: 30000
- },
- skipOnError: {
- type: 'boolean',
- title: 'Skip On Error',
- description: 'Return success false instead of failing the workflow. Useful inside a loop.',
- default: false
- },
- timeoutMs: {
- type: 'number',
- title: 'Timeout (ms)',
- description: 'Per-attempt request timeout',
- default: 120000
- }
- },
- required: ['baseUrl', 'model']
- };
- const inputSchema = {
- type: 'object',
- properties: {
- data: {
- type: 'any',
- description: 'Upstream output. Image data is detected here when Image Input is Auto.'
- },
- file: {
- type: 'object',
- description: 'Binary file object with base64 data'
- },
- base64: {
- type: 'string',
- description: 'Raw base64-encoded image'
- },
- url: {
- type: 'string',
- description: 'Image URL, fetched when no binary data is present'
- }
- }
- };
- const outputSchema = {
- type: 'object',
- properties: {
- success: { type: 'boolean', description: 'False when the call failed and Skip On Error is enabled' },
- error: { type: 'string', description: 'Failure reason when success is false' },
- content: { type: 'string', description: 'Raw text reply from the model' },
- json: { type: 'any', description: 'Parsed reply when Response Format is JSON' },
- model: { type: 'string', description: 'Model that answered' },
- attempts: { type: 'number', description: 'How many attempts were made' },
- hadImage: { type: 'boolean', description: 'Whether an image was sent' },
- imageBase64: { type: 'string', description: 'Base64 image when Pass Image Through is enabled' },
- mimeType: { type: 'string', description: 'Image MIME type when known' },
- sourceUrl: { type: 'string', description: 'Originating image URL when known' }
- }
- };
- function getPath(root, path) {
- if (!root || !path) {
- return undefined;
- }
- const parts = String(path).split('.');
- let current = root;
- for (let i = 0; i < parts.length; i++) {
- if (current === null || typeof current !== 'object') {
- return undefined;
- }
- current = current[parts[i]];
- }
- return current;
- }
- // A model asked for JSON that returns something almost-JSON is repaired rather
- // than retried. The retry existed for a model having a bad moment, but where
- // the fault is deterministic - a model that always omits the opening brace -
- // every extra attempt is another paid call for the same malformed answer.
- // What was repaired is logged, so a model that has started ignoring the schema
- // is visible rather than quietly patched over on every run.
- function parseModelJson(text, provider) {
- var repaired = smartbotic.utils.repairJson(text);
- if (!repaired.ok) {
- // Nothing salvageable - let the strict parser raise the real message.
- return JSON.parse(text);
- }
- if (repaired.repairs.length > 0) {
- smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
- repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
- }
- return repaired.value;
- }
- function stripFences(text) {
- const out = String(text || '').trim();
- if (out.indexOf('```') === -1) {
- return out;
- }
- const first = out.indexOf('{');
- const last = out.lastIndexOf('}');
- if (first !== -1 && last !== -1 && last > first) {
- return out.substring(first, last + 1);
- }
- const firstArr = out.indexOf('[');
- const lastArr = out.lastIndexOf(']');
- if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
- return out.substring(firstArr, lastArr + 1);
- }
- return out;
- }
- function findImage(input, override) {
- const result = { base64: '', mimeType: '', url: '' };
- if (override) {
- const direct = getPath(input, override);
- if (typeof direct === 'string' && direct.length > 0) {
- result.base64 = direct;
- return result;
- }
- }
- // Collect candidate roots breadth-first: inside a loop, or when another node
- // sits between the fetch and this one, the file object is nested rather than
- // sitting at input or input.data.
- const roots = [];
- const queue = [input];
- let guard = 0;
- while (queue.length > 0 && guard < 64) {
- guard++;
- const node = queue.shift();
- if (!node || typeof node !== 'object') {
- continue;
- }
- roots.push(node);
- const keys = Object.keys(node);
- for (let k = 0; k < keys.length; k++) {
- const child = node[keys[k]];
- if (child && typeof child === 'object' && keys[k] !== 'file') {
- queue.push(child);
- }
- }
- }
- for (let i = 0; i < roots.length; i++) {
- const root = roots[i];
- if (!root || typeof root !== 'object') {
- continue;
- }
- if (!result.base64 && root.file && typeof root.file.data === 'string') {
- result.base64 = root.file.data;
- result.mimeType = root.file.mimeType || '';
- }
- if (!result.base64 && typeof root.base64 === 'string') {
- result.base64 = root.base64;
- }
- if (!result.base64 && typeof root.imageBase64 === 'string') {
- result.base64 = root.imageBase64;
- }
- if (!result.url && typeof root.url === 'string') {
- result.url = root.url;
- }
- if (!result.url && typeof root.sourceUrl === 'string') {
- result.url = root.sourceUrl;
- }
- }
- return result;
- }
- function buildHeaders(config) {
- const headers = { 'Content-Type': 'application/json' };
- if (config.authType === 'credential' && config.credentialId) {
- const auth = smartbotic.credentials.get(config.credentialId);
- if (!auth.success) {
- throw new Error('Failed to load credential: ' + auth.error);
- }
- headers[auth.headerName] = auth.headerValue;
- }
- return headers;
- }
- // Statuses no amount of retrying can get past.
- //
- // 429 is deliberately NOT here: a rate limit is exactly what backing off is
- // for. Neither are 500 and 502 - Ollama's cloud returns those transiently, and
- // spacing the retries out recovers from them.
- const PERMANENT_STATUSES = [
- 400, // bad request - the same payload will be just as bad next time
- 401, // unauthorized - no key, or one the server will not accept
- 403, // refused: no subscription for this model, or the allowance is spent
- 404 // no such model
- ];
- function callOllama(config, base64, headers) {
- const messages = [];
- if (config.systemPrompt && String(config.systemPrompt).trim().length > 0) {
- messages.push({ role: 'system', content: String(config.systemPrompt) });
- }
- const userMessage = { role: 'user', content: config.userPrompt || 'Describe the input.' };
- if (base64) {
- userMessage.images = [base64];
- }
- messages.push(userMessage);
- const payload = {
- model: config.model,
- stream: false,
- options: { temperature: Number(config.temperature) || 0 },
- messages: messages
- };
- // Ask the server for JSON, rather than only checking afterwards whether we
- // got any.
- //
- // "JSON object" used to mean nothing more than "parse the reply and fail
- // the attempt if it does not parse". The model was never told, so nothing
- // stopped it emitting almost-JSON - and a reply missing one opening quote
- //
- // { "title_hu":Tuzfenyes ejszakai olelkezes", "title_en": "..."
- //
- // failed all three attempts and took the workflow down with it. Ollama's
- // format parameter constrains decoding so a malformed reply cannot be
- // produced in the first place; the parse below then only has to deal with
- // an empty or truncated response.
- if (config.responseFormat === 'json') {
- payload.format = 'json';
- }
- const response = smartbotic.http.request({
- method: 'POST',
- url: String(config.baseUrl).replace(/\/+$/, '') + '/api/chat',
- headers: headers,
- body: JSON.stringify(payload),
- timeout: Number(config.timeoutMs) || 120000
- });
- if (response.status < 200 || response.status >= 300) {
- const detail = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
- const error = new Error('Ollama HTTP ' + response.status + ': ' + detail);
- // Whether trying again could possibly help.
- //
- // Ollama documents 400, 404, 429, 500 and 502; 401 and 403 are not in
- // the documentation but both come back from the cloud endpoint - 401
- // with no key, 403 when the key is fine and the account is refused,
- // which is what an exhausted weekly allowance looks like.
- //
- // Nothing in the permanent set changes because we ask again a few
- // seconds later: the request is malformed, the model does not exist,
- // the key is wrong, or the plan says no. Retrying those only delays
- // the report and spends more of whatever ran out.
- error.permanent = PERMANENT_STATUSES.indexOf(response.status) !== -1;
- error.status = response.status;
- throw error;
- }
- const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
- if (body && body.error) {
- throw new Error('Ollama error: ' + body.error);
- }
- const content = body && body.message ? body.message.content : '';
- if (!content) {
- throw new Error('Ollama returned an empty response');
- }
- return content;
- }
- module.exports = {
- configSchema,
- inputSchema,
- outputSchema,
- async execute(config, input, context) {
- let image = { base64: '', mimeType: '', url: '' };
- if (config.imageMode !== 'none') {
- image = findImage(input, config.imageField);
- if (!image.base64 && image.url) {
- smartbotic.log.info('ollama-chat: fetching image from ' + image.url);
- const dl = smartbotic.http.request({ method: 'GET', url: image.url, timeout: 60000 });
- if (dl.status < 200 || dl.status >= 300) {
- throw new Error('Failed to fetch image: HTTP ' + dl.status);
- }
- image.base64 = typeof dl.data === 'string' ? smartbotic.utils.base64Encode(dl.data) : '';
- }
- }
- // Resolved once, outside the retry loop: a missing or broken credential is
- // not transient, so retrying it with backoff only wastes time.
- const headers = buildHeaders(config);
- const wantJson = config.responseFormat === 'json';
- const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
- let content = '';
- let parsed = null;
- let lastError = '';
- let used = 0;
- let permanent = false;
- for (let attempt = 1; attempt <= attempts; attempt++) {
- used = attempt;
- try {
- content = callOllama(config, image.base64, headers);
- if (wantJson) {
- parsed = parseModelJson(stripFences(content), 'Ollama');
- }
- lastError = '';
- break;
- } catch (err) {
- lastError = err && err.message ? err.message : String(err);
- parsed = null;
- if (err && err.permanent === true) {
- // Said once, and said as what it is. Reporting "failed after
- // 3 attempts" for a refusal invites the reader to wonder
- // what was flaky, when nothing was.
- smartbotic.log.warn('ollama-chat: ' + lastError);
- permanent = true;
- break;
- }
- smartbotic.log.warn('ollama-chat: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError);
- if (attempt < attempts) {
- // Exponential backoff. Ollama's cloud tier returns transient 500s far
- // more often under rapid succession, so spacing retries out recovers
- // markedly better than hammering at a fixed interval.
- const base = Number(config.retryDelayMs) || 2000;
- const cap = Number(config.retryMaxDelayMs) || 30000;
- let delay = base * Math.pow(2, attempt - 1);
- if (delay > cap) { delay = cap; }
- smartbotic.log.info('ollama-chat: backing off ' + delay + 'ms before retry');
- smartbotic.utils.sleep(delay);
- }
- }
- }
- const passImage = config.passthroughImage === true;
- if (lastError) {
- // A refusal is reported as a refusal. "Failed after 3 attempts"
- // reads as something flaky that might work next time, which sends
- // whoever gets the alert looking for a fault that is not there -
- // the answer to a spent allowance or a wrong key is not to run it
- // again.
- const summary = permanent
- ? 'ollama-chat was refused: ' + lastError
- : 'ollama-chat failed after ' + used + ' attempt(s): ' + lastError;
- if (config.skipOnError !== true) {
- throw new Error(summary);
- }
- smartbotic.log.warn('ollama-chat: skipping - ' + summary);
- return {
- success: false,
- error: lastError,
- content: content,
- json: null,
- model: config.model,
- attempts: used,
- hadImage: image.base64 ? true : false,
- imageBase64: passImage ? image.base64 : '',
- mimeType: image.mimeType,
- sourceUrl: image.url
- };
- }
- return {
- success: true,
- error: '',
- content: content,
- json: parsed,
- model: config.model,
- attempts: used,
- hadImage: image.base64 ? true : false,
- imageBase64: passImage ? image.base64 : '',
- mimeType: image.mimeType,
- sourceUrl: image.url
- };
- }
- };
|