diff --git a/Core/build.gradle b/Core/build.gradle index 7fe01bf..3a5ae9f 100644 --- a/Core/build.gradle +++ b/Core/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java-library' } -version = '1.9.5' +version = '1.9.6' 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 3f290f7..65560a1 100644 --- a/Core/src/main/java/me/neurodock/core/Core.java +++ b/Core/src/main/java/me/neurodock/core/Core.java @@ -19,10 +19,9 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -68,6 +67,14 @@ public class Core { * The URL of the Ollama API. */ private URL url; + /** + * Used to cancel/stop a responce, only usefull if + */ + private AtomicBoolean cancelled = new AtomicBoolean(false); + /** + * The current connection to Ollama + */ + private HttpURLConnection activeConnection; /** * The PrintMessageHandler to use. @@ -346,116 +353,10 @@ public class Core { } } - /** - * This function is pending a better name, as this was introduced from being a generic block. This was ugly, and ideally things here should be refactored more. - * - * TODO: Fix a better name and refactor this method. - */ - @Deprecated(forRemoval = true) - private void constructCore(){ - File dir = new File("./logs/"); - if (!dir.exists()) { - dir.mkdir(); - } - dir = new File("./pythonFiles/"); - if (!dir.exists()) { - dir.mkdir(); - } - dir = new File("./messages"); - if (!dir.exists()) { - dir.mkdir(); - } - - try { - url = new URI("http://"+ollamaIP+":"+ollamaPort+"/api/chat").toURL(); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - - try { - if (logFile.exists()) { - BufferedReader br = new BufferedReader(new FileReader(logFile)); - String line = br.readLine(); - br.close(); - if (line != null) { - String date = line.substring(0, line.indexOf(">")).replaceAll("[/:]", "-"); - logFile.renameTo(new File(logFile.getParentFile(), date + ".log")); - logFile = new File("./logs/latest.log"); - } - else { - System.out.println("Exisitng log file is empty, overwriting it!"); - logFile.delete(); - } - logFile.createNewFile(); - } - logWriter = new BufferedWriter(new FileWriter(logFile)); - }catch (IOException e) { - throw new RuntimeException(e); - } - - this.scheduler = Executors.newScheduledThreadPool(1); - - scheduler.scheduleAtFixedRate(() -> { - try { - logWriter.flush(); - //System.out.println("Buffer flushed to file."); - } catch (IOException e) { - e.printStackTrace(); - } - }, 0, 3, TimeUnit.MINUTES); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - scheduler.shutdownNow(); - try { - try { - logWriter.flush(); - logWriter.close(); - }catch (IOException ignore) - { - // This exception is kinda expected. Since it can often occur that the logWriter is already closed - System.out.println("Failed to flush log file, but that is not a problem."); - } - - LocalDateTime now = LocalDateTime.now(); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd_HH-mm-ss"); - - File messagesFile = new File("./messages/"+now.format(formatter)+".json"); - - BufferedWriter messagesWriter = new BufferedWriter(new FileWriter(messagesFile)); - - JSONArray messages = new JSONArray(); - - for(OllamaMessage message : ollamaObject.getMessages()) { - messages.put(message.toJSON()); - } - - messagesWriter.write(messages.toString()); - messagesWriter.close(); - - File f = new File(DATA_DIR,"messages.json"); - if(f.exists()) - { - f.delete(); - } - f.createNewFile(); - - messagesWriter = new BufferedWriter(new FileWriter(f)); - - messagesWriter.write(messages.toString()); - messagesWriter.close(); - - } catch (IOException e) { - throw new RuntimeException(e); - } - })); - } - /** * A check to exit early if Ollama isn't reachable. */ - private void confirmOllama() + private boolean confirmOllama() { try { URL url = new URL("http://" + ollamaIP + ":" + ollamaPort + "/api/version"); @@ -469,14 +370,14 @@ public class Core { if (responseCode != HttpURLConnection.HTTP_OK) { new RuntimeException("Ollama is un-responsive on url: " + url.toString()).printStackTrace(); - System.exit(1); + return false; } }catch (IOException ex) { ex.printStackTrace(); - System.out.println("Can not reach Ollama!"); - System.exit(1); + return false; } + return true; } /** @@ -593,23 +494,43 @@ public class Core { logWriter.flush(); }catch (IOException e) {} } + + public void cancel() + { + cancelled.set(true); + HttpURLConnection conn = activeConnection; + if (conn != null) { + conn.disconnect(); + } + } + + public CompletableFuture qurryOllama() + { + return qurryOllama(_ -> {}); + } /** * Sends the OllamaObject to Ollama * @return The response from Ollama */ - public CompletableFuture qurryOllama() + public CompletableFuture qurryOllama(Consumer onChunk) { return CompletableFuture.supplyAsync(() -> { + HttpURLConnection connection = null; try { - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection = (HttpURLConnection) url.openConnection(); + activeConnection = connection; + + if (cancelled.get()) { + throw new CancellationException("Query cancelled before request was sent"); + } + connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); connection.setDoOutput(true); connection.setConnectTimeout(80 * 1000); String ollamaObjectString = ollamaObject.toJSON().toString(); - ollamaObjectString = ollamaObjectString.replace("\n", "\\n"); try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) { @@ -618,20 +539,20 @@ public class Core { } int responseCode = connection.getResponseCode(); - - // HTTP_OK or 200 response code generally means that the server ran successfully without any errors StringBuilder response = new StringBuilder(); - // Read response content - // connection.getInputStream() purpose is to obtain an input stream for reading the server's response. - try ( - BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { - response.append(line); // Adds every line to response till the end of file. + if (cancelled.get()) { + throw new CancellationException("Query cancelled mid-response"); + } + response.append(line); + } + } catch (IOException ex) { + if (cancelled.get()) { + throw new CancellationException("Query cancelled mid-response"); } - } catch (Exception ex) { - // If the server returns an error, we read the error stream instead InputStream errorStream = connection.getErrorStream(); if (errorStream != null) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(errorStream))) { @@ -644,17 +565,20 @@ public class Core { } if (responseCode == HttpURLConnection.HTTP_OK) { - - - connection.disconnect(); return new JSONObject(response.toString()); } else { - connection.disconnect(); - printMessageHandler.printErrorMessage(new OllamaMessage(OllamaMessageRole.SYSTEM,"Error: HTTP Response code - " + responseCode + "\n" + response.toString())); + printMessageHandler.printErrorMessage(new OllamaMessage(OllamaMessageRole.SYSTEM, + "Error: HTTP Response code - " + responseCode + "\n" + response)); throw new RuntimeException("HTTP Response code - " + responseCode); } } catch (IOException e) { + if (cancelled.get()) { + throw new CancellationException("Query cancelled"); + } throw new RuntimeException(e); + } finally { + if (connection != null) connection.disconnect(); + activeConnection = null; } }); } diff --git a/Core/src/main/java/me/neurodock/ollama/OllamaMessage.java b/Core/src/main/java/me/neurodock/ollama/OllamaMessage.java index 66ba40d..52aa4f4 100644 --- a/Core/src/main/java/me/neurodock/ollama/OllamaMessage.java +++ b/Core/src/main/java/me/neurodock/ollama/OllamaMessage.java @@ -1,7 +1,17 @@ package me.neurodock.ollama; +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; import org.json.JSONObject; +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.RenderedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; + /** * Represents a message sent by a Tool, Assistant(Ollama), or User. */ @@ -14,6 +24,11 @@ public class OllamaMessage { * The content of the message. */ String content; + /** + * The image content of a message. + * This is a base64 encoded string of either an Image or an Audio file (this is how Ollama dose this, ask them, not me) + */ + ArrayList images; /** * Creates a new instance of OllamaMessage. @@ -25,6 +40,25 @@ public class OllamaMessage { this.content = content; } + /** + * Creates a new instance of OllamaMessage. + * @param role The role of the message + * @param content The content of the message + * @param images The image of the message + * @throws IOException if an error occurs during writing or when not able to create the required ImageOutputStream. + */ + public OllamaMessage(OllamaMessageRole role, String content, @NotNull Image... images) throws IOException + { + this(role, content); + ArrayList base65Images = new ArrayList<>(); + for(Image i : images) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + ImageIO.write((RenderedImage) i, "PNG", stream); + base65Images.add(Base64.getEncoder().encodeToString(stream.toByteArray())); + } + this.images = base65Images; + } + /** * @return The role of who sent this "message", represented as {@link OllamaMessageRole} */ @@ -43,6 +77,10 @@ public class OllamaMessage { JSONObject json = new JSONObject(); json.put("role", role.getRole()); json.put("content", content.replace("\n", "\\n")); + if(images != null && !images.isEmpty()) + { + json.put("images", new JSONArray(images)); + } return json; } } diff --git a/Core/src/main/java/me/neurodock/ollama/OllamaObject.java b/Core/src/main/java/me/neurodock/ollama/OllamaObject.java index d32587c..d1785fe 100644 --- a/Core/src/main/java/me/neurodock/ollama/OllamaObject.java +++ b/Core/src/main/java/me/neurodock/ollama/OllamaObject.java @@ -60,6 +60,10 @@ public class OllamaObject { * The system prompt object used to re-generate the system prompt. */ LLMSystemPrompt systemPrompt; + /** + * The level of thining the model shuld do + */ + Thinking thinking; /** * Creates a new instance of OllamaObject. @@ -72,10 +76,12 @@ public class OllamaObject { * @param keep_alive The keep alive of the Ollama Object. see {@link OllamaObject#keep_alive} * @param prompt The system prompt object of the Ollama Object. See {@link OllamaObject#systemPrompt} */ - private OllamaObject(String model, ArrayList messages, ArrayList> tools, JSONObject format, Map options, boolean stream, String keep_alive, LLMSystemPrompt prompt) { + private OllamaObject(String model, ArrayList messages, ArrayList> tools, JSONObject format, Map options, boolean stream, String keep_alive, LLMSystemPrompt prompt, Thinking thinking) { this.model = model; this.messages = messages; this.systemPrompt = prompt; + this.thinking = thinking; + this.stream = stream; // Reflecting the reflection injection for ALL tools that was added in the OllamaObjectBuilder, etc, etc comment.. its past midnignt and i'm tired. for(Pair tool : tools) @@ -346,6 +352,7 @@ public class OllamaObject { json.put("options", options); json.put("stream", stream); json.put("keep_alive", keep_alive); + json.put("think", thinking); return json; } @@ -394,6 +401,10 @@ public class OllamaObject { * This represents the System Prompt for the LLM */ LLMSystemPrompt systemPrompt; + /** + * Weather the model should think or not (requires supportive model) + */ + Thinking think; /** * Creates a new instance of {@link OllamaObjectBuilder}. @@ -434,10 +445,8 @@ public class OllamaObject { /** * Sets if the Ollama Object is streamed. * @param stream If the Ollama Object is streamed - * @deprecated This should be false due to being broken in the current version of this system * @return The {@link OllamaObjectBuilder} */ - @Deprecated public OllamaObjectBuilder stream(boolean stream) { this.stream = stream; return this; @@ -592,12 +601,114 @@ public class OllamaObject { return this; } + /** + * Sets the thinking tier that should be requested from the model when this + * {@link OllamaObject} is built. + *

+ * Not all models support this. Whether {@code think} has any effect depends + * entirely on the model being queried — models without native thinking support will + * simply ignore it. + *

+ * Among models that do support it, the accepted scheme is not consistent: + *

    + *
  • Some models only support a boolean on/off toggle (thinking enabled or disabled)
  • + *
  • Others support graded levels, e.g. {@code "low"}, {@code "medium"}, {@code "high"}, + * or {@code "max"}
  • + *
+ * Callers are responsible for knowing which scheme the target model expects and supplying + * a {@link Thinking} value it can actually interpret — an unsupported value is not + * guaranteed to fail cleanly and may instead be silently ignored by Ollama. + * + * @param think the thinking tier to request, or {@code null}/equivalent to leave + * thinking behavior at the model's default + * @return this builder, for chaining + */ + public OllamaObjectBuilder setThinkingTier(Thinking think) + { + this.think = think; + return this; + } + /** * Builds the {@link OllamaObject} * @return The {@link OllamaObject} */ public OllamaObject build() { - return new OllamaObject(model, messages, tools, format, options, stream, keep_alive, systemPrompt); + return new OllamaObject(model, messages, tools, format, options, stream, keep_alive, systemPrompt, think); + } + } + + /** + * Represents the "thinking" tier that can be requested from an Ollama model via the + * {@code think} request field. + *

+ * Support for this varies by model, and models that do support it do not agree on a + * single scheme: + *

    + *
  • Some models only understand a boolean toggle — {@link #TRUE} / {@link #FALSE}
  • + *
  • Others understand graded levels — {@link #LOW}, {@link #MEDIUM}, {@link #HIGH}, + * {@link #MAX}
  • + *
+ * All six values live in this single enum for convenience, but mixing schemes on a model + * that doesn't support the one you pick is a caller error — e.g. sending {@link #HIGH} to + * a model that only understands {@link #TRUE}/{@link #FALSE} is not guaranteed to fail + * cleanly and may simply be ignored. Callers must know which scheme their target model + * expects before choosing a value here. + */ + public static enum Thinking { + + /** Thinking enabled, for models using the boolean on/off scheme. */ + TRUE("true"), + + /** Thinking disabled, for models using the boolean on/off scheme. */ + FALSE("false"), + + /** Lowest thinking effort, for models using the graded-level scheme. */ + LOW("low"), + + /** Moderate thinking effort, for models using the graded-level scheme. */ + MEDIUM("medium"), + + /** High thinking effort, for models using the graded-level scheme. */ + HIGH("high"), + + /** Maximum thinking effort, for models using the graded-level scheme. */ + MAX("max"); + + /** The raw string value sent to Ollama's {@code think} request field. */ + final String value; + + private Thinking(String value) + { + this.value = value; + } + + /** + * Returns the raw string form of this tier, as expected by Ollama's {@code think} + * request field. + * + * @return the raw value, e.g. {@code "true"} or {@code "high"} + */ + public String getValue() + { + return value; + } + + /** + * Resolves a {@link Thinking} constant from its raw string value. + *

+ * Matching is case-insensitive; the input is lowercased before comparison. + * + * @param value the raw value to resolve, e.g. {@code "true"} or {@code "HIGH"} + * @return the matching {@link Thinking} constant + * @throws IllegalArgumentException if no constant matches the given value + */ + public static Thinking fromValue(String value) { + for(Thinking roleRole : values()) { + if(roleRole.value.equals(value.toLowerCase())) + return roleRole; + } + throw new IllegalArgumentException("Invalid value: " + value); } } }