From 939fdb22111b99f1a5c46beb3fa31de9837712f7 Mon Sep 17 00:00:00 2001 From: Zacharias Date: Sat, 25 Jul 2026 21:25:41 +0200 Subject: [PATCH] Added support for Imagaes, Ollama model thinking, and streaming of the responce --- API/build.gradle | 2 + Core/build.gradle | 2 +- .../src/main/java/me/neurodock/core/Core.java | 95 +++++++++++++++---- .../me/neurodock/ollama/OllamaObject.java | 21 +++- Core/src/test/java/OllamaImages.java | 53 +++++++++++ 5 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 Core/src/test/java/OllamaImages.java diff --git a/API/build.gradle b/API/build.gradle index 4d6f181..03d6a86 100644 --- a/API/build.gradle +++ b/API/build.gradle @@ -14,6 +14,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web:4.1.0-M4' implementation 'org.springframework.boot:spring-boot-starter-webflux:4.1.0-M4' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0-M1' + testImplementation 'org.springframework.boot:spring-boot-starter-test:4.1.0-M4' + //implementation 'org.springframework.boot:spring-boot-starter-actuator' testImplementation 'org.springframework.boot:spring-boot-starter-test:4.1.0-M4' diff --git a/Core/build.gradle b/Core/build.gradle index 3a5ae9f..6bfde9c 100644 --- a/Core/build.gradle +++ b/Core/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java-library' } -version = '1.9.6' +version = '1.9.10' dependencies { implementation project(":Plugin-API") diff --git a/Core/src/main/java/me/neurodock/core/Core.java b/Core/src/main/java/me/neurodock/core/Core.java index 65560a1..5a4cb54 100644 --- a/Core/src/main/java/me/neurodock/core/Core.java +++ b/Core/src/main/java/me/neurodock/core/Core.java @@ -359,7 +359,7 @@ public class Core { private boolean confirmOllama() { try { - URL url = new URL("http://" + ollamaIP + ":" + ollamaPort + "/api/version"); + URL url = URI.create("http://" + ollamaIP + ":" + ollamaPort + "/api/version").toURL(); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("Content-Type", "application/json"); @@ -504,17 +504,52 @@ public class Core { } } + /** + * Queries Ollama and resolves once the full response is available, discarding any + * intermediate streaming chunks along the way. + *

