|
|
@@ -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
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|