ollama-chat.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. /**
  2. * @node ollama-chat
  3. * @name Ollama Chat
  4. * @category ai
  5. * @version 1.0.0
  6. * @description Send a prompt to an Ollama model, optionally with an image for vision models, and return the text or parsed JSON response
  7. * @icon bot
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. baseUrl: {
  13. type: 'string',
  14. title: 'Ollama Base URL',
  15. description: 'Base URL of the Ollama instance. Use https://ollama.com to call the hosted API directly instead of proxying through a local daemon.',
  16. default: 'http://localhost:11434'
  17. },
  18. authType: {
  19. type: 'string',
  20. title: 'Authentication',
  21. description: 'Hosted ollama.com requires a credential. A local daemon usually does not.',
  22. enum: ['none', 'credential'],
  23. enumLabels: ['None', 'Stored credential'],
  24. default: 'none'
  25. },
  26. credentialId: {
  27. type: 'string',
  28. title: 'Credential',
  29. description: 'Stored Bearer credential holding the ollama.com API key',
  30. default: '',
  31. showWhen: { field: 'authType', value: 'credential' }
  32. },
  33. model: {
  34. type: 'string',
  35. title: 'Model',
  36. description: 'Model tag, for example llama3.2 or a vision model tag',
  37. default: 'llama3.2'
  38. },
  39. systemPrompt: {
  40. type: 'string',
  41. title: 'System Prompt',
  42. description: 'Role and output contract for the model. When Response Format is JSON, mention the word JSON here so the model honours it.',
  43. format: 'textarea',
  44. default: ''
  45. },
  46. userPrompt: {
  47. type: 'string',
  48. title: 'User Prompt',
  49. description: 'Message sent with the request. Supports {{variable}} interpolation.',
  50. format: 'textarea',
  51. default: 'Describe the input.'
  52. },
  53. responseFormat: {
  54. type: 'string',
  55. title: 'Response Format',
  56. description: 'How to treat the reply. JSON parses the content and fails the attempt when it is not valid JSON.',
  57. enum: ['text', 'json'],
  58. enumLabels: ['Plain text', 'JSON object'],
  59. default: 'text'
  60. },
  61. temperature: {
  62. type: 'number',
  63. title: 'Temperature',
  64. description: 'Sampling temperature. Use 0 for the most repeatable output.',
  65. default: 0
  66. },
  67. imageMode: {
  68. type: 'string',
  69. title: 'Image Input',
  70. description: 'Auto picks up binary or base64 image data from the previous node. Use None for text-only models.',
  71. enum: ['auto', 'none'],
  72. enumLabels: ['Auto detect', 'None'],
  73. default: 'auto'
  74. },
  75. imageField: {
  76. valueKind: 'path',
  77. type: 'string',
  78. title: 'Image Field Override',
  79. description: 'Optional dot path to base64 image data, for example data.file.data',
  80. default: '',
  81. showWhen: { field: 'imageMode', value: 'auto' }
  82. },
  83. passthroughImage: {
  84. type: 'boolean',
  85. title: 'Pass Image Through',
  86. description: 'Include the base64 image in the output so later nodes can store or reuse it',
  87. default: false
  88. },
  89. retryCount: {
  90. type: 'number',
  91. title: 'Retries',
  92. description: 'Extra attempts after a failed or unparseable response',
  93. default: 0
  94. },
  95. retryDelayMs: {
  96. type: 'number',
  97. title: 'Retry Delay (ms)',
  98. description: 'Pause before the first retry. Doubles on each further attempt.',
  99. default: 2000
  100. },
  101. retryMaxDelayMs: {
  102. type: 'number',
  103. title: 'Max Retry Delay (ms)',
  104. description: 'Upper bound for the exponential backoff',
  105. default: 30000
  106. },
  107. skipOnError: {
  108. type: 'boolean',
  109. title: 'Skip On Error',
  110. description: 'Return success false instead of failing the workflow. Useful inside a loop.',
  111. default: false
  112. },
  113. timeoutMs: {
  114. type: 'number',
  115. title: 'Timeout (ms)',
  116. description: 'Per-attempt request timeout',
  117. default: 120000
  118. }
  119. },
  120. required: ['baseUrl', 'model']
  121. };
  122. const inputSchema = {
  123. type: 'object',
  124. properties: {
  125. data: {
  126. type: 'any',
  127. description: 'Upstream output. Image data is detected here when Image Input is Auto.'
  128. },
  129. file: {
  130. type: 'object',
  131. description: 'Binary file object with base64 data'
  132. },
  133. base64: {
  134. type: 'string',
  135. description: 'Raw base64-encoded image'
  136. },
  137. url: {
  138. type: 'string',
  139. description: 'Image URL, fetched when no binary data is present'
  140. }
  141. }
  142. };
  143. const outputSchema = {
  144. type: 'object',
  145. properties: {
  146. success: { type: 'boolean', description: 'False when the call failed and Skip On Error is enabled' },
  147. error: { type: 'string', description: 'Failure reason when success is false' },
  148. content: { type: 'string', description: 'Raw text reply from the model' },
  149. json: { type: 'any', description: 'Parsed reply when Response Format is JSON' },
  150. model: { type: 'string', description: 'Model that answered' },
  151. attempts: { type: 'number', description: 'How many attempts were made' },
  152. hadImage: { type: 'boolean', description: 'Whether an image was sent' },
  153. imageBase64: { type: 'string', description: 'Base64 image when Pass Image Through is enabled' },
  154. mimeType: { type: 'string', description: 'Image MIME type when known' },
  155. sourceUrl: { type: 'string', description: 'Originating image URL when known' }
  156. }
  157. };
  158. function getPath(root, path) {
  159. if (!root || !path) {
  160. return undefined;
  161. }
  162. const parts = String(path).split('.');
  163. let current = root;
  164. for (let i = 0; i < parts.length; i++) {
  165. if (current === null || typeof current !== 'object') {
  166. return undefined;
  167. }
  168. current = current[parts[i]];
  169. }
  170. return current;
  171. }
  172. function stripFences(text) {
  173. const out = String(text || '').trim();
  174. if (out.indexOf('```') === -1) {
  175. return out;
  176. }
  177. const first = out.indexOf('{');
  178. const last = out.lastIndexOf('}');
  179. if (first !== -1 && last !== -1 && last > first) {
  180. return out.substring(first, last + 1);
  181. }
  182. const firstArr = out.indexOf('[');
  183. const lastArr = out.lastIndexOf(']');
  184. if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
  185. return out.substring(firstArr, lastArr + 1);
  186. }
  187. return out;
  188. }
  189. function findImage(input, override) {
  190. const result = { base64: '', mimeType: '', url: '' };
  191. if (override) {
  192. const direct = getPath(input, override);
  193. if (typeof direct === 'string' && direct.length > 0) {
  194. result.base64 = direct;
  195. return result;
  196. }
  197. }
  198. // Collect candidate roots breadth-first: inside a loop, or when another node
  199. // sits between the fetch and this one, the file object is nested rather than
  200. // sitting at input or input.data.
  201. const roots = [];
  202. const queue = [input];
  203. let guard = 0;
  204. while (queue.length > 0 && guard < 64) {
  205. guard++;
  206. const node = queue.shift();
  207. if (!node || typeof node !== 'object') {
  208. continue;
  209. }
  210. roots.push(node);
  211. const keys = Object.keys(node);
  212. for (let k = 0; k < keys.length; k++) {
  213. const child = node[keys[k]];
  214. if (child && typeof child === 'object' && keys[k] !== 'file') {
  215. queue.push(child);
  216. }
  217. }
  218. }
  219. for (let i = 0; i < roots.length; i++) {
  220. const root = roots[i];
  221. if (!root || typeof root !== 'object') {
  222. continue;
  223. }
  224. if (!result.base64 && root.file && typeof root.file.data === 'string') {
  225. result.base64 = root.file.data;
  226. result.mimeType = root.file.mimeType || '';
  227. }
  228. if (!result.base64 && typeof root.base64 === 'string') {
  229. result.base64 = root.base64;
  230. }
  231. if (!result.base64 && typeof root.imageBase64 === 'string') {
  232. result.base64 = root.imageBase64;
  233. }
  234. if (!result.url && typeof root.url === 'string') {
  235. result.url = root.url;
  236. }
  237. if (!result.url && typeof root.sourceUrl === 'string') {
  238. result.url = root.sourceUrl;
  239. }
  240. }
  241. return result;
  242. }
  243. function buildHeaders(config) {
  244. const headers = { 'Content-Type': 'application/json' };
  245. if (config.authType === 'credential' && config.credentialId) {
  246. const auth = smartbotic.credentials.get(config.credentialId);
  247. if (!auth.success) {
  248. throw new Error('Failed to load credential: ' + auth.error);
  249. }
  250. headers[auth.headerName] = auth.headerValue;
  251. }
  252. return headers;
  253. }
  254. function callOllama(config, base64, headers) {
  255. const messages = [];
  256. if (config.systemPrompt && String(config.systemPrompt).trim().length > 0) {
  257. messages.push({ role: 'system', content: String(config.systemPrompt) });
  258. }
  259. const userMessage = { role: 'user', content: config.userPrompt || 'Describe the input.' };
  260. if (base64) {
  261. userMessage.images = [base64];
  262. }
  263. messages.push(userMessage);
  264. const payload = {
  265. model: config.model,
  266. stream: false,
  267. options: { temperature: Number(config.temperature) || 0 },
  268. messages: messages
  269. };
  270. const response = smartbotic.http.request({
  271. method: 'POST',
  272. url: String(config.baseUrl).replace(/\/+$/, '') + '/api/chat',
  273. headers: headers,
  274. body: JSON.stringify(payload),
  275. timeout: Number(config.timeoutMs) || 120000
  276. });
  277. if (response.status < 200 || response.status >= 300) {
  278. const detail = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  279. throw new Error('Ollama HTTP ' + response.status + ': ' + detail);
  280. }
  281. const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
  282. if (body && body.error) {
  283. throw new Error('Ollama error: ' + body.error);
  284. }
  285. const content = body && body.message ? body.message.content : '';
  286. if (!content) {
  287. throw new Error('Ollama returned an empty response');
  288. }
  289. return content;
  290. }
  291. module.exports = {
  292. configSchema,
  293. inputSchema,
  294. outputSchema,
  295. async execute(config, input, context) {
  296. let image = { base64: '', mimeType: '', url: '' };
  297. if (config.imageMode !== 'none') {
  298. image = findImage(input, config.imageField);
  299. if (!image.base64 && image.url) {
  300. smartbotic.log.info('ollama-chat: fetching image from ' + image.url);
  301. const dl = smartbotic.http.request({ method: 'GET', url: image.url, timeout: 60000 });
  302. if (dl.status < 200 || dl.status >= 300) {
  303. throw new Error('Failed to fetch image: HTTP ' + dl.status);
  304. }
  305. image.base64 = typeof dl.data === 'string' ? smartbotic.utils.base64Encode(dl.data) : '';
  306. }
  307. }
  308. // Resolved once, outside the retry loop: a missing or broken credential is
  309. // not transient, so retrying it with backoff only wastes time.
  310. const headers = buildHeaders(config);
  311. const wantJson = config.responseFormat === 'json';
  312. const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
  313. let content = '';
  314. let parsed = null;
  315. let lastError = '';
  316. let used = 0;
  317. for (let attempt = 1; attempt <= attempts; attempt++) {
  318. used = attempt;
  319. try {
  320. content = callOllama(config, image.base64, headers);
  321. if (wantJson) {
  322. parsed = JSON.parse(stripFences(content));
  323. }
  324. lastError = '';
  325. break;
  326. } catch (err) {
  327. lastError = err && err.message ? err.message : String(err);
  328. parsed = null;
  329. smartbotic.log.warn('ollama-chat: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError);
  330. if (attempt < attempts) {
  331. // Exponential backoff. Ollama's cloud tier returns transient 500s far
  332. // more often under rapid succession, so spacing retries out recovers
  333. // markedly better than hammering at a fixed interval.
  334. const base = Number(config.retryDelayMs) || 2000;
  335. const cap = Number(config.retryMaxDelayMs) || 30000;
  336. let delay = base * Math.pow(2, attempt - 1);
  337. if (delay > cap) { delay = cap; }
  338. smartbotic.log.info('ollama-chat: backing off ' + delay + 'ms before retry');
  339. smartbotic.utils.sleep(delay);
  340. }
  341. }
  342. }
  343. const passImage = config.passthroughImage === true;
  344. if (lastError) {
  345. if (config.skipOnError !== true) {
  346. throw new Error('ollama-chat failed after ' + used + ' attempt(s): ' + lastError);
  347. }
  348. smartbotic.log.warn('ollama-chat: skipping after ' + used + ' attempt(s)');
  349. return {
  350. success: false,
  351. error: lastError,
  352. content: content,
  353. json: null,
  354. model: config.model,
  355. attempts: used,
  356. hadImage: image.base64 ? true : false,
  357. imageBase64: passImage ? image.base64 : '',
  358. mimeType: image.mimeType,
  359. sourceUrl: image.url
  360. };
  361. }
  362. return {
  363. success: true,
  364. error: '',
  365. content: content,
  366. json: parsed,
  367. model: config.model,
  368. attempts: used,
  369. hadImage: image.base64 ? true : false,
  370. imageBase64: passImage ? image.base64 : '',
  371. mimeType: image.mimeType,
  372. sourceUrl: image.url
  373. };
  374. }
  375. };