+ * This is a convenience overload of {@link #qurryOllama(Consumer)} with a no-op chunk + * listener. Whether the underlying request is actually sent as a streaming or + * non-streaming HTTP request is determined entirely by this query's {@link OllamaObject}'s + * {@code stream} setting — this method does not itself impose a mode. If the + * {@link OllamaObject} is configured to stream, each intermediate chunk is still received + * and parsed internally, just not exposed to the caller; use + * {@link #qurryOllama(Consumer)} if intermediate chunks are needed. + * + * @return a future that resolves to the final response object once generation completes + */ public CompletableFuture qurryOllama() { return qurryOllama(_ -> {}); } - + /** - * Sends the OllamaObject to Ollama - * @return The response from Ollama + * Queries Ollama, invoking {@code onChunk} for each JSON object received from the server, + * and resolves once the final response is available. + *

+ * Whether this is a streaming or non-streaming exchange is determined by this query's + * {@link OllamaObject}'s {@code stream} setting, not by which overload was called: + *

+ * In both cases, the returned future resolves once, to the final ({@code done: true}) + * response object — {@code onChunk} is for observing progress, the returned future is for + * "the response is complete." + *

+ * If {@link #cancel()} is called while this query is in flight, the underlying connection + * is closed and the returned future completes exceptionally with a + * {@link java.util.concurrent.CancellationException}. + * + * @param onChunk callback invoked for each response object received from Ollama, in + * arrival order; never invoked with {@code null} + * @return a future that resolves to the final response object once generation completes */ - public CompletableFuture qurryOllama(Consumer onChunk) - { + public CompletableFuture qurryOllama(Consumer onChunk) { return CompletableFuture.supplyAsync(() -> { HttpURLConnection connection = null; try { @@ -539,42 +574,60 @@ public class Core { } int responseCode = connection.getResponseCode(); - StringBuilder response = new StringBuilder(); + boolean isStreaming = ollamaObject.isStream(); // whatever the real accessor is + JSONObject last = null; + StringBuilder rawErrorOrDump = new StringBuilder(); + StringBuilder messageContent = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { - if (cancelled.get()) { - throw new CancellationException("Query cancelled mid-response"); + if (cancelled.get()) throw new CancellationException("Query cancelled mid-response"); + if (line.isBlank()) continue; + + rawErrorOrDump.append(line); + + if (isStreaming) { + // Ollama emits one JSON object per line when streaming — parse and + // dispatch each one as it arrives. + JSONObject chunkObj = new JSONObject(line); + last = chunkObj; + if(chunkObj.has("message")) { + messageContent.append(chunkObj.getJSONObject("message").optString("content", "")); + } + onChunk.accept(chunkObj); + } + else + { + last = new JSONObject(line); } - response.append(line); } } catch (IOException ex) { - if (cancelled.get()) { - throw new CancellationException("Query cancelled mid-response"); - } + if (cancelled.get()) throw new CancellationException("Query cancelled mid-response"); InputStream errorStream = connection.getErrorStream(); if (errorStream != null) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(errorStream))) { String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } + while ((line = reader.readLine()) != null) rawErrorOrDump.append(line); } } } + if (isStreaming && last != null && last.has("message")) { + JSONObject finalObj = new JSONObject(last.toString()); // deep-ish copy via re-parse + finalObj.getJSONObject("message").put("content", messageContent.toString()); + last = finalObj; + } + if (responseCode == HttpURLConnection.HTTP_OK) { - return new JSONObject(response.toString()); + return last; } else { printMessageHandler.printErrorMessage(new OllamaMessage(OllamaMessageRole.SYSTEM, - "Error: HTTP Response code - " + responseCode + "\n" + response)); + "Error: HTTP Response code - " + responseCode + "\n" + rawErrorOrDump)); throw new RuntimeException("HTTP Response code - " + responseCode); } } catch (IOException e) { - if (cancelled.get()) { - throw new CancellationException("Query cancelled"); - } + if (cancelled.get()) throw new CancellationException("Query cancelled"); throw new RuntimeException(e); } finally { if (connection != null) connection.disconnect(); diff --git a/Core/src/main/java/me/neurodock/ollama/OllamaObject.java b/Core/src/main/java/me/neurodock/ollama/OllamaObject.java index d1785fe..ada1538 100644 --- a/Core/src/main/java/me/neurodock/ollama/OllamaObject.java +++ b/Core/src/main/java/me/neurodock/ollama/OllamaObject.java @@ -352,7 +352,7 @@ public class OllamaObject { json.put("options", options); json.put("stream", stream); json.put("keep_alive", keep_alive); - json.put("think", thinking); + thinking.putInto(json, "think"); return json; } @@ -683,6 +683,25 @@ public class OllamaObject { this.value = value; } + /** True if this constant belongs to the boolean on/off scheme, false if it's a graded level. */ + public boolean isBoolean() { + return this == TRUE || this == FALSE; + } + + + /** + * Puts this thinking tier into the given JSON object under the given key, using + * whichever JSON type Ollama expects for this constant — a real boolean for + * {@link #TRUE}/{@link #FALSE}, or a string for the graded levels. + */ + public void putInto(JSONObject json, String key) { + if (isBoolean()) { + json.put(key, Boolean.parseBoolean(value)); // real JSON boolean, no quotes + } else { + json.put(key, value); // JSON string, e.g. "high" + } + } + /** * Returns the raw string form of this tier, as expected by Ollama's {@code think} * request field. diff --git a/Core/src/test/java/OllamaImages.java b/Core/src/test/java/OllamaImages.java new file mode 100644 index 0000000..6c34604 --- /dev/null +++ b/Core/src/test/java/OllamaImages.java @@ -0,0 +1,53 @@ +import me.neurodock.core.Core; +import me.neurodock.core.LaunchOptions; +import me.neurodock.core.PrintMessageHandler; +import me.neurodock.ollama.OllamaMessage; +import me.neurodock.ollama.OllamaMessageRole; +import me.neurodock.ollama.OllamaObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.io.File; +import java.util.concurrent.atomic.AtomicReference; + +public class OllamaImages { + @Test + public void test1() + { + AtomicReference i = new AtomicReference<>(); + assertDoesNotThrow(() -> { + File input = new File("../testImage.png"); + i.set(ImageIO.read(input)); + }); + assertNotNull(i.get()); + + LaunchOptions.getInstance().setLoadOld(false); + Core.setDataDirectory("AI-Chat/Test", false); + + Core core = new Core(new PrintMessageHandler() { + @Override + public void printMessage(String message) { + System.out.println(message); + } + + @Override + public boolean color() { + return false; + } + }); + + core.setOllamaObjectNoMemory(OllamaObject.builder() + .setModel("qwen3.5:4b") + .keep_alive(0) + .build()); + + assertDoesNotThrow(() -> { + core.getOllamaObject().addMessage(new OllamaMessage(OllamaMessageRole.USER, "Describe this image", i.get())); + }); + + core.qurryOllama().thenAccept(core::handleResponse).join(); + + } +}