Эх сурвалжийг харах

feat(nodes): add generic Ollama Chat node

Adds nodes/ai/ollama-chat.js, a task-agnostic node for Ollama instances.
Model, system prompt, user prompt, temperature and endpoint are all
configuration, so a workflow decides what the call is for rather than the
node hardcoding one job.

Supports vision models by auto-detecting base64 image data in the upstream
output, matching the convention the image node already uses, with an
optional dot-path override. Response Format json parses the reply and
strips markdown fences, since cloud-routed models tend to ignore Ollama's
format schema and answer with a fenced block. Retry count, retry delay and
skip-on-error make it usable inside a loop, where one flaky call should not
abort the whole run.
fszontagh 1 сар өмнө
parent
commit
afe3ab3b0f
1 өөрчлөгдсөн 335 нэмэгдсэн , 0 устгасан
  1. 335 0
      nodes/ai/ollama-chat.js

+ 335 - 0
nodes/ai/ollama-chat.js

@@ -0,0 +1,335 @@
+/**
+ * @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 bot
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        baseUrl: {
+            type: 'string',
+            title: 'Ollama Base URL',
+            description: 'Base URL of the Ollama instance',
+            default: 'http://localhost:11434'
+        },
+        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: {
+            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 each retry attempt',
+            default: 2000
+        },
+        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;
+}
+
+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;
+        }
+    }
+
+    const roots = [input, input && input.data];
+    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 callOllama(config, base64) {
+    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
+    };
+
+    const response = smartbotic.http.request({
+        method: 'POST',
+        url: String(config.baseUrl).replace(/\/+$/, '') + '/api/chat',
+        headers: { 'Content-Type': 'application/json' },
+        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);
+        throw new Error('Ollama HTTP ' + response.status + ': ' + detail);
+    }
+
+    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) : '';
+            }
+        }
+
+        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;
+
+        for (let attempt = 1; attempt <= attempts; attempt++) {
+            used = attempt;
+            try {
+                content = callOllama(config, image.base64);
+                if (wantJson) {
+                    parsed = JSON.parse(stripFences(content));
+                }
+                lastError = '';
+                break;
+            } catch (err) {
+                lastError = err && err.message ? err.message : String(err);
+                parsed = null;
+                smartbotic.log.warn('ollama-chat: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError);
+                if (attempt < attempts) {
+                    smartbotic.utils.sleep(Number(config.retryDelayMs) || 2000);
+                }
+            }
+        }
+
+        const passImage = config.passthroughImage === true;
+
+        if (lastError) {
+            if (config.skipOnError !== true) {
+                throw new Error('ollama-chat failed after ' + used + ' attempt(s): ' + lastError);
+            }
+            smartbotic.log.warn('ollama-chat: skipping after ' + used + ' attempt(s)');
+            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
+        };
+    }
+};