ollama-chat.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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',
  16. default: 'http://localhost:11434'
  17. },
  18. model: {
  19. type: 'string',
  20. title: 'Model',
  21. description: 'Model tag, for example llama3.2 or a vision model tag',
  22. default: 'llama3.2'
  23. },
  24. systemPrompt: {
  25. type: 'string',
  26. title: 'System Prompt',
  27. description: 'Role and output contract for the model. When Response Format is JSON, mention the word JSON here so the model honours it.',
  28. format: 'textarea',
  29. default: ''
  30. },
  31. userPrompt: {
  32. type: 'string',
  33. title: 'User Prompt',
  34. description: 'Message sent with the request. Supports {{variable}} interpolation.',
  35. format: 'textarea',
  36. default: 'Describe the input.'
  37. },
  38. responseFormat: {
  39. type: 'string',
  40. title: 'Response Format',
  41. description: 'How to treat the reply. JSON parses the content and fails the attempt when it is not valid JSON.',
  42. enum: ['text', 'json'],
  43. enumLabels: ['Plain text', 'JSON object'],
  44. default: 'text'
  45. },
  46. temperature: {
  47. type: 'number',
  48. title: 'Temperature',
  49. description: 'Sampling temperature. Use 0 for the most repeatable output.',
  50. default: 0
  51. },
  52. imageMode: {
  53. type: 'string',
  54. title: 'Image Input',
  55. description: 'Auto picks up binary or base64 image data from the previous node. Use None for text-only models.',
  56. enum: ['auto', 'none'],
  57. enumLabels: ['Auto detect', 'None'],
  58. default: 'auto'
  59. },
  60. imageField: {
  61. type: 'string',
  62. title: 'Image Field Override',
  63. description: 'Optional dot path to base64 image data, for example data.file.data',
  64. default: '',
  65. showWhen: { field: 'imageMode', value: 'auto' }
  66. },
  67. passthroughImage: {
  68. type: 'boolean',
  69. title: 'Pass Image Through',
  70. description: 'Include the base64 image in the output so later nodes can store or reuse it',
  71. default: false
  72. },
  73. retryCount: {
  74. type: 'number',
  75. title: 'Retries',
  76. description: 'Extra attempts after a failed or unparseable response',
  77. default: 0
  78. },
  79. retryDelayMs: {
  80. type: 'number',
  81. title: 'Retry Delay (ms)',
  82. description: 'Pause before each retry attempt',
  83. default: 2000
  84. },
  85. skipOnError: {
  86. type: 'boolean',
  87. title: 'Skip On Error',
  88. description: 'Return success false instead of failing the workflow. Useful inside a loop.',
  89. default: false
  90. },
  91. timeoutMs: {
  92. type: 'number',
  93. title: 'Timeout (ms)',
  94. description: 'Per-attempt request timeout',
  95. default: 120000
  96. }
  97. },
  98. required: ['baseUrl', 'model']
  99. };
  100. const inputSchema = {
  101. type: 'object',
  102. properties: {
  103. data: {
  104. type: 'any',
  105. description: 'Upstream output. Image data is detected here when Image Input is Auto.'
  106. },
  107. file: {
  108. type: 'object',
  109. description: 'Binary file object with base64 data'
  110. },
  111. base64: {
  112. type: 'string',
  113. description: 'Raw base64-encoded image'
  114. },
  115. url: {
  116. type: 'string',
  117. description: 'Image URL, fetched when no binary data is present'
  118. }
  119. }
  120. };
  121. const outputSchema = {
  122. type: 'object',
  123. properties: {
  124. success: { type: 'boolean', description: 'False when the call failed and Skip On Error is enabled' },
  125. error: { type: 'string', description: 'Failure reason when success is false' },
  126. content: { type: 'string', description: 'Raw text reply from the model' },
  127. json: { type: 'any', description: 'Parsed reply when Response Format is JSON' },
  128. model: { type: 'string', description: 'Model that answered' },
  129. attempts: { type: 'number', description: 'How many attempts were made' },
  130. hadImage: { type: 'boolean', description: 'Whether an image was sent' },
  131. imageBase64: { type: 'string', description: 'Base64 image when Pass Image Through is enabled' },
  132. mimeType: { type: 'string', description: 'Image MIME type when known' },
  133. sourceUrl: { type: 'string', description: 'Originating image URL when known' }
  134. }
  135. };
  136. function getPath(root, path) {
  137. if (!root || !path) {
  138. return undefined;
  139. }
  140. const parts = String(path).split('.');
  141. let current = root;
  142. for (let i = 0; i < parts.length; i++) {
  143. if (current === null || typeof current !== 'object') {
  144. return undefined;
  145. }
  146. current = current[parts[i]];
  147. }
  148. return current;
  149. }
  150. function stripFences(text) {
  151. const out = String(text || '').trim();
  152. if (out.indexOf('```') === -1) {
  153. return out;
  154. }
  155. const first = out.indexOf('{');
  156. const last = out.lastIndexOf('}');
  157. if (first !== -1 && last !== -1 && last > first) {
  158. return out.substring(first, last + 1);
  159. }
  160. const firstArr = out.indexOf('[');
  161. const lastArr = out.lastIndexOf(']');
  162. if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
  163. return out.substring(firstArr, lastArr + 1);
  164. }
  165. return out;
  166. }
  167. function findImage(input, override) {
  168. const result = { base64: '', mimeType: '', url: '' };
  169. if (override) {
  170. const direct = getPath(input, override);
  171. if (typeof direct === 'string' && direct.length > 0) {
  172. result.base64 = direct;
  173. return result;
  174. }
  175. }
  176. const roots = [input, input && input.data];
  177. for (let i = 0; i < roots.length; i++) {
  178. const root = roots[i];
  179. if (!root || typeof root !== 'object') {
  180. continue;
  181. }
  182. if (!result.base64 && root.file && typeof root.file.data === 'string') {
  183. result.base64 = root.file.data;
  184. result.mimeType = root.file.mimeType || '';
  185. }
  186. if (!result.base64 && typeof root.base64 === 'string') {
  187. result.base64 = root.base64;
  188. }
  189. if (!result.base64 && typeof root.imageBase64 === 'string') {
  190. result.base64 = root.imageBase64;
  191. }
  192. if (!result.url && typeof root.url === 'string') {
  193. result.url = root.url;
  194. }
  195. if (!result.url && typeof root.sourceUrl === 'string') {
  196. result.url = root.sourceUrl;
  197. }
  198. }
  199. return result;
  200. }
  201. function callOllama(config, base64) {
  202. const messages = [];
  203. if (config.systemPrompt && String(config.systemPrompt).trim().length > 0) {
  204. messages.push({ role: 'system', content: String(config.systemPrompt) });
  205. }
  206. const userMessage = { role: 'user', content: config.userPrompt || 'Describe the input.' };
  207. if (base64) {
  208. userMessage.images = [base64];
  209. }
  210. messages.push(userMessage);
  211. const payload = {
  212. model: config.model,
  213. stream: false,
  214. options: { temperature: Number(config.temperature) || 0 },
  215. messages: messages
  216. };
  217. const response = smartbotic.http.request({
  218. method: 'POST',
  219. url: String(config.baseUrl).replace(/\/+$/, '') + '/api/chat',
  220. headers: { 'Content-Type': 'application/json' },
  221. body: JSON.stringify(payload),
  222. timeout: Number(config.timeoutMs) || 120000
  223. });
  224. if (response.status < 200 || response.status >= 300) {
  225. const detail = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  226. throw new Error('Ollama HTTP ' + response.status + ': ' + detail);
  227. }
  228. const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
  229. if (body && body.error) {
  230. throw new Error('Ollama error: ' + body.error);
  231. }
  232. const content = body && body.message ? body.message.content : '';
  233. if (!content) {
  234. throw new Error('Ollama returned an empty response');
  235. }
  236. return content;
  237. }
  238. module.exports = {
  239. configSchema,
  240. inputSchema,
  241. outputSchema,
  242. async execute(config, input, context) {
  243. let image = { base64: '', mimeType: '', url: '' };
  244. if (config.imageMode !== 'none') {
  245. image = findImage(input, config.imageField);
  246. if (!image.base64 && image.url) {
  247. smartbotic.log.info('ollama-chat: fetching image from ' + image.url);
  248. const dl = smartbotic.http.request({ method: 'GET', url: image.url, timeout: 60000 });
  249. if (dl.status < 200 || dl.status >= 300) {
  250. throw new Error('Failed to fetch image: HTTP ' + dl.status);
  251. }
  252. image.base64 = typeof dl.data === 'string' ? smartbotic.utils.base64Encode(dl.data) : '';
  253. }
  254. }
  255. const wantJson = config.responseFormat === 'json';
  256. const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
  257. let content = '';
  258. let parsed = null;
  259. let lastError = '';
  260. let used = 0;
  261. for (let attempt = 1; attempt <= attempts; attempt++) {
  262. used = attempt;
  263. try {
  264. content = callOllama(config, image.base64);
  265. if (wantJson) {
  266. parsed = JSON.parse(stripFences(content));
  267. }
  268. lastError = '';
  269. break;
  270. } catch (err) {
  271. lastError = err && err.message ? err.message : String(err);
  272. parsed = null;
  273. smartbotic.log.warn('ollama-chat: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError);
  274. if (attempt < attempts) {
  275. smartbotic.utils.sleep(Number(config.retryDelayMs) || 2000);
  276. }
  277. }
  278. }
  279. const passImage = config.passthroughImage === true;
  280. if (lastError) {
  281. if (config.skipOnError !== true) {
  282. throw new Error('ollama-chat failed after ' + used + ' attempt(s): ' + lastError);
  283. }
  284. smartbotic.log.warn('ollama-chat: skipping after ' + used + ' attempt(s)');
  285. return {
  286. success: false,
  287. error: lastError,
  288. content: content,
  289. json: null,
  290. model: config.model,
  291. attempts: used,
  292. hadImage: image.base64 ? true : false,
  293. imageBase64: passImage ? image.base64 : '',
  294. mimeType: image.mimeType,
  295. sourceUrl: image.url
  296. };
  297. }
  298. return {
  299. success: true,
  300. error: '',
  301. content: content,
  302. json: parsed,
  303. model: config.model,
  304. attempts: used,
  305. hadImage: image.base64 ? true : false,
  306. imageBase64: passImage ? image.base64 : '',
  307. mimeType: image.mimeType,
  308. sourceUrl: image.url
  309. };
  310. }
  311. };