Just to break it down, all AI related tools use API's - the only thing that n8n or node-red for that matter, can bring, is to sit in between and do the API translations and deal with the responses, ie. converting some object to another object and communicate from/to another tool.
ollama has a npm package that already can be used in a function node, but then again, ollama has a rest API that can also be called via a http request node (which the npm package abstracts, and it provides streaming responses)
I have been working on implementing RAG using node-red and use ollama via NR to make it all work. n8n might be faster in the initial setup as the workflows are more geared towards it, but I see no reason to pick it over NR.
If you are interested, I have created a simple chat interface running via node-red to interact with ollama.
This is the flow.
[{"id":"0a050a4b1676969e","type":"function","z":"60fdaddad83dd177","name":"ollama chat","func":"const server = flow.get(\"ollama_server\")\nconst {res,req} = msg\nconst o = new ollama.Ollama({ host: `${server.host}:${server.port}` })\nconst message = { role: 'user', content: msg.payload }\nconst response = await o.chat({ model: server.model, messages: [message], stream: true })\n// @ts-ignore\nfor await (const part of response) {\n node.send({payload:part,req,res})\n}\n\nreturn null\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"ollama","module":"ollama"}],"x":350,"y":180,"wires":[["88b9ffdb65494f2c"]]},{"id":"d934559fa2d213d0","type":"inject","z":"60fdaddad83dd177","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":125,"y":60,"wires":[["dd8ebd3976b2de10"]],"l":false},{"id":"dd8ebd3976b2de10","type":"template","z":"60fdaddad83dd177","name":"ollama server address","field":"ollama_server","fieldType":"flow","format":"yaml","syntax":"mustache","template":"host: 10.0.0.191\nport: 11434\nmodel: 'llama3'","output":"yaml","x":260,"y":60,"wires":[[]]},{"id":"d4c0b223cd7fdbc3","type":"websocket in","z":"60fdaddad83dd177","name":"","server":"356c7f041a66096b","client":"","x":180,"y":180,"wires":[["0a050a4b1676969e"]]},{"id":"88b9ffdb65494f2c","type":"websocket out","z":"60fdaddad83dd177","name":"","server":"356c7f041a66096b","client":"","x":520,"y":180,"wires":[]},{"id":"9f98aa5af9412608","type":"http in","z":"60fdaddad83dd177","name":"","url":"/ollama_chat","method":"get","upload":false,"swaggerDoc":"","x":190,"y":120,"wires":[["6f1ae63ef4fd8653"]]},{"id":"6f1ae63ef4fd8653","type":"template","z":"60fdaddad83dd177","name":"html","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Ollama chat</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.14.0/cdn.min.js\" defer></script>\n <script src=\"https://unpkg.com/@vimesh/style\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n <style>\n pre {\n margin: 24px 0px !important;\n background-color: #475569;\n color: #fff;\n padding: 12px;\n border-radius: 3px;\n cursor: pointer;\n }\n\n ul,\n ol {\n list-style-type: decimal;\n list-style-position: inside;\n margin-left: 8px;\n margin-top: 8px;\n margin-bottom: 8px;\n }\n ol li {\n margin-top: 8px;\n }\n ul ul,\n ol ul {\n list-style-type: circle;\n list-style-position: inside;\n margin-left: 15px;\n }\n ol ol,\n ul ol {\n list-style-type: lower-latin;\n list-style-position: inside;\n margin-left: 15px;\n }\n p {\n margin-top: 8px;\n margin-bottom: 8px;\n }\n </style>\n </head>\n <body class=\"font-sans bg-zinc-300\">\n <div class=\"flex-1 justify-between flex flex-col h-screen\" x-data=\"load()\">\n <div id=\"messages\" class=\"flex flex-col p-4 overflow-y-auto scrollbar-thumb-blue scrollbar-thumb-rounded scrollbar-track-blue-lighter scrollbar-w-2 scrolling-touch\">\n <template x-for=\"r in responses\">\n <div class=\"space-y-4\">\n <template x-if=\"r.q\">\n <div class=\"chat-message mb-4 mt-4 cursor-pointer\" @click=\"document.querySelector('#chat_input').value = r.q\">\n <div class=\"flex items-end justify-end\">\n <div class=\"flex flex-col space-y-2 text-sm max-w-xs mx-2 order-1 items-end\">\n <div><span class=\"px-4 py-2 rounded-lg inline-block bg-indigo-500 text-white\" x-text=\"r.q\"></span></div>\n </div>\n </div>\n </div>\n </template>\n <div class=\"chat-message mt-2\">\n <div class=\"flex items-end\">\n <div class=\"flex flex-col text-sm max-w-3xl mx-2 order-2 items-start\">\n <div><span class=\"px-4 py-2 rounded-lg inline-block bg-gray-100 text-gray-700\" :class=\"r.response.length>0 ? '' : 'hidden'\" x-html=\"r.response.length>0 ? marked.parse(r.response) : '' \"></span></div>\n </div>\n </div>\n </div>\n </div>\n </template>\n <div class=\"chat-message\">\n <div class=\"flex items-end\">\n <div class=\"flex flex-col space-y-2 text-sm max-w-3xl mx-2 order-2 items-start\">\n <div>\n <template x-if=\"waiting && resp.length ==0\">\n <span class=\"px-4 py-2 rounded-lg inline-block bg-gray-100 text-gray-700 animate-pulse\" x-text=\"waiting && resp.length ==0 ? '...' : ''\"></span>\n </template>\n <span class=\"px-4 py-2 rounded-lg inline-block bg-gray-100 text-gray-700\" x-html=\"marked.parse(resp)\" :class=\"resp.length== 0 ? 'hidden':''\"></span>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class=\"bg-white border-t border-gray-300 p-4 mb-2 sm:mb-0\">\n <div class=\"relative flex items-start\">\n <textarea id=\"chat_input\" @keyup.ctrl.enter=\"chat()\" rows=\"4\" type=\"text\" placeholder=\"Ask a question\" class=\"max-h-[80px] w-full overflow-y-auto focus:outline-none focus:border-indigo-500 focus:placeholder-gray-500 text-gray-600 placeholder-gray-400 px-4 py-3 bg-gray-100 rounded-md border-2 border-gray-300\"></textarea>\n <div class=\"right-4 items-center inset-y-0 hidden sm:flex\">\n <button type=\"button\" class=\"inline-flex items-center justify-center rounded-lg px-3 py-2 ml-2 transition duration-500 ease-in-out text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none\" @click=\"chat()\">\n <span class=\"font-semibold text-sm\">Send</span>\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <script>\n const el = document.getElementById(\"messages\")\n const data = []\n const responses = []\n\n let resp\n let code_elements\n let waiting = false\n function load() {\n return {\n init() {\n this.responses = responses\n\n this.resp = \"\"\n this.ws = new WebSocket(location.origin.replace(/^http/, 'ws') + '/ws/ollama')\n this.ws.onopen = (event) => {\n this.responses.push({ q: false, response: \"Ready for input...\" })\n }\n this.ws.onerror = (event) => {\n this.responses.push({ q: false, response: \"Error connecting to ollama.\" })\n }\n\n this.ws.onmessage = (event) => {\n const input = JSON.parse(event.data)\n el.scrollTop = el.scrollHeight\n this.waiting = true\n this.resp += input.message.content\n if (input.done) {\n const responseIndex = this.responses.length - 1\n this.responses[responseIndex].response = this.resp\n this.resp = \"\"\n this.waiting = false\n el.scrollTop = el.scrollHeight\n }\n }\n },\n chat() {\n const q = document.getElementById(\"chat_input\").value\n this.responses.push({ q, response: false })\n\n this.ws.send(q)\n this.waiting = true\n document.getElementById(\"chat_input\").value = \"\"\n setTimeout(() => {\n el.scrollTop = el.scrollHeight\n }, 100)\n },\n }\n }\n </script>\n </body>\n</html>\n","output":"str","x":370,"y":120,"wires":[["c08e76930daefda7"]]},{"id":"c08e76930daefda7","type":"http response","z":"60fdaddad83dd177","name":"","statusCode":"","headers":{},"x":510,"y":120,"wires":[]},{"id":"356c7f041a66096b","type":"websocket-listener","path":"/ws/ollama","wholemsg":"false"}]
Just modify the 'ollama server address' node to set the host and the model.
Which generates an interface on /ollama_chat
and streams the responses